/**
 * SF Auto-Worktree -- lifecycle management for autonomous mode worktrees.
 *
 * Autonomous mode creates worktrees with `milestone/<MID>` branches (distinct from
 * manual `/worktree` which uses `worktree/<name>` branches). This module
 * manages create, enter, detect, and teardown for autonomous mode worktrees.
 */
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import {
	cpSync,
	existsSync,
	lstatSync as lstatSyncFn,
	mkdirSync,
	readdirSync,
	readFileSync,
	realpathSync,
	rmSync,
	statSync,
	unlinkSync,
} from "node:fs";
import { isAbsolute, join, sep as pathSep } from "node:path";
import { atomicWriteSync } from "./atomic-write.js";
import { debugLog } from "./debug-logger.js";
import { getErrorMessage } from "./error-utils.js";
import { SF_GIT_ERROR, SF_IO_ERROR, SFError } from "./errors.js";
import {
	MergeConflictError,
	RUNTIME_EXCLUSION_PATHS,
	readIntegrationBranch,
} from "./git-service.js";
import { emitJournalEvent } from "./journal.js";
import {
	nativeAddAllWithExclusions,
	nativeAddPaths,
	nativeBranchDelete,
	nativeBranchExists,
	nativeCheckoutBranch,
	nativeCheckoutTheirs,
	nativeCommit,
	nativeConflictFiles,
	nativeDetectMainBranch,
	nativeDiffNumstat,
	nativeGetCurrentBranch,
	nativeIsAncestor,
	nativeMergeAbort,
	nativeMergeSquash,
	nativeRmForce,
	nativeUpdateRef,
	nativeWorkingTreeStatus,
} from "./native-git-bridge.js";
import { sfRoot } from "./paths.js";
import { loadEffectiveSFPreferences } from "./preferences.js";
import { isInsideWorktree } from "./repo-identity.js";
import { safeCopy, safeCopyRecursive } from "./safe-fs.js";
import {
	getMilestone,
	getMilestoneSlices,
	isDbAvailable,
	reconcileWorktreeDb,
} from "./sf-db.js";
import { sfHome } from "./sf-home.js";
import { logError, logWarning } from "./workflow-logger.js";
import { detectWorktreeName, nudgeGitBranchCache } from "./worktree.js";
import {
	createWorktree,
	isInsideWorktreesDir,
	removeWorktree,
	resolveGitDir,
	worktreePath,
} from "./worktree-manager.js";

const PROJECT_PREFERENCES_FILE = "preferences.yaml";
// ─── Shared Constants & Helpers ─────────────────────────────────────────────
/**
 * Root-level .sf/ state files synced between worktree and project root.
 * Single source of truth — used by syncSfStateToWorktree, syncWorktreeStateBack,
 * and the dispatch-level sync functions.
 */
const ROOT_STATE_FILES = [
	"DECISIONS.md",
	"REQUIREMENTS.md",
	"PROJECT.md",
	"KNOWLEDGE.md",
	"OVERRIDES.md",
	"QUEUE.md",
	"completed-units.json",
	"metrics.json",
	"mcp.json",
	// NOTE: project preferences are intentionally NOT in ROOT_STATE_FILES.
	// Forward-sync (main → worktree) is handled explicitly in syncSfStateToWorktree().
	// Back-sync (worktree → main) must NEVER overwrite the project root's copy
	// because the project root is authoritative for preferences (#2684).
];
/**
 * Check if two filesystem paths resolve to the same real location.
 * Returns false if either path cannot be resolved (e.g. doesn't exist).
 */
function isSamePath(a, b) {
	try {
		return realpathSync(a) === realpathSync(b);
	} catch (e) {
		logWarning("worktree", `isSamePath failed: ${e.message}`);
		return false;
	}
}
// ─── ASSESSMENT Force-Sync Helper (#2821) ─────────────────────────────────
/** Regex matching YAML frontmatter `verdict:` field. */
const VERDICT_RE = /verdict:\s*[\w-]+/i;
/**
 * Walk a milestone directory and force-overwrite ASSESSMENT files in the
 * destination when the source copy contains a `verdict:` field.
 *
 * This is the targeted fix for the UAT stuck-loop (#2821): the main
 * safeCopyRecursive uses force:false to protect worktree-authoritative
 * files (#1886), but ASSESSMENT files written by run-uat must be
 * forward-synced when the project root has a verdict. Without this,
 * the worktree retains a stale FAIL or missing ASSESSMENT and
 * checkNeedsRunUat re-dispatches run-uat indefinitely.
 *
 * Only overwrites when the source has a verdict — never clobbers a
 * worktree ASSESSMENT with a verdictless project-root copy.
 */
function forceOverwriteAssessmentsWithVerdict(
	srcMilestoneDir,
	dstMilestoneDir,
) {
	if (!existsSync(srcMilestoneDir)) return;
	// Walk slices/<SID>/ looking for *-ASSESSMENT.md files
	const slicesDir = join(srcMilestoneDir, "slices");
	if (!existsSync(slicesDir)) return;
	try {
		for (const sliceEntry of readdirSync(slicesDir, { withFileTypes: true })) {
			if (!sliceEntry.isDirectory()) continue;
			const srcSliceDir = join(slicesDir, sliceEntry.name);
			const dstSliceDir = join(dstMilestoneDir, "slices", sliceEntry.name);
			try {
				for (const fileEntry of readdirSync(srcSliceDir, {
					withFileTypes: true,
				})) {
					if (!fileEntry.isFile()) continue;
					if (!fileEntry.name.endsWith("-ASSESSMENT.md")) continue;
					const srcFile = join(srcSliceDir, fileEntry.name);
					try {
						const srcContent = readFileSync(srcFile, "utf-8");
						if (!VERDICT_RE.test(srcContent)) continue; // no verdict in source — skip
						// Source has a verdict — force-copy into worktree
						mkdirSync(dstSliceDir, { recursive: true });
						safeCopy(srcFile, join(dstSliceDir, fileEntry.name), {
							force: true,
						});
					} catch (err) {
						/* non-fatal per file */
						logWarning(
							"worktree",
							`assessment force-copy failed: ${getErrorMessage(err)}`,
						);
					}
				}
			} catch (err) {
				/* non-fatal per slice */
				logWarning(
					"worktree",
					`assessment slice scan failed: ${getErrorMessage(err)}`,
				);
			}
		}
	} catch (err) {
		/* non-fatal */
		logWarning("worktree", `assessment sync failed: ${getErrorMessage(err)}`);
	}
}
// ─── Module State ──────────────────────────────────────────────────────────
/** Original project root before chdir into auto-worktree. */
let originalBase = null;
function clearProjectRootStateFiles(basePath, milestoneId) {
	const sfDir = sfRoot(basePath);
	const transientFiles = [
		join(sfDir, "STATE.md"),
		join(sfDir, "auto.lock"),
		join(sfDir, "milestones", milestoneId, `${milestoneId}-META.json`),
	];
	for (const file of transientFiles) {
		try {
			unlinkSync(file);
		} catch (err) {
			// ENOENT is expected — file may not exist (#3597)
			if (err.code !== "ENOENT") {
				logWarning("worktree", `file unlink failed: ${getErrorMessage(err)}`);
			}
		}
	}
	// Clean up entire synced milestone directory and runtime/units.
	// syncStateToProjectRoot() copies these into the project root during
	// execution.  If they remain as untracked files when we attempt
	// `git merge --squash`, git rejects the merge with "local changes would
	// be overwritten", causing silent data loss (#1738).
	const syncedDirs = [
		join(sfDir, "milestones", milestoneId),
		join(sfDir, "runtime", "units"),
	];
	for (const dir of syncedDirs) {
		try {
			if (existsSync(dir)) {
				// Only remove files that are untracked by git — tracked files are
				// managed by the branch checkout and should not be deleted.
				const untrackedOutput = execFileSync(
					"git",
					["ls-files", "--others", "--exclude-standard", dir],
					{
						cwd: basePath,
						stdio: ["ignore", "pipe", "pipe"],
						encoding: "utf-8",
					},
				).trim();
				if (untrackedOutput) {
					for (const f of untrackedOutput.split("\n").filter(Boolean)) {
						try {
							unlinkSync(join(basePath, f));
						} catch (err) {
							// ENOENT/EISDIR are expected for already-removed or directory entries (#3597)
							const code = err.code;
							if (code !== "ENOENT" && code !== "EISDIR") {
								logWarning(
									"worktree",
									`untracked file unlink failed: ${getErrorMessage(err)}`,
								);
							}
						}
					}
				}
			}
		} catch (err) {
			/* non-fatal — git command may fail if not in repo */
			logWarning(
				"worktree",
				`untracked file cleanup failed: ${getErrorMessage(err)}`,
			);
		}
	}
}
// ─── Build Artifact Auto-Resolve ─────────────────────────────────────────────
/** Patterns for machine-generated build artifacts that can be safely
 * auto-resolved by accepting --theirs during merge. These files are
 * regenerable and never contain meaningful manual edits. */
export const SAFE_AUTO_RESOLVE_PATTERNS = [
	/\.tsbuildinfo$/,
	/\.pyc$/,
	/\/__pycache__\//,
	/\.DS_Store$/,
	/\.map$/,
];
/** Returns true if the file path is safe to auto-resolve during merge.
 * Covers `.sf/` state files and common build artifacts. */
export const isSafeToAutoResolve = (filePath) =>
	filePath.startsWith(".sf/") ||
	SAFE_AUTO_RESOLVE_PATTERNS.some((re) => re.test(filePath));
// ─── Dispatch-Level Sync (project root ↔ worktree) ──────────────────────────
/**
 * Sync milestone artifacts from project root INTO worktree before deriveState.
 * Covers the case where the LLM wrote artifacts to the main repo filesystem
 * (e.g. via absolute paths) but the worktree has stale data. Also deletes
 * sf.db in the worktree so it rebuilds from fresh disk state (#853).
 * Non-fatal — sync failure should never block dispatch.
 */
export function syncProjectRootToWorktree(
	projectRoot,
	worktreePath_,
	milestoneId,
) {
	if (!worktreePath_ || !projectRoot || worktreePath_ === projectRoot) return;
	if (!milestoneId) return;
	const prSf = join(projectRoot, ".sf");
	const wtSf = join(worktreePath_, ".sf");
	// When .sf is a symlink to the same external directory in both locations,
	// cpSync rejects the copy because source === destination (ERR_FS_CP_EINVAL).
	// Compare realpaths and skip when they resolve to the same physical path (#2184).
	if (isSamePath(prSf, wtSf)) return;
	// Copy milestone directory from project root to worktree — additive only.
	// force:false prevents cpSync from overwriting existing worktree files.
	// Without this, worktree-authoritative files (e.g. VALIDATION.md written
	// by validate-milestone) get clobbered by stale project root copies,
	// causing an infinite re-validation loop (#1886).
	safeCopyRecursive(
		join(prSf, "milestones", milestoneId),
		join(wtSf, "milestones", milestoneId),
		{ force: false },
	);
	// Force-sync ASSESSMENT files that have a verdict from project root (#2821).
	// The additive-only copy above preserves worktree-authoritative files, but
	// ASSESSMENT files are special: after run-uat writes a verdict and post-unit
	// syncs it to the project root, the worktree may retain a stale copy (e.g.
	// verdict:fail while the project root has verdict:pass from a retry). On
	// session resume the DB is rebuilt from disk, and if the stale ASSESSMENT
	// persists, checkNeedsRunUat finds no passing verdict → re-dispatches
	// run-uat indefinitely (stuck-loop ×9).
	forceOverwriteAssessmentsWithVerdict(
		join(prSf, "milestones", milestoneId),
		join(wtSf, "milestones", milestoneId),
	);
	// Forward-sync completed-units.json from project root to worktree.
	// Project root is authoritative for completion state after crash recovery;
	// without this, the worktree re-dispatches already-completed units (#1886).
	safeCopy(
		join(prSf, "completed-units.json"),
		join(wtSf, "completed-units.json"),
		{ force: true },
	);
	// Delete worktree sf.db ONLY if it is empty (0 bytes).
	// An empty DB is stale/corrupt and should be rebuilt (#853).
	// A non-empty DB was populated by sf-migrate on respawn and must be
	// preserved — deleting it truncates the file to 0 bytes when
	// openDatabase re-creates it, causing "no such table" failures (#2815).
	try {
		const wtDb = join(wtSf, "sf.db");
		let deleteSidecars = false;
		if (existsSync(wtDb)) {
			const size = statSync(wtDb).size;
			if (size === 0) {
				unlinkSync(wtDb);
				deleteSidecars = true;
			}
		} else {
			// Main DB already missing — sidecars are orphaned from a previous
			// partial cleanup and must still be removed.
			deleteSidecars = true;
		}
		// Always clean up WAL/SHM sidecar files when the main DB was deleted
		// or is already missing. Orphaned WAL/SHM files cause SQLite WAL
		// recovery on next open, which triggers a CPU spin on Node 26's
		// node:sqlite DatabaseSync implementation (#2478).
		if (deleteSidecars) {
			for (const suffix of ["-wal", "-shm"]) {
				const f = wtDb + suffix;
				if (existsSync(f)) {
					unlinkSync(f);
				}
			}
		}
	} catch (err) {
		/* non-fatal */
		logWarning(
			"worktree",
			`worktree DB cleanup failed: ${getErrorMessage(err)}`,
		);
	}
}
/**
 * Sync dispatch-critical .sf/ state files from worktree to project root.
 * Only runs when inside an auto-worktree (worktreePath differs from projectRoot).
 * Copies: STATE.md + active milestone directory (roadmap, slice plans, task summaries).
 * Non-fatal — sync failure should never block dispatch.
 */
export function syncStateToProjectRoot(
	worktreePath_,
	projectRoot,
	milestoneId,
) {
	if (!worktreePath_ || !projectRoot || worktreePath_ === projectRoot) return;
	if (!milestoneId) return;
	const wtSf = join(worktreePath_, ".sf");
	const prSf = join(projectRoot, ".sf");
	// When .sf is a symlink to the same external directory in both locations,
	// cpSync rejects the copy because source === destination (ERR_FS_CP_EINVAL).
	// Compare realpaths and skip when they resolve to the same physical path (#2184).
	if (isSamePath(wtSf, prSf)) return;
	// 1. STATE.md — the quick-glance status used by initial deriveState()
	safeCopy(join(wtSf, "STATE.md"), join(prSf, "STATE.md"), { force: true });
	// 2. Milestone directory — ROADMAP, slice PLANs, task summaries
	// Copy the entire milestone .sf subtree so deriveState reads current checkboxes
	safeCopyRecursive(
		join(wtSf, "milestones", milestoneId),
		join(prSf, "milestones", milestoneId),
		{ force: true },
	);
	// 3. metrics.json — session cost/token tracking (#2313).
	// Without this, metrics accumulated in the worktree are invisible from the
	// project root and never appear in the dashboard or skill-health reports.
	safeCopy(join(wtSf, "metrics.json"), join(prSf, "metrics.json"), {
		force: true,
	});
	// 4. Runtime records — unit dispatch state used by selfHealRuntimeRecords().
	// Without this, a crash during a unit leaves the runtime record only in the
	// worktree. If the next session resolves basePath before worktree re-entry,
	// selfHeal can't find or clear the stale record (#769).
	safeCopyRecursive(
		join(wtSf, "runtime", "units"),
		join(prSf, "runtime", "units"),
		{ force: true },
	);
}
// ─── Resource Staleness ───────────────────────────────────────────────────
/**
 * Read the resource version (semver) from the managed-resources manifest.
 * Uses sfVersion instead of syncedAt so that launching a second session
 * doesn't falsely trigger staleness (#804).
 */
export function readResourceVersion() {
	const agentDir = process.env.SF_CODING_AGENT_DIR || join(sfHome(), "agent");
	const manifestPath = join(agentDir, "managed-resources.json");
	try {
		const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
		return typeof manifest?.sfVersion === "string" ? manifest.sfVersion : null;
	} catch (e) {
		logWarning("worktree", `readResourceVersion failed: ${e.message}`);
		return null;
	}
}
/**
 * Check if managed resources have been updated since session start.
 * Returns a warning message if stale, null otherwise.
 */
export function checkResourcesStale(versionOnStart) {
	if (versionOnStart === null) return null;
	const current = readResourceVersion();
	if (current === null) return null;
	if (current !== versionOnStart) {
		return "SF resources were updated since this session started. Restart sf to load the new code.";
	}
	return null;
}
// ─── Stale Worktree Escape ────────────────────────────────────────────────
/**
 * Detect and escape a stale worktree cwd (#608).
 *
 * After milestone completion + merge, the worktree directory is removed but
 * the process cwd may still point inside `.sf/worktrees/<MID>/`.
 * When a new session starts, `process.cwd()` is passed as `base` to startAuto
 * and all subsequent writes land in the wrong directory. This function detects
 * that scenario and chdir back to the project root.
 *
 * Returns the corrected base path.
 */
export function escapeStaleWorktree(base) {
	// Direct layout: /.sf/worktrees/
	const directMarker = `${pathSep}.sf${pathSep}worktrees${pathSep}`;
	let idx = base.indexOf(directMarker);
	if (idx === -1) {
		// Symlink-resolved layout: /.sf/projects/<hash>/worktrees/
		const symlinkRe = new RegExp(
			`\\${pathSep}\\.sf\\${pathSep}projects\\${pathSep}[a-f0-9]+\\${pathSep}worktrees\\${pathSep}`,
		);
		const match = base.match(symlinkRe);
		if (!match || match.index === undefined) return base;
		idx = match.index;
	}
	// base is inside .sf/worktrees/<something> — extract the project root
	const projectRoot = base.slice(0, idx);
	// Guard: If the candidate project root's .sf IS the user-level ~/.sf,
	// the string-slice heuristic matched the wrong /.sf/ boundary. This happens
	// when .sf is a symlink into ~/.sf/projects/<hash> and process.cwd()
	// resolved through the symlink. Returning ~ would be catastrophic (#1676).
	const candidateSf = join(projectRoot, ".sf").replaceAll("\\", "/");
	const sfHomePath = sfHome().replaceAll("\\", "/");
	if (candidateSf === sfHomePath || candidateSf.startsWith(sfHomePath + "/")) {
		// Don't chdir to home — return base unchanged.
		// resolveProjectRoot() in worktree.ts has the full git-file-based recovery
		// and will be called by the caller (startAuto → projectRoot()).
		return base;
	}
	try {
		process.chdir(projectRoot);
	} catch (e) {
		// If chdir fails, return the original — caller will handle errors downstream
		logWarning("worktree", `escapeStaleWorktree chdir failed: ${e.message}`);
		return base;
	}
	return projectRoot;
}
/**
 * Clean stale runtime unit files for completed milestones.
 *
 * After restart, stale runtime/units/*.json from prior milestones can
 * cause deriveState to resume the wrong milestone (#887). Removes files
 * for milestones that have a SUMMARY (fully complete).
 */
export function cleanStaleRuntimeUnits(sfRootPath, hasMilestoneSummary) {
	const runtimeUnitsDir = join(sfRootPath, "runtime", "units");
	if (!existsSync(runtimeUnitsDir)) return 0;
	let cleaned = 0;
	try {
		for (const file of readdirSync(runtimeUnitsDir)) {
			if (!file.endsWith(".json")) continue;
			const midMatch = file.match(/(M\d+(?:-[a-z0-9]{6})?)/);
			if (!midMatch) continue;
			if (hasMilestoneSummary(midMatch[1])) {
				try {
					unlinkSync(join(runtimeUnitsDir, file));
					cleaned++;
				} catch (err) {
					/* non-fatal */
					logWarning(
						"worktree",
						`stale runtime unit unlink failed (${file}): ${getErrorMessage(err)}`,
					);
				}
			}
		}
	} catch (err) {
		/* non-fatal */
		logWarning(
			"worktree",
			`stale runtime unit cleanup failed: ${getErrorMessage(err)}`,
		);
	}
	return cleaned;
}
// ─── Worktree ↔ Main Repo Sync (#1311) ──────────────────────────────────────
/**
 * Sync .sf/ state from the main repo into the worktree.
 *
 * When .sf/ is a symlink to the external state directory, both the main
 * repo and worktree share the same directory — no sync needed.
 *
 * When .sf/ is a real directory (e.g., git-tracked or manage_gitignore:false),
 * the worktree has its own copy that may be stale. This function copies
 * missing milestones, CONTEXT, ROADMAP, DECISIONS, REQUIREMENTS, and
 * PROJECT files from the main repo's .sf/ into the worktree's .sf/.
 *
 * Only adds missing content — never overwrites existing files in the worktree
 * (the worktree's execution state is authoritative for in-progress work).
 */
export function syncSfStateToWorktree(mainBasePath, worktreePath_) {
	const mainSf = sfRoot(mainBasePath);
	const wtSf = sfRoot(worktreePath_);
	const synced = [];
	// If both resolve to the same directory (symlink), no sync needed
	if (isSamePath(mainSf, wtSf)) return { synced };
	if (!existsSync(mainSf) || !existsSync(wtSf)) return { synced };
	// Sync root-level .sf/ files (DECISIONS, REQUIREMENTS, PROJECT, KNOWLEDGE, etc.)
	for (const f of ROOT_STATE_FILES) {
		const src = join(mainSf, f);
		const dst = join(wtSf, f);
		if (existsSync(src) && !existsSync(dst)) {
			try {
				cpSync(src, dst);
				synced.push(f);
			} catch (err) {
				/* non-fatal */
				logWarning(
					"worktree",
					`file copy failed (${f}): ${getErrorMessage(err)}`,
				);
			}
		}
	}
	// Forward-sync project preferences from project root to worktree (additive only).
	{
		const worktreeHasPreferences = existsSync(
			join(wtSf, PROJECT_PREFERENCES_FILE),
		);
		if (!worktreeHasPreferences) {
			const src = join(mainSf, PROJECT_PREFERENCES_FILE);
			const dst = join(wtSf, PROJECT_PREFERENCES_FILE);
			if (existsSync(src)) {
				try {
					cpSync(src, dst);
					synced.push(PROJECT_PREFERENCES_FILE);
				} catch (err) {
					/* non-fatal */
					logWarning(
						"worktree",
						`preferences copy failed (${PROJECT_PREFERENCES_FILE}): ${getErrorMessage(err)}`,
					);
				}
			}
		}
	}
	// Sync milestones: copy entire milestone directories that are missing
	const mainMilestonesDir = join(mainSf, "milestones");
	const wtMilestonesDir = join(wtSf, "milestones");
	if (existsSync(mainMilestonesDir)) {
		try {
			mkdirSync(wtMilestonesDir, { recursive: true });
			const mainMilestones = readdirSync(mainMilestonesDir, {
				withFileTypes: true,
			})
				.filter((d) => d.isDirectory())
				.map((d) => d.name);
			for (const mid of mainMilestones) {
				const srcDir = join(mainMilestonesDir, mid);
				const dstDir = join(wtMilestonesDir, mid);
				if (!existsSync(dstDir)) {
					// Entire milestone missing from worktree — copy it
					try {
						cpSync(srcDir, dstDir, { recursive: true });
						synced.push(`milestones/${mid}/`);
					} catch (err) {
						/* non-fatal */
						logWarning(
							"worktree",
							`milestone copy failed (${mid}): ${getErrorMessage(err)}`,
						);
					}
				} else {
					// Milestone directory exists but may be missing files (stale snapshot).
					// Sync individual top-level milestone files (CONTEXT, ROADMAP, RESEARCH, etc.)
					try {
						const srcFiles = readdirSync(srcDir).filter(
							(f) => f.endsWith(".md") || f.endsWith(".json"),
						);
						for (const f of srcFiles) {
							const srcFile = join(srcDir, f);
							const dstFile = join(dstDir, f);
							if (!existsSync(dstFile)) {
								try {
									const srcStat = lstatSyncFn(srcFile);
									if (srcStat.isFile()) {
										cpSync(srcFile, dstFile);
										synced.push(`milestones/${mid}/${f}`);
									}
								} catch (err) {
									/* non-fatal */
									logWarning(
										"worktree",
										`milestone file copy failed (${mid}/${f}): ${getErrorMessage(err)}`,
									);
								}
							}
						}
						// Sync slices directory if it exists in main but not in worktree
						const srcSlicesDir = join(srcDir, "slices");
						const dstSlicesDir = join(dstDir, "slices");
						if (existsSync(srcSlicesDir) && !existsSync(dstSlicesDir)) {
							try {
								cpSync(srcSlicesDir, dstSlicesDir, { recursive: true });
								synced.push(`milestones/${mid}/slices/`);
							} catch (err) {
								/* non-fatal */
								logWarning(
									"worktree",
									`slices copy failed (${mid}): ${getErrorMessage(err)}`,
								);
							}
						} else if (existsSync(srcSlicesDir) && existsSync(dstSlicesDir)) {
							// Both exist — sync missing slice directories
							const srcSlices = readdirSync(srcSlicesDir, {
								withFileTypes: true,
							})
								.filter((d) => d.isDirectory())
								.map((d) => d.name);
							for (const sid of srcSlices) {
								const srcSlice = join(srcSlicesDir, sid);
								const dstSlice = join(dstSlicesDir, sid);
								if (!existsSync(dstSlice)) {
									try {
										cpSync(srcSlice, dstSlice, { recursive: true });
										synced.push(`milestones/${mid}/slices/${sid}/`);
									} catch (err) {
										/* non-fatal */
										logWarning(
											"worktree",
											`slice copy failed (${mid}/${sid}): ${getErrorMessage(err)}`,
										);
									}
								}
							}
						}
					} catch (err) {
						/* non-fatal */
						logWarning(
							"worktree",
							`milestone file sync failed: ${getErrorMessage(err)}`,
						);
					}
				}
			}
		} catch (err) {
			/* non-fatal */
			logWarning(
				"worktree",
				`milestone directory sync failed: ${getErrorMessage(err)}`,
			);
		}
	}
	return { synced };
}
/**
 * Sync milestone artifacts from worktree back to the main external state directory.
 * Called before milestone merge to ensure completion artifacts (SUMMARY, VALIDATION,
 * updated ROADMAP) are visible from the project root (#1412).
 *
 * Syncs:
 *   1. Root-level .sf/ files (REQUIREMENTS, PROJECT, DECISIONS, KNOWLEDGE,
 *      OVERRIDES) — the worktree's versions overwrite main's because the
 *      worktree is the authoritative execution context.
 *   2. ALL milestone directories found in the worktree — not just the
 *      current milestoneId. The complete-milestone unit may create artifacts
 *      for the *next* milestone (CONTEXT, ROADMAP, new requirements) which
 *      must survive worktree teardown.
 *
 * History: Originally only synced milestones/<milestoneId>/ and assumed
 * root-level files would be carried by the squash merge. In practice,
 * .sf/ files are often untracked (gitignored or never committed), so the
 * squash merge carries nothing. This caused next-milestone artifacts and
 * updated REQUIREMENTS/PROJECT to be silently lost on teardown.
 */
export function syncWorktreeStateBack(mainBasePath, worktreePath, milestoneId) {
	const mainSf = sfRoot(mainBasePath);
	const wtSf = sfRoot(worktreePath);
	const synced = [];
	// If both resolve to the same directory (symlink), no sync needed
	if (isSamePath(mainSf, wtSf)) return { synced };
	if (!existsSync(wtSf) || !existsSync(mainSf)) return { synced };
	// ── 0. Pre-upgrade worktree DB reconciliation ────────────────────────
	// If the worktree has its own sf.db (copied before the WAL transition),
	// reconcile its hierarchy data into the project root DB before syncing
	// files. This handles in-flight worktrees that were created before the
	// upgrade to shared WAL mode.
	const wtLocalDb = join(wtSf, "sf.db");
	const mainDb = join(mainSf, "sf.db");
	if (existsSync(wtLocalDb) && existsSync(mainDb)) {
		try {
			reconcileWorktreeDb(mainDb, wtLocalDb);
			synced.push("sf.db (pre-upgrade reconcile)");
		} catch (err) {
			// Non-fatal — file sync below is the fallback
			logError("worktree", `DB reconciliation failed: ${getErrorMessage(err)}`);
		}
	}
	// ── 1. Sync root-level .sf/ files back ──────────────────────────────
	// The worktree is authoritative — complete-milestone updates REQUIREMENTS,
	// PROJECT, etc. These must overwrite main's copies so they survive teardown.
	// Also includes QUEUE.md, completed-units.json, and metrics.json which are
	// written during milestone closeout and lost on teardown without explicit sync
	// (#1787, #2313).
	for (const f of ROOT_STATE_FILES) {
		const src = join(wtSf, f);
		const dst = join(mainSf, f);
		if (existsSync(src)) {
			try {
				cpSync(src, dst, { force: true });
				synced.push(f);
			} catch (err) {
				/* non-fatal */
				logWarning(
					"worktree",
					`state file copy-back failed (${f}): ${getErrorMessage(err)}`,
				);
			}
		}
	}
	// ── 2. Sync ALL milestone directories ────────────────────────────────
	// The complete-milestone unit may create next-milestone artifacts (e.g.
	// M007 setup while closing M006). We must sync every milestone directory
	// in the worktree, not just the current one.
	const wtMilestonesDir = join(wtSf, "milestones");
	if (!existsSync(wtMilestonesDir)) return { synced };
	try {
		const wtMilestones = readdirSync(wtMilestonesDir, { withFileTypes: true })
			.filter((d) => d.isDirectory())
			.map((d) => d.name);
		for (const mid of wtMilestones) {
			// Skip the current milestone being merged — its files are already in the
			// milestone branch and would conflict with the squash merge (#3641).
			if (mid === milestoneId) continue;
			syncMilestoneDir(wtSf, mainSf, mid, synced);
		}
	} catch (err) {
		/* non-fatal */
		logWarning(
			"worktree",
			`milestone sync-back failed: ${getErrorMessage(err)}`,
		);
	}
	return { synced };
}
/**
 * Sync a single milestone directory from worktree to main.
 * Copies milestone-level .md files, slice-level files, and task summaries.
 */
/** Copy matching files from srcDir to dstDir (non-fatal per file). */
function syncDirFiles(srcDir, dstDir, filter, synced, prefix) {
	try {
		for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
			if (!entry.isFile() || !filter(entry.name)) continue;
			try {
				cpSync(join(srcDir, entry.name), join(dstDir, entry.name), {
					force: true,
				});
				synced.push(`${prefix}${entry.name}`);
			} catch (err) {
				/* non-fatal */
				logWarning(
					"worktree",
					`file copy failed (${prefix}${entry.name}): ${getErrorMessage(err)}`,
				);
			}
		}
	} catch (err) {
		/* non-fatal — srcDir may not be readable */
		logWarning("worktree", `directory read failed: ${getErrorMessage(err)}`);
	}
}
function syncMilestoneDir(wtSf, mainSf, mid, synced) {
	const wtMilestoneDir = join(wtSf, "milestones", mid);
	const mainMilestoneDir = join(mainSf, "milestones", mid);
	if (!existsSync(wtMilestoneDir)) return;
	mkdirSync(mainMilestoneDir, { recursive: true });
	const isMd = (name) => name.endsWith(".md");
	// Sync milestone-level files (SUMMARY, VALIDATION, ROADMAP, CONTEXT)
	syncDirFiles(
		wtMilestoneDir,
		mainMilestoneDir,
		isMd,
		synced,
		`milestones/${mid}/`,
	);
	// Sync slice-level files (summaries, UATs) and task summaries (#1678)
	const wtSlicesDir = join(wtMilestoneDir, "slices");
	const mainSlicesDir = join(mainMilestoneDir, "slices");
	if (!existsSync(wtSlicesDir)) return;
	try {
		for (const sliceEntry of readdirSync(wtSlicesDir, {
			withFileTypes: true,
		})) {
			if (!sliceEntry.isDirectory()) continue;
			const sid = sliceEntry.name;
			const wtSliceDir = join(wtSlicesDir, sid);
			const mainSliceDir = join(mainSlicesDir, sid);
			mkdirSync(mainSliceDir, { recursive: true });
			syncDirFiles(
				wtSliceDir,
				mainSliceDir,
				isMd,
				synced,
				`milestones/${mid}/slices/${sid}/`,
			);
			const wtTasksDir = join(wtSliceDir, "tasks");
			const mainTasksDir = join(mainSliceDir, "tasks");
			if (existsSync(wtTasksDir)) {
				mkdirSync(mainTasksDir, { recursive: true });
				syncDirFiles(
					wtTasksDir,
					mainTasksDir,
					isMd,
					synced,
					`milestones/${mid}/slices/${sid}/tasks/`,
				);
			}
		}
	} catch (err) {
		/* non-fatal */
		logWarning(
			"worktree",
			`milestone slice sync failed (${mid}): ${getErrorMessage(err)}`,
		);
	}
}
// ─── Worktree Post-Create Hook (#597) ────────────────────────────────────────
/**
 * Run the user-configured post-create hook script after worktree creation.
 * The script receives SOURCE_DIR and WORKTREE_DIR as environment variables.
 * Failure is non-fatal — returns the error message or null on success.
 *
 * Reads the hook path from git.worktree_post_create in preferences.
 * Also runs workspace.after_create (inline shell script) if configured.
 * Pass hookPath directly to bypass preference loading (useful for testing).
 */
export function runWorktreePostCreateHook(sourceDir, worktreeDir, hookPath) {
	const errors = [];
	// ── Legacy file-path hook (git.worktree_post_create) ─────────────────────
	let resolvedHookPath = hookPath;
	if (resolvedHookPath === undefined) {
		const prefs = loadEffectiveSFPreferences()?.preferences?.git;
		resolvedHookPath = prefs?.worktree_post_create;
	}
	if (resolvedHookPath) {
		// Resolve relative paths against the source project root.
		// On Windows, convert 8.3 short paths (e.g. RUNNER~1) to long paths
		// so execFileSync can locate the file correctly.
		let resolved = isAbsolute(resolvedHookPath)
			? resolvedHookPath
			: join(sourceDir, resolvedHookPath);
		if (!existsSync(resolved)) {
			errors.push(`Worktree post-create hook not found: ${resolved}`);
		} else {
			if (process.platform === "win32") {
				try {
					resolved = realpathSync.native(resolved);
				} catch (err) {
					/* keep original */
					logWarning("worktree", `realpath failed: ${getErrorMessage(err)}`);
				}
			}
			try {
				// .bat/.cmd files on Windows require shell mode — execFileSync cannot
				// spawn them directly (EINVAL).
				const needsShell =
					process.platform === "win32" && /\.(bat|cmd)$/i.test(resolved);
				execFileSync(resolved, [], {
					cwd: worktreeDir,
					env: {
						...process.env,
						SOURCE_DIR: sourceDir,
						WORKTREE_DIR: worktreeDir,
					},
					stdio: ["ignore", "pipe", "pipe"],
					encoding: "utf-8",
					timeout: 30_000,
					shell: needsShell,
				});
			} catch (err) {
				const msg = getErrorMessage(err);
				errors.push(`Worktree post-create hook failed: ${msg}`);
			}
		}
	}
	// ── Inline script hook (workspace.after_create) ───────────────────────────
	// Only read from prefs when hookPath was not passed explicitly (testing path).
	if (hookPath === undefined) {
		const afterCreate =
			loadEffectiveSFPreferences()?.preferences?.workspace?.after_create;
		if (afterCreate) {
			try {
				execFileSync("sh", ["-c", afterCreate], {
					cwd: worktreeDir,
					env: {
						...process.env,
						SOURCE_DIR: sourceDir,
						WORKTREE_DIR: worktreeDir,
					},
					stdio: ["ignore", "pipe", "pipe"],
					encoding: "utf-8",
					timeout: 60_000,
				});
			} catch (err) {
				const msg = getErrorMessage(err);
				errors.push(`workspace.after_create hook failed: ${msg}`);
			}
		}
	}
	return errors.length > 0 ? errors.join("; ") : null;
}
// ─── Auto-Worktree Branch Naming ───────────────────────────────────────────
export function autoWorktreeBranch(milestoneId) {
	return `milestone/${milestoneId}`;
}
// ─── Public API ────────────────────────────────────────────────────────────
/**
 * Create a new auto-worktree for a milestone, chdir into it, and store
 * the original base path for later teardown.
 *
 * Atomic: chdir + originalBase update happen in the same try block
 * to prevent split-brain.
 */
/**
 * Forward-merge plan checkbox state from the project root into a freshly
 * re-attached worktree (#778).
 *
 * When autonomous mode stops via crash (not graceful stop), the milestone branch
 * HEAD may be behind the filesystem state at the project root because
 * syncStateToProjectRoot() runs after every task completion but the final
 * git commit may not have happened before the crash. On restart the worktree
 * is re-attached to the branch HEAD, which has [ ] for the crashed task,
 * causing verifyExpectedArtifact() to fail and triggering an infinite
 * dispatch/skip loop.
 *
 * Fix: after re-attaching, read every *.md plan file in the milestone
 * directory at the project root and apply any [x] checkbox states that are
 * ahead of the worktree version (forward-only: never downgrade [x] → [ ]).
 *
 * This is safe because syncStateToProjectRoot() is the authoritative source
 * of post-task state at the project root — it writes the same [x] the LLM
 * produced, then the auto-commit follows. If the commit never happened, the
 * filesystem copy is still valid and correct.
 */
function reconcilePlanCheckboxes(projectRoot, wtPath, milestoneId) {
	const srcMilestone = join(projectRoot, ".sf", "milestones", milestoneId);
	const dstMilestone = join(wtPath, ".sf", "milestones", milestoneId);
	if (!existsSync(srcMilestone) || !existsSync(dstMilestone)) return;
	// Walk all markdown files in the milestone directory (plans, summaries, etc.)
	function walkMd(dir) {
		const results = [];
		try {
			for (const entry of readdirSync(dir, { withFileTypes: true })) {
				const full = join(dir, entry.name);
				if (entry.isDirectory()) {
					results.push(...walkMd(full));
				} else if (entry.isFile() && entry.name.endsWith(".md")) {
					results.push(full);
				}
			}
		} catch (err) {
			/* non-fatal */
			logWarning(
				"worktree",
				`walkMd directory read failed: ${getErrorMessage(err)}`,
			);
		}
		return results;
	}
	for (const srcFile of walkMd(srcMilestone)) {
		const rel = srcFile.slice(srcMilestone.length);
		const dstFile = dstMilestone + rel;
		if (!existsSync(dstFile)) continue; // only reconcile existing files
		let srcContent;
		let dstContent;
		try {
			srcContent = readFileSync(srcFile, "utf-8");
			dstContent = readFileSync(dstFile, "utf-8");
		} catch (e) {
			logWarning(
				"worktree",
				`reconcilePlanCheckboxes read failed: ${e.message}`,
			);
			continue;
		}
		if (srcContent === dstContent) continue;
		// Extract all checked task IDs from the source (project root)
		// Pattern: - [x] **T<id>: or - [x] **S<id>: (case-insensitive x)
		const checkedRe = /^- \[[xX]\] \*\*([TS]\d+):/gm;
		const srcChecked = new Set();
		for (const m of srcContent.matchAll(checkedRe)) srcChecked.add(m[1]);
		if (srcChecked.size === 0) continue;
		// Forward-apply: replace [ ] → [x] for any IDs that are checked in src
		let updated = dstContent;
		let changed = false;
		for (const id of srcChecked) {
			const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
			const uncheckedRe = new RegExp(
				`^(- )\\[ \\]( \\*\\*${escapedId}:)`,
				"gm",
			);
			if (uncheckedRe.test(updated)) {
				updated = updated.replace(
					new RegExp(`^(- )\\[ \\]( \\*\\*${escapedId}:)`, "gm"),
					"$1[x]$2",
				);
				changed = true;
			}
		}
		if (changed) {
			try {
				atomicWriteSync(dstFile, updated, "utf-8");
			} catch (err) {
				/* non-fatal */
				logWarning(
					"worktree",
					`plan checkbox reconcile write failed: ${getErrorMessage(err)}`,
				);
			}
		}
	}
}
export function createAutoWorktree(basePath, milestoneId) {
	// Guard: refuse to create a worktree from inside an existing worktree.
	// Nested worktrees corrupt state on merge-back and are never intentional.
	if (isInsideWorktree(basePath)) {
		emitJournalEvent(basePath, {
			ts: new Date().toISOString(),
			flowId: randomUUID(),
			seq: 0,
			eventType: "worktree-create-failed",
			data: {
				milestoneId,
				reason: "nested-worktree-rejected",
				basePath,
			},
		});
		throw new SFError(
			SF_GIT_ERROR,
			`cannot create a nested worktree from inside an existing worktree: ${basePath}`,
		);
	}
	const branch = autoWorktreeBranch(milestoneId);
	// Check if the milestone branch already exists — it survives autonomous mode
	// stop/pause and contains committed work from prior sessions. If it exists,
	// re-attach the worktree to it WITHOUT resetting. Only create a fresh branch
	// from the integration branch when no prior work exists.
	const branchExists = nativeBranchExists(basePath, branch);
	let info;
	if (branchExists) {
		// Re-attach worktree to the existing milestone branch (preserving commits)
		info = createWorktree(basePath, milestoneId, {
			branch,
			reuseExistingBranch: true,
		});
	} else {
		// Fresh start — create branch from integration branch.
		// Use the same 3-tier fallback as mergeMilestoneToMain (#3461):
		//   1. META.json integration branch (explicit per-milestone override)
		//   2. git.main_branch preference (user's configured working branch)
		//   3. nativeDetectMainBranch (origin/HEAD auto-detection)
		// Without tier 2, projects with main_branch=dev but origin/HEAD→master
		// would fork worktrees from the wrong (stale) branch.
		const integrationBranch =
			readIntegrationBranch(basePath, milestoneId) ?? undefined;
		const gitPrefs = loadEffectiveSFPreferences()?.preferences?.git;
		const startPoint = integrationBranch ?? gitPrefs?.main_branch ?? undefined;
		info = createWorktree(basePath, milestoneId, {
			branch,
			startPoint,
		});
	}
	// Copy .sf/ planning artifacts from the source repo into the new worktree.
	// Worktrees are fresh git checkouts — untracked files don't carry over.
	// Planning artifacts may be untracked if the project's .gitignore had a
	// blanket .sf/ rule (pre-v2.14.0). Without this copy, autonomous mode loops
	// on plan-slice because the plan file doesn't exist in the worktree.
	//
	// IMPORTANT: Skip when re-attaching to an existing branch (#759).
	// The branch checkout already has committed artifacts with correct state
	// (e.g. [x] for completed slices). Copying from the project root would
	// overwrite them with stale data ([ ] checkboxes) because the root is
	// not always fully synced.
	if (!branchExists) {
		copyPlanningArtifacts(basePath, info.path);
	} else {
		// Re-attaching to an existing branch: forward-merge any plan checkpoint
		// state from the project root into the worktree (#778).
		//
		// If autonomous mode stopped via crash, the milestone branch HEAD may lag behind
		// the project root filesystem because syncStateToProjectRoot() ran after
		// task completion but the auto-commit never fired. On restart the worktree
		// is re-created from the branch HEAD (which has [ ] for the crashed task),
		// causing verifyExpectedArtifact() to return false → stale-key eviction →
		// infinite dispatch/skip loop. Reconciling here ensures the worktree sees
		// the same [x] state that syncStateToProjectRoot() wrote to the root.
		reconcilePlanCheckboxes(basePath, info.path, milestoneId);
	}
	// Run user-configured post-create hook (#597) — e.g. copy .env, symlink assets
	const hookError = runWorktreePostCreateHook(basePath, info.path);
	if (hookError) {
		// Non-fatal — log but don't prevent worktree usage
		logWarning("reconcile", hookError, { worktree: info.name });
	}
	const previousCwd = process.cwd();
	try {
		process.chdir(info.path);
		originalBase = basePath;
	} catch (err) {
		// If chdir fails, the worktree was created but we couldn't enter it.
		// Don't store originalBase -- caller can retry or clean up.
		throw new SFError(
			SF_IO_ERROR,
			`Auto-worktree created at ${info.path} but chdir failed: ${getErrorMessage(err)}`,
		);
	}
	nudgeGitBranchCache(previousCwd);
	return info.path;
}
/**
 * Copy .sf/ planning artifacts from source repo to a new worktree.
 * Copies milestones/, DECISIONS.md, REQUIREMENTS.md, PROJECT.md, QUEUE.md,
 * STATE.md, KNOWLEDGE.md, and OVERRIDES.md.
 * Skips runtime files (auto.lock, metrics.json, etc.) and the worktrees/ dir.
 * Best-effort — failures are non-fatal since autonomous mode can recreate artifacts.
 */
function copyPlanningArtifacts(srcBase, wtPath) {
	const srcSf = join(srcBase, ".sf");
	const dstSf = join(wtPath, ".sf");
	if (!existsSync(srcSf)) return;
	if (isSamePath(srcSf, dstSf)) return;
	// Copy milestones/ directory (planning files, roadmaps, plans, research)
	safeCopyRecursive(join(srcSf, "milestones"), join(dstSf, "milestones"), {
		force: true,
		filter: (src) => !src.endsWith("-META.json"),
	});
	// Copy top-level planning files
	for (const file of [
		"DECISIONS.md",
		"REQUIREMENTS.md",
		"PROJECT.md",
		"QUEUE.md",
		"STATE.md",
		"KNOWLEDGE.md",
		"OVERRIDES.md",
		"mcp.json",
	]) {
		safeCopy(join(srcSf, file), join(dstSf, file), { force: true });
	}
	// Seed canonical preferences.yaml when available.
	if (existsSync(join(srcSf, PROJECT_PREFERENCES_FILE))) {
		safeCopy(
			join(srcSf, PROJECT_PREFERENCES_FILE),
			join(dstSf, PROJECT_PREFERENCES_FILE),
			{ force: true },
		);
	}
	// Shared WAL (R012): worktrees use the project root's DB directly.
	// No longer copy sf.db into the worktree — the DB path resolver in
	// ensureDbOpen() detects the worktree location and opens the root DB.
	// Compat note: reconcileWorktreeDb() in mergeMilestoneToMain handles
	// worktrees that already have a local sf.db from before this change.
}
/**
 * Teardown an auto-worktree: chdir back to original base, then remove
 * the worktree and its branch.
 */
export function teardownAutoWorktree(originalBasePath, milestoneId, opts = {}) {
	const branch = autoWorktreeBranch(milestoneId);
	const { preserveBranch = false } = opts;
	const previousCwd = process.cwd();
	try {
		process.chdir(originalBasePath);
		originalBase = null;
	} catch (err) {
		throw new SFError(
			SF_IO_ERROR,
			`Failed to chdir back to ${originalBasePath} during teardown: ${getErrorMessage(err)}`,
		);
	}
	nudgeGitBranchCache(previousCwd);
	removeWorktree(originalBasePath, milestoneId, {
		branch,
		deleteBranch: !preserveBranch,
	});
	// Verify cleanup succeeded — warn if the worktree directory is still on disk.
	// On Windows, bash-based cleanup can silently fail when paths contain
	// backslashes (#1436), leaving ~1 GB+ orphaned directories.
	const wtDir = worktreePath(originalBasePath, milestoneId);
	if (existsSync(wtDir)) {
		logWarning(
			"reconcile",
			`Worktree directory still exists after teardown: ${wtDir}. ` +
				`This is likely an orphaned directory consuming disk space. ` +
				`Remove it manually with: rm -rf "${wtDir.replaceAll("\\", "/")}"`,
			{ worktree: milestoneId },
		);
		// Attempt a direct filesystem removal as a fallback — but ONLY if the
		// path is safely inside .sf/worktrees/ to prevent #2365 data loss.
		if (isInsideWorktreesDir(originalBasePath, wtDir)) {
			try {
				rmSync(wtDir, { recursive: true, force: true });
			} catch (err) {
				// Non-fatal — the warning above tells the user how to clean up
				logWarning(
					"worktree",
					`worktree directory removal failed: ${getErrorMessage(err)}`,
				);
			}
		} else {
			console.error(
				`[SF] REFUSING fallback rmSync — path is outside .sf/worktrees/: ${wtDir}`,
			);
		}
	}
}
/**
 * Detect if the process is currently inside an auto-worktree.
 * Checks both module state and git branch prefix.
 */
export function isInAutoWorktree(basePath) {
	if (!originalBase) return false;
	const cwd = process.cwd();
	const resolvedBase = existsSync(basePath) ? realpathSync(basePath) : basePath;
	const wtDir = join(resolvedBase, ".sf", "worktrees");
	if (!cwd.startsWith(wtDir)) return false;
	const branch = nativeGetCurrentBranch(cwd);
	return branch.startsWith("milestone/");
}
/**
 * Get the filesystem path for an auto-worktree, or null if it doesn't exist
 * or is not a valid git worktree.
 *
 * Validates that the path is a real git worktree (has a .git file with a
 * gitdir: pointer) rather than just a stray directory. This prevents
 * mis-detection of leftover directories as active worktrees (#695).
 */
export function getAutoWorktreePath(basePath, milestoneId) {
	const p = worktreePath(basePath, milestoneId);
	if (!existsSync(p)) return null;
	// Validate this is a real git worktree, not a stray directory.
	// A git worktree has a .git *file* (not directory) containing "gitdir: <path>".
	const gitPath = join(p, ".git");
	if (!existsSync(gitPath)) return null;
	try {
		const content = readFileSync(gitPath, "utf8").trim();
		if (!content.startsWith("gitdir: ")) return null;
	} catch (e) {
		logWarning(
			"worktree",
			`getAutoWorktreePath .git read failed: ${e.message}`,
		);
		return null;
	}
	return p;
}
/**
 * Enter an existing auto-worktree (chdir into it, store originalBase).
 * Use for resume -- the worktree already exists from a prior create.
 *
 * Atomic: chdir + originalBase update in same try block.
 */
export function enterAutoWorktree(basePath, milestoneId) {
	const p = worktreePath(basePath, milestoneId);
	if (!existsSync(p)) {
		throw new SFError(
			SF_IO_ERROR,
			`Auto-worktree for ${milestoneId} does not exist at ${p}`,
		);
	}
	// Validate this is a real git worktree, not a stray directory (#695)
	const gitPath = join(p, ".git");
	if (!existsSync(gitPath)) {
		throw new SFError(
			SF_GIT_ERROR,
			`Auto-worktree path ${p} exists but is not a git worktree (no .git)`,
		);
	}
	try {
		const content = readFileSync(gitPath, "utf8").trim();
		if (!content.startsWith("gitdir: ")) {
			throw new SFError(
				SF_GIT_ERROR,
				`Auto-worktree path ${p} has a .git but it is not a worktree gitdir pointer`,
			);
		}
	} catch (err) {
		if (err instanceof Error && err.message.includes("worktree")) throw err;
		throw new SFError(
			SF_IO_ERROR,
			`Auto-worktree path ${p} exists but .git is unreadable`,
		);
	}
	const previousCwd = process.cwd();
	try {
		process.chdir(p);
		originalBase = basePath;
	} catch (err) {
		throw new SFError(
			SF_IO_ERROR,
			`Failed to enter auto-worktree at ${p}: ${getErrorMessage(err)}`,
		);
	}
	nudgeGitBranchCache(previousCwd);
	return p;
}
/**
 * Get the original project root stored when entering an auto-worktree.
 * Returns null if not currently in an auto-worktree.
 */
/**
 * Get the original project root stored when entering an auto-worktree.
 * Returns null if not currently in an auto-worktree.
 */
export function getAutoWorktreeOriginalBase() {
	return originalBase;
}
/**
 * Get the context of the currently active auto-worktree (originalBase, name, branch).
 * Returns null if not currently inside an auto-worktree.
 */
export function getActiveAutoWorktreeContext() {
	if (!originalBase) return null;
	const cwd = process.cwd();
	const resolvedBase = existsSync(originalBase)
		? realpathSync(originalBase)
		: originalBase;
	const wtDir = join(resolvedBase, ".sf", "worktrees");
	if (!cwd.startsWith(wtDir)) return null;
	const worktreeName = detectWorktreeName(cwd);
	if (!worktreeName) return null;
	const branch = nativeGetCurrentBranch(cwd);
	if (!branch.startsWith("milestone/")) return null;
	return {
		originalBase,
		worktreeName,
		branch,
	};
}
// ─── Merge Milestone -> Main ───────────────────────────────────────────────
/**
 * Auto-commit any dirty (uncommitted) state in the given directory.
 * Returns true if a commit was made, false if working tree was clean.
 */
function autoCommitDirtyState(cwd) {
	try {
		const status = nativeWorkingTreeStatus(cwd);
		if (!status) return false;
		nativeAddAllWithExclusions(cwd, RUNTIME_EXCLUSION_PATHS);
		const result = nativeCommit(
			cwd,
			"chore: auto-commit before milestone merge",
		);
		return result !== null;
	} catch (e) {
		debugLog("autoCommitDirtyState", { error: String(e) });
		return false;
	}
}
/**
 * Squash-merge the milestone branch into main with a rich commit message
 * listing all completed slices, then tear down the worktree.
 *
 * Sequence:
 *  1. Auto-commit dirty worktree state
 *  2. chdir to originalBasePath
 *  3. git checkout main
 *  4. git merge --squash milestone/<MID>
 *  5. git commit with rich message
 *  6. Auto-push if enabled
 *  7. Delete milestone branch
 *  8. Remove worktree directory
 *  9. Clear originalBase
 *
 * On merge conflict: throws MergeConflictError.
 * On "nothing to commit" after squash: safe only if milestone work is already
 * on the integration branch.  Throws if unanchored code changes would be lost.
 */
export function mergeMilestoneToMain(
	originalBasePath_,
	milestoneId,
	roadmapContent,
) {
	const worktreeCwd = process.cwd();
	const milestoneBranch = autoWorktreeBranch(milestoneId);
	// 1. Auto-commit dirty state before leaving.
	//    Guard: when we entered through an auto-worktree (originalBase is set),
	//    only auto-commit when cwd is on the milestone branch. In parallel mode,
	//    cwd may be on the integration branch after a prior merge's
	//    MergeConflictError left cwd unrestored. Auto-committing on the
	//    integration branch captures dirty files from OTHER milestones under a
	//    misleading commit message, contaminating the main branch (#2929).
	//
	//    When originalBase is null (branch mode, no worktree), autoCommitDirtyState
	//    runs unconditionally — the caller is responsible for cwd placement.
	{
		let shouldAutoCommit = true;
		if (originalBase !== null) {
			try {
				const currentBranch = nativeGetCurrentBranch(worktreeCwd);
				shouldAutoCommit = currentBranch === milestoneBranch;
			} catch {
				// If we can't determine the branch, skip the auto-commit to be safe
				shouldAutoCommit = false;
			}
		}
		if (shouldAutoCommit) {
			autoCommitDirtyState(worktreeCwd);
		}
	}
	// Reconcile worktree DB into main DB before leaving worktree context.
	// Skip when both paths resolve to the same physical file (shared WAL /
	// symlink layout) — ATTACHing a WAL-mode file to itself corrupts the
	// database (#2823).
	if (isDbAvailable()) {
		try {
			const worktreeDbPath = join(worktreeCwd, ".sf", "sf.db");
			const mainDbPath = join(originalBasePath_, ".sf", "sf.db");
			if (!isSamePath(worktreeDbPath, mainDbPath)) {
				reconcileWorktreeDb(mainDbPath, worktreeDbPath);
			}
		} catch (err) {
			/* non-fatal */
			logError("worktree", `DB reconciliation failed: ${getErrorMessage(err)}`);
		}
	}
	// 2. Get completed slices for commit message
	let completedSlices = [];
	if (isDbAvailable()) {
		completedSlices = getMilestoneSlices(milestoneId)
			.filter((s) => s.status === "complete")
			.map((s) => ({ id: s.id, title: s.title }));
	}
	// Fallback: parse roadmap content when DB is unavailable
	if (completedSlices.length === 0 && roadmapContent) {
		const sliceRe = /- \[x\] \*\*(\w+):\s*(.+?)\*\*/gi;
		let m;
		while ((m = sliceRe.exec(roadmapContent)) !== null) {
			completedSlices.push({ id: m[1], title: m[2] });
		}
	}
	// 3. chdir to original base
	const previousCwd = process.cwd();
	process.chdir(originalBasePath_);
	// 4. Resolve integration branch — prefer milestone metadata, then preferences,
	//    then auto-detect (origin/HEAD → main → master → current). Never hardcode
	//    "main": repos using "master" or a custom default branch would fail at
	//    checkout and leave the user with a broken merge state (#1668).
	const prefs = loadEffectiveSFPreferences()?.preferences?.git ?? {};
	const integrationBranch = readIntegrationBranch(
		originalBasePath_,
		milestoneId,
	);
	// Validate prefs.main_branch exists before using it — a stale preference
	// (e.g. "master" when repo uses "main") causes merge failure (#3589).
	const validatedPrefBranch =
		prefs.main_branch &&
		nativeBranchExists(originalBasePath_, prefs.main_branch)
			? prefs.main_branch
			: undefined;
	const mainBranch =
		integrationBranch ??
		validatedPrefBranch ??
		nativeDetectMainBranch(originalBasePath_);
	// Remove transient project-root state files before any branch or merge
	// operation. Untracked milestone metadata can otherwise block squash merges.
	clearProjectRootStateFiles(originalBasePath_, milestoneId);
	// 5. Checkout integration branch (skip if already current — avoids git error
	//    when main is already checked out in the project-root worktree, #757)
	const currentBranchAtBase = nativeGetCurrentBranch(originalBasePath_);
	if (currentBranchAtBase !== mainBranch) {
		nativeCheckoutBranch(originalBasePath_, mainBranch);
	}
	// 6. Build rich commit message
	const dbMilestone = getMilestone(milestoneId);
	let milestoneTitle = (dbMilestone?.title ?? "")
		.replace(/^M\d+:\s*/, "")
		.trim();
	// Fallback: parse title from roadmap content header (e.g. "# M020: Backend foundation")
	if (!milestoneTitle && roadmapContent) {
		const titleMatch = roadmapContent.match(
			new RegExp(`^#\\s+${milestoneId}:\\s*(.+)`, "m"),
		);
		if (titleMatch) milestoneTitle = titleMatch[1].trim();
	}
	milestoneTitle = milestoneTitle || milestoneId;
	const subject = `feat: ${milestoneTitle}`;
	let body = "";
	if (completedSlices.length > 0) {
		const sliceLines = completedSlices
			.map((s) => `- ${s.id}: ${s.title}`)
			.join("\n");
		body = `\n\nCompleted slices:\n${sliceLines}\n\nSF-Milestone: ${milestoneId}\nBranch: ${milestoneBranch}`;
	} else {
		body = `\n\nSF-Milestone: ${milestoneId}\nBranch: ${milestoneBranch}`;
	}
	const commitMessage = subject + body;
	// 6b. Reconcile worktree HEAD with milestone branch ref (#1846).
	//     When the worktree HEAD detaches and advances past the named branch,
	//     the branch ref becomes stale. Squash-merging the stale ref silently
	//     orphans all commits between the branch ref and the actual worktree HEAD.
	//     Fix: fast-forward the branch ref to the worktree HEAD before merging.
	//     Only applies when merging from an actual worktree (worktreeCwd differs
	//     from originalBasePath_).
	if (worktreeCwd !== originalBasePath_) {
		try {
			const worktreeHead = execFileSync("git", ["rev-parse", "HEAD"], {
				cwd: worktreeCwd,
				stdio: ["ignore", "pipe", "pipe"],
				encoding: "utf-8",
			}).trim();
			const branchHead = execFileSync("git", ["rev-parse", milestoneBranch], {
				cwd: originalBasePath_,
				stdio: ["ignore", "pipe", "pipe"],
				encoding: "utf-8",
			}).trim();
			if (worktreeHead && branchHead && worktreeHead !== branchHead) {
				if (nativeIsAncestor(originalBasePath_, branchHead, worktreeHead)) {
					// Worktree HEAD is strictly ahead — fast-forward the branch ref
					nativeUpdateRef(
						originalBasePath_,
						`refs/heads/${milestoneBranch}`,
						worktreeHead,
					);
					debugLog("mergeMilestoneToMain", {
						action: "fast-forward-branch-ref",
						milestoneBranch,
						oldRef: branchHead.slice(0, 8),
						newRef: worktreeHead.slice(0, 8),
					});
				} else {
					// Diverged — fail loudly rather than silently losing commits
					process.chdir(previousCwd);
					throw new SFError(
						SF_GIT_ERROR,
						`Worktree HEAD (${worktreeHead.slice(0, 8)}) diverged from ` +
							`${milestoneBranch} (${branchHead.slice(0, 8)}). ` +
							`Manual reconciliation required before merge.`,
					);
				}
			}
		} catch (err) {
			// Re-throw SFError (divergence); swallow rev-parse failures
			// (e.g. worktree dir already removed by external cleanup)
			if (err instanceof SFError) throw err;
			debugLog("mergeMilestoneToMain", {
				action: "reconcile-skipped",
				reason: String(err),
			});
		}
	}
	// 7. Stash any pre-existing dirty files so the squash merge is not
	//    blocked by unrelated local changes (#2151).  clearProjectRootStateFiles
	//    only removes untracked .sf/ files; tracked dirty files elsewhere (e.g.
	//    .planning/work-state.json with stash conflict markers) are invisible to
	//    that cleanup but will cause `git merge --squash` to reject.
	let stashed = false;
	try {
		const status = execFileSync("git", ["status", "--porcelain"], {
			cwd: originalBasePath_,
			stdio: ["ignore", "pipe", "pipe"],
			encoding: "utf-8",
		}).trim();
		if (status) {
			// Use --include-untracked to stash untracked files that would block
			// the squash merge, but EXCLUDE .sf/milestones/ (#2505).
			// --include-untracked without exclusion sweeps queued milestone
			// CONTEXT files into the stash. If stash pop later fails, those files
			// are permanently trapped in the stash entry and lost on the next
			// stash push or drop.
			execFileSync(
				"git",
				[
					"stash",
					"push",
					"--include-untracked",
					"-m",
					`sf: pre-merge stash for ${milestoneId}`,
					"--",
					":(exclude).sf/milestones",
				],
				{
					cwd: originalBasePath_,
					stdio: ["ignore", "pipe", "pipe"],
					encoding: "utf-8",
				},
			);
			stashed = true;
		}
	} catch (err) {
		// Stash failure is non-fatal — proceed without stash and let the merge
		// report the dirty tree if it fails.
		logWarning("worktree", `git stash failed: ${getErrorMessage(err)}`);
	}
	// 7a. Shelter queued milestone directories before the squash merge (#2505).
	// The milestone branch may contain copies of queued milestone dirs (via
	// copyPlanningArtifacts), so `git merge --squash` rejects when those same
	// files exist as untracked in the working tree. Temporarily move them to
	// a backup location, then restore after the merge+commit.
	const milestonesDir = join(sfRoot(originalBasePath_), "milestones");
	const shelterDir = join(sfRoot(originalBasePath_), ".milestone-shelter");
	const shelteredDirs = [];
	// Helper: restore sheltered milestone directories (#2505).
	// Called on both success and error paths to ensure queued CONTEXT files
	// are never permanently lost.
	const restoreShelter = () => {
		if (shelteredDirs.length === 0) return;
		for (const dirName of shelteredDirs) {
			try {
				mkdirSync(milestonesDir, { recursive: true });
				cpSync(join(shelterDir, dirName), join(milestonesDir, dirName), {
					recursive: true,
					force: true,
				});
			} catch (err) {
				/* best-effort */
				logError("worktree", `shelter restore failed: ${getErrorMessage(err)}`);
			}
		}
		try {
			rmSync(shelterDir, { recursive: true, force: true });
		} catch (err) {
			/* best-effort */
			logWarning("worktree", `shelter cleanup failed: ${getErrorMessage(err)}`);
		}
	};
	try {
		if (existsSync(milestonesDir)) {
			const entries = readdirSync(milestonesDir, { withFileTypes: true });
			for (const entry of entries) {
				if (!entry.isDirectory()) continue;
				// Only shelter directories that do NOT belong to the milestone being merged
				if (entry.name === milestoneId) continue;
				const srcDir = join(milestonesDir, entry.name);
				const dstDir = join(shelterDir, entry.name);
				try {
					mkdirSync(shelterDir, { recursive: true });
					cpSync(srcDir, dstDir, { recursive: true, force: true });
					rmSync(srcDir, { recursive: true, force: true });
					shelteredDirs.push(entry.name);
				} catch (err) {
					// Non-fatal — if shelter fails, the merge may still succeed
					logWarning(
						"worktree",
						`milestone shelter failed (${entry.name}): ${getErrorMessage(err)}`,
					);
				}
			}
		}
	} catch (err) {
		// Non-fatal — proceed with merge; untracked files may block it
		logWarning(
			"worktree",
			`milestone shelter operation failed: ${getErrorMessage(err)}`,
		);
	}
	// 7b. Clean up stale merge state before attempting squash merge (#2912).
	// A leftover MERGE_HEAD (from a previous failed merge, libgit2 native path,
	// or interrupted operation) causes `git merge --squash` to refuse with
	// "fatal: You have not concluded your merge (MERGE_HEAD exists)".
	// Defensively remove merge artifacts before starting.
	try {
		const gitDir_ = resolveGitDir(originalBasePath_);
		for (const f of ["SQUASH_MSG", "MERGE_MSG", "MERGE_HEAD"]) {
			const p = join(gitDir_, f);
			if (existsSync(p)) unlinkSync(p);
		}
	} catch (err) {
		/* best-effort */
		logError("worktree", `merge state cleanup failed: ${getErrorMessage(err)}`);
	}
	// 8. Squash merge — auto-resolve .sf/ state file conflicts (#530)
	const mergeResult = nativeMergeSquash(originalBasePath_, milestoneBranch);
	if (!mergeResult.success) {
		// Dirty working tree — the merge was rejected before it started (e.g.
		// untracked .sf/ files left by syncStateToProjectRoot).  Preserve the
		// milestone branch so commits are not lost.
		if (mergeResult.conflicts.includes("__dirty_working_tree__")) {
			// Defensively clean merge state — the native path may leave MERGE_HEAD
			// even when the merge is rejected (#2912).
			try {
				const gitDir_ = resolveGitDir(originalBasePath_);
				for (const f of ["SQUASH_MSG", "MERGE_MSG", "MERGE_HEAD"]) {
					const p = join(gitDir_, f);
					if (existsSync(p)) unlinkSync(p);
				}
			} catch (err) {
				/* best-effort */
				logError(
					"worktree",
					`merge state cleanup failed: ${getErrorMessage(err)}`,
				);
			}
			// Pop stash before throwing so local work is not lost.
			if (stashed) {
				try {
					execFileSync("git", ["stash", "pop"], {
						cwd: originalBasePath_,
						stdio: ["ignore", "pipe", "pipe"],
						encoding: "utf-8",
					});
				} catch (err) {
					/* stash pop conflict is non-fatal */
					logWarning(
						"worktree",
						`git stash pop failed: ${getErrorMessage(err)}`,
					);
				}
			}
			restoreShelter();
			// Restore cwd so the caller is not stranded on the integration branch
			process.chdir(previousCwd);
			// Surface the actual dirty filenames from git stderr instead of
			// generically blaming .sf/ (#2151).
			const fileList = mergeResult.dirtyFiles?.length
				? `Dirty files:\n${mergeResult.dirtyFiles.map((f) => `  ${f}`).join("\n")}`
				: `Check \`git status\` in the project root for details.`;
			throw new SFError(
				SF_GIT_ERROR,
				`Squash merge of ${milestoneBranch} rejected: working tree has dirty or untracked files ` +
					`that conflict with the merge. ${fileList}`,
			);
		}
		// Check for conflicts — use merge result first, fall back to nativeConflictFiles
		const conflictedFiles =
			mergeResult.conflicts.length > 0
				? mergeResult.conflicts
				: nativeConflictFiles(originalBasePath_);
		if (conflictedFiles.length > 0) {
			// Separate auto-resolvable conflicts (SF state files + build artifacts)
			// from real code conflicts. SF state files diverge between branches
			// during normal operation. Build artifacts are machine-generated and
			// regenerable. Both are safe to accept from the milestone branch.
			const autoResolvable = conflictedFiles.filter(isSafeToAutoResolve);
			const codeConflicts = conflictedFiles.filter(
				(f) => !isSafeToAutoResolve(f),
			);
			// Auto-resolve safe conflicts by accepting the milestone branch version
			if (autoResolvable.length > 0) {
				for (const safeFile of autoResolvable) {
					try {
						nativeCheckoutTheirs(originalBasePath_, [safeFile]);
						nativeAddPaths(originalBasePath_, [safeFile]);
					} catch (e) {
						// If checkout --theirs fails, try removing the file from the merge
						// (it's a runtime file that shouldn't be committed anyway)
						logWarning(
							"worktree",
							`checkout --theirs failed for ${safeFile}, removing: ${e.message}`,
						);
						nativeRmForce(originalBasePath_, [safeFile]);
					}
				}
			}
			// If there are still real code conflicts, escalate
			if (codeConflicts.length > 0) {
				// Abort merge state so MERGE_HEAD is not left on disk (#2912).
				// libgit2's merge creates MERGE_HEAD even for squash merges; if left
				// dangling, subsequent merges fail and doctor reports corrupt state.
				try {
					nativeMergeAbort(originalBasePath_);
				} catch (err) {
					/* best-effort */
					logError(
						"worktree",
						`git merge-abort failed: ${getErrorMessage(err)}`,
					);
				}
				try {
					const gitDir_ = resolveGitDir(originalBasePath_);
					for (const f of ["SQUASH_MSG", "MERGE_MSG", "MERGE_HEAD"]) {
						const p = join(gitDir_, f);
						if (existsSync(p)) unlinkSync(p);
					}
				} catch (err) {
					/* best-effort */
					logError(
						"worktree",
						`merge state file cleanup failed: ${getErrorMessage(err)}`,
					);
				}
				// Pop stash before throwing so local work is not lost (#2151).
				if (stashed) {
					try {
						execFileSync("git", ["stash", "pop"], {
							cwd: originalBasePath_,
							stdio: ["ignore", "pipe", "pipe"],
							encoding: "utf-8",
						});
					} catch (err) {
						/* stash pop conflict is non-fatal */
						logWarning(
							"worktree",
							`git stash pop failed: ${getErrorMessage(err)}`,
						);
					}
				}
				restoreShelter();
				// Restore cwd so the caller is not stranded on the integration branch.
				// Without this, the next mergeMilestoneToMain call in a parallel merge
				// sequence uses process.cwd() (now the project root) as worktreeCwd,
				// causing autoCommitDirtyState to commit unrelated milestone files to
				// the integration branch (#2929).
				process.chdir(previousCwd);
				throw new MergeConflictError(
					codeConflicts,
					"squash",
					milestoneBranch,
					mainBranch,
				);
			}
		}
		// No conflicts detected — possibly "already up to date", fall through to commit
	}
	// 9. Commit (handle nothing-to-commit gracefully)
	const commitResult = nativeCommit(originalBasePath_, commitMessage);
	const nothingToCommit = commitResult === null;
	// 9a. Clean up merge state files left by git merge --squash (#1853, #2912).
	// git only removes SQUASH_MSG when the commit reads it directly (plain
	// `git commit`).  nativeCommit uses `-F -` (stdin) or libgit2, neither
	// of which trigger git's SQUASH_MSG cleanup.  MERGE_HEAD is created by
	// libgit2's merge even in squash mode and is not removed by nativeCommit.
	// If left on disk, doctor reports `corrupt_merge_state` on every subsequent run.
	try {
		const gitDir_ = resolveGitDir(originalBasePath_);
		for (const f of ["SQUASH_MSG", "MERGE_MSG", "MERGE_HEAD"]) {
			const p = join(gitDir_, f);
			if (existsSync(p)) unlinkSync(p);
		}
	} catch (err) {
		/* best-effort */
		logError(
			"worktree",
			`post-commit merge state cleanup failed: ${getErrorMessage(err)}`,
		);
	}
	// 9a-ii. Restore stashed files now that the merge+commit is complete (#2151).
	// Pop after commit so stashed changes do not interfere with the squash merge
	// or the commit content.  Conflict on pop is non-fatal — the stash entry is
	// preserved and the user can resolve manually with `git stash pop`.
	if (stashed) {
		try {
			execFileSync("git", ["stash", "pop"], {
				cwd: originalBasePath_,
				stdio: ["ignore", "pipe", "pipe"],
				encoding: "utf-8",
			});
		} catch (e) {
			logWarning(
				"worktree",
				`git stash pop failed, attempting conflict resolution: ${e.message}`,
			);
			// Stash pop after squash merge can conflict on .sf/ state files that
			// diverged between branches.  Left unresolved, these UU entries block
			// every subsequent merge.  Auto-resolve them the same way we handle
			// .sf/ conflicts during the merge itself: accept HEAD (the just-committed
			// version) and drop the now-applied stash.
			const uu = nativeConflictFiles(originalBasePath_);
			const sfUU = uu.filter((f) => f.startsWith(".sf/"));
			const nonSfUU = uu.filter((f) => !f.startsWith(".sf/"));
			if (sfUU.length > 0) {
				for (const f of sfUU) {
					try {
						// Accept the committed (HEAD) version of the state file
						execFileSync("git", ["checkout", "HEAD", "--", f], {
							cwd: originalBasePath_,
							stdio: ["ignore", "pipe", "pipe"],
							encoding: "utf-8",
						});
						nativeAddPaths(originalBasePath_, [f]);
					} catch (e) {
						// Last resort: remove the conflicted state file
						logWarning(
							"worktree",
							`checkout HEAD failed for ${f}, removing: ${e.message}`,
						);
						nativeRmForce(originalBasePath_, [f]);
					}
				}
			}
			if (nonSfUU.length === 0) {
				// All conflicts were .sf/ files — safe to drop the stash
				try {
					execFileSync("git", ["stash", "drop"], {
						cwd: originalBasePath_,
						stdio: ["ignore", "pipe", "pipe"],
						encoding: "utf-8",
					});
				} catch (err) {
					/* stash may already be consumed */
					logWarning(
						"worktree",
						`git stash drop failed: ${getErrorMessage(err)}`,
					);
				}
			} else {
				// Non-.sf conflicts remain — leave stash for manual resolution
				logWarning(
					"reconcile",
					"Stash pop conflict on non-.sf files after merge",
					{
						files: nonSfUU.join(", "),
					},
				);
			}
		}
	}
	// 9a-iii. Restore sheltered queued milestone directories (#2505).
	restoreShelter();
	// 9b. Safety check (#1792): if nothing was committed, verify the milestone
	// work is already on the integration branch before allowing teardown.
	// Compare only non-.sf/ paths — .sf/ state files diverge normally and
	// are auto-resolved during the squash merge.
	if (nothingToCommit) {
		const numstat = nativeDiffNumstat(
			originalBasePath_,
			mainBranch,
			milestoneBranch,
		);
		const codeChanges = numstat.filter(
			(entry) => !entry.path.startsWith(".sf/"),
		);
		if (codeChanges.length > 0) {
			// Milestone has unanchored code changes — abort teardown.
			process.chdir(previousCwd);
			throw new SFError(
				SF_GIT_ERROR,
				`Squash merge produced nothing to commit but milestone branch "${milestoneBranch}" ` +
					`has ${codeChanges.length} code file(s) not on "${mainBranch}". ` +
					`Aborting worktree teardown to prevent data loss.`,
			);
		}
	}
	// 9c. Detect whether any non-.sf/ code files were actually merged (#1906).
	// When a milestone only produced .sf/ metadata (summaries, roadmaps) but no
	// real code, the user sees "milestone complete" but nothing changed in their
	// codebase. Surface this so the caller can warn the user.
	let codeFilesChanged = false;
	if (!nothingToCommit) {
		try {
			const mergedFiles = nativeDiffNumstat(
				originalBasePath_,
				"HEAD~1",
				"HEAD",
			);
			codeFilesChanged = mergedFiles.some(
				(entry) => !entry.path.startsWith(".sf/"),
			);
		} catch (e) {
			// If HEAD~1 doesn't exist (first commit), assume code was changed
			logWarning(
				"worktree",
				`diff numstat failed (assuming code changed): ${e.message}`,
			);
			codeFilesChanged = true;
		}
	}
	// 10. Auto-push if enabled
	let pushed = false;
	if (prefs.auto_push === true && !nothingToCommit) {
		const remote = prefs.remote ?? "origin";
		try {
			execFileSync("git", ["push", remote, mainBranch], {
				cwd: originalBasePath_,
				stdio: ["ignore", "pipe", "pipe"],
				encoding: "utf-8",
			});
			pushed = true;
		} catch (err) {
			// Push failure is non-fatal
			logWarning("worktree", `git push failed: ${getErrorMessage(err)}`);
		}
	}
	// 9b. Auto-create PR if enabled (#2302: no longer gated on pushed/auto_push)
	let prCreated = false;
	if (prefs.auto_pr === true && !nothingToCommit) {
		const remote = prefs.remote ?? "origin";
		const prTarget = prefs.pr_target_branch ?? mainBranch;
		try {
			// Push the milestone branch to remote first
			execFileSync("git", ["push", remote, milestoneBranch], {
				cwd: originalBasePath_,
				stdio: ["ignore", "pipe", "pipe"],
				encoding: "utf-8",
			});
			// Create PR via gh CLI with explicit --head and --base (#2302)
			execFileSync(
				"gh",
				[
					"pr",
					"create",
					"--draft",
					"--base",
					prTarget,
					"--head",
					milestoneBranch,
					"--title",
					`Milestone ${milestoneId} complete`,
					"--body",
					"Auto-created by SF on milestone completion.",
				],
				{
					cwd: originalBasePath_,
					stdio: ["ignore", "pipe", "pipe"],
					encoding: "utf-8",
				},
			);
			prCreated = true;
		} catch (err) {
			// PR creation failure is non-fatal — gh may not be installed or authenticated
			logWarning("worktree", `PR creation failed: ${getErrorMessage(err)}`);
		}
	}
	// 11. Guard removed — step 9b (#1792) now handles this with a smarter check:
	//     throws only when the milestone has unanchored code changes, passes
	//     through when the code is genuinely already on the integration branch.
	// 11a. Pre-teardown safety net (#1853): if the worktree still has uncommitted
	// changes (e.g. nativeHasChanges cache returned stale false, or auto-commit
	// silently failed), force one final commit so code is not destroyed by
	// `git worktree remove --force`.
	//
	// Guard: only run when worktreeCwd is on the milestone branch (#2929).
	// In parallel mode or branch-mode merges, worktreeCwd may be the project
	// root on the integration branch. Committing dirty state there would
	// capture unrelated files from other milestones.
	if (existsSync(worktreeCwd)) {
		let preTeardownBranch = null;
		try {
			preTeardownBranch = nativeGetCurrentBranch(worktreeCwd);
		} catch (err) {
			debugLog("mergeMilestoneToMain", {
				phase: "pre-teardown-branch-detect-failed",
				error: String(err),
			});
		}
		const isOnMilestoneBranch = preTeardownBranch === milestoneBranch;
		if (isOnMilestoneBranch) {
			try {
				const dirtyCheck = nativeWorkingTreeStatus(worktreeCwd);
				if (dirtyCheck) {
					debugLog("mergeMilestoneToMain", {
						phase: "pre-teardown-dirty",
						worktreeCwd,
						status: dirtyCheck.slice(0, 200),
					});
					nativeAddAllWithExclusions(worktreeCwd, RUNTIME_EXCLUSION_PATHS);
					nativeCommit(
						worktreeCwd,
						"chore: pre-teardown auto-commit of uncommitted worktree changes",
					);
				}
			} catch (e) {
				debugLog("mergeMilestoneToMain", {
					phase: "pre-teardown-commit-error",
					error: String(e),
				});
			}
		}
	}
	// 12. Remove worktree directory first (must happen before branch deletion)
	try {
		removeWorktree(originalBasePath_, milestoneId, {
			branch: milestoneBranch,
			deleteBranch: false,
		});
	} catch (err) {
		// Best-effort -- worktree dir may already be gone
		logWarning("worktree", `worktree removal failed: ${getErrorMessage(err)}`);
	}
	// 13. Delete milestone branch (after worktree removal so ref is unlocked)
	try {
		nativeBranchDelete(originalBasePath_, milestoneBranch);
	} catch (err) {
		// Best-effort
		logWarning("worktree", `git branch-delete failed: ${getErrorMessage(err)}`);
	}
	// 14. Clear module state
	originalBase = null;
	nudgeGitBranchCache(previousCwd);
	return { commitMessage, pushed, prCreated, codeFilesChanged };
}
