singularity-forge/src/resources/extensions/sf/auto-worktree.ts
2026-05-02 09:30:14 +02:00

2391 lines
80 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* SF Auto-Worktree -- lifecycle management for auto-mode worktrees.
*
* Auto-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 auto-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 { homedir } from "node:os";
import { isAbsolute, join, sep as pathSep } from "node:path";
import { atomicWriteSync } from "./atomic-write.js";
import { debugLog } from "./debug-logger.js";
import { SF_GIT_ERROR, SF_IO_ERROR, SFError } from "./errors.js";
import {
MergeConflictError,
RUNTIME_EXCLUSION_PATHS,
readIntegrationBranch,
} from "./git-service.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 { safeCopy, safeCopyRecursive } from "./safe-fs.js";
import {
getMilestone,
getMilestoneSlices,
isDbAvailable,
reconcileWorktreeDb,
} from "./sf-db.js";
import { emitJournalEvent } from "./journal.js";
import { logError, logWarning } from "./workflow-logger.js";
import { detectWorktreeName, nudgeGitBranchCache } from "./worktree.js";
import {
createWorktree,
isInsideWorktreesDir,
removeWorktree,
resolveGitDir,
worktreePath,
} from "./worktree-manager.js";
import { isInsideWorktree } from "./repo-identity.js";
const sfHome = process.env.SF_HOME || join(homedir(), ".sf");
const PROJECT_PREFERENCES_FILE = "PREFERENCES.md";
const LEGACY_PROJECT_PREFERENCES_FILE = "preferences.md";
// ─── 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).
] as const;
/**
* 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: string, b: string): boolean {
try {
return realpathSync(a) === realpathSync(b);
} catch (e) {
logWarning("worktree", `isSamePath failed: ${(e as Error).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: string,
dstMilestoneDir: string,
): void {
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: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
} catch (err) {
/* non-fatal per slice */
logWarning(
"worktree",
`assessment slice scan failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
} catch (err) {
/* non-fatal */
logWarning(
"worktree",
`assessment sync failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
// ─── Module State ──────────────────────────────────────────────────────────
/** Original project root before chdir into auto-worktree. */
let originalBase: string | null = null;
function clearProjectRootStateFiles(
basePath: string,
milestoneId: string,
): void {
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 as NodeJS.ErrnoException).code !== "ENOENT") {
logWarning(
"worktree",
`file unlink failed: ${err instanceof Error ? err.message : String(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 as NodeJS.ErrnoException).code;
if (code !== "ENOENT" && code !== "EISDIR") {
logWarning(
"worktree",
`untracked file unlink failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
}
}
} catch (err) {
/* non-fatal — git command may fail if not in repo */
logWarning(
"worktree",
`untracked file cleanup failed: ${err instanceof Error ? err.message : String(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: RegExp[] = [
/\.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: string): boolean =>
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: string,
worktreePath_: string,
milestoneId: string | null,
): void {
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 24'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: ${err instanceof Error ? err.message : String(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_: string,
projectRoot: string,
milestoneId: string | null,
): void {
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(): string | null {
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 as Error).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: string | null,
): string | null {
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: string): string {
// 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 as Error).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: string,
hasMilestoneSummary: (mid: string) => boolean,
): number {
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}): ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
} catch (err) {
/* non-fatal */
logWarning(
"worktree",
`stale runtime unit cleanup failed: ${err instanceof Error ? err.message : String(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: string,
worktreePath_: string,
): { synced: string[] } {
const mainSf = sfRoot(mainBasePath);
const wtSf = sfRoot(worktreePath_);
const synced: string[] = [];
// 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}): ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
// Forward-sync project preferences from project root to worktree (additive only).
// Prefer the canonical uppercase file name, but keep the legacy lowercase
// fallback so older repos still work on case-sensitive filesystems.
{
const worktreeHasPreferences =
existsSync(join(wtSf, PROJECT_PREFERENCES_FILE)) ||
existsSync(join(wtSf, LEGACY_PROJECT_PREFERENCES_FILE));
if (!worktreeHasPreferences) {
for (const file of [
PROJECT_PREFERENCES_FILE,
LEGACY_PROJECT_PREFERENCES_FILE,
] as const) {
const src = join(mainSf, file);
const dst = join(wtSf, file);
if (existsSync(src)) {
try {
cpSync(src, dst);
synced.push(file);
} catch (err) {
/* non-fatal */
logWarning(
"worktree",
`preferences copy failed (${file}): ${err instanceof Error ? err.message : String(err)}`,
);
}
break;
}
}
}
}
// 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}): ${err instanceof Error ? err.message : String(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}): ${err instanceof Error ? err.message : String(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}): ${err instanceof Error ? err.message : String(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}): ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
}
} catch (err) {
/* non-fatal */
logWarning(
"worktree",
`milestone file sync failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
} catch (err) {
/* non-fatal */
logWarning(
"worktree",
`milestone directory sync failed: ${err instanceof Error ? err.message : String(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: string,
worktreePath: string,
milestoneId: string,
): { synced: string[] } {
const mainSf = sfRoot(mainBasePath);
const wtSf = sfRoot(worktreePath);
const synced: string[] = [];
// 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: ${err instanceof Error ? err.message : String(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}): ${err instanceof Error ? err.message : String(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: ${err instanceof Error ? err.message : String(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: string,
dstDir: string,
filter: (name: string) => boolean,
synced: string[],
prefix: string,
): void {
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}): ${err instanceof Error ? err.message : String(err)}`,
);
}
}
} catch (err) {
/* non-fatal — srcDir may not be readable */
logWarning(
"worktree",
`directory read failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
function syncMilestoneDir(
wtSf: string,
mainSf: string,
mid: string,
synced: string[],
): void {
const wtMilestoneDir = join(wtSf, "milestones", mid);
const mainMilestoneDir = join(mainSf, "milestones", mid);
if (!existsSync(wtMilestoneDir)) return;
mkdirSync(mainMilestoneDir, { recursive: true });
const isMd = (name: string): boolean => 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}): ${err instanceof Error ? err.message : String(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: string,
worktreeDir: string,
hookPath?: string,
): string | null {
const errors: string[] = [];
// ── 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: ${err instanceof Error ? err.message : String(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 = err instanceof Error ? err.message : String(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 = err instanceof Error ? err.message : String(err);
errors.push(`workspace.after_create hook failed: ${msg}`);
}
}
}
return errors.length > 0 ? errors.join("; ") : null;
}
// ─── Auto-Worktree Branch Naming ───────────────────────────────────────────
export function autoWorktreeBranch(milestoneId: string): string {
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 auto-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: string,
wtPath: string,
milestoneId: string,
): void {
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: string): string[] {
const results: string[] = [];
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: ${err instanceof Error ? err.message : String(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: string;
let dstContent: string;
try {
srcContent = readFileSync(srcFile, "utf-8");
dstContent = readFileSync(dstFile, "utf-8");
} catch (e) {
logWarning(
"worktree",
`reconcilePlanCheckboxes read failed: ${(e as Error).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<string>();
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: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
}
export function createAutoWorktree(
basePath: string,
milestoneId: string,
): string {
// 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 auto-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: { name: string; path: string; branch: string; exists: boolean };
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, auto-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 auto-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: ${err instanceof Error ? err.message : String(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 auto-mode can recreate artifacts.
*/
function copyPlanningArtifacts(srcBase: string, wtPath: string): void {
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.md when available; fall back to legacy lowercase.
if (existsSync(join(srcSf, PROJECT_PREFERENCES_FILE))) {
safeCopy(
join(srcSf, PROJECT_PREFERENCES_FILE),
join(dstSf, PROJECT_PREFERENCES_FILE),
{ force: true },
);
} else if (existsSync(join(srcSf, LEGACY_PROJECT_PREFERENCES_FILE))) {
safeCopy(
join(srcSf, LEGACY_PROJECT_PREFERENCES_FILE),
join(dstSf, LEGACY_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: string,
milestoneId: string,
opts: { preserveBranch?: boolean } = {},
): void {
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: ${err instanceof Error ? err.message : String(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: ${err instanceof Error ? err.message : String(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: string): boolean {
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: string,
milestoneId: string,
): string | null {
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 as Error).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: string,
milestoneId: string,
): string {
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}: ${err instanceof Error ? err.message : String(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(): string | null {
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(): {
originalBase: string;
worktreeName: string;
branch: string;
} | null {
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: string): boolean {
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_: string,
milestoneId: string,
roadmapContent: string,
): {
commitMessage: string;
pushed: boolean;
prCreated: boolean;
codeFilesChanged: boolean;
} {
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: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
// 2. Get completed slices for commit message
let completedSlices: { id: string; title: string }[] = [];
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: RegExpExecArray | null;
// biome-ignore lint/suspicious/noAssignInExpressions: intentional read loop
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: ${err instanceof Error ? err.message : String(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: string[] = [];
// Helper: restore sheltered milestone directories (#2505).
// Called on both success and error paths to ensure queued CONTEXT files
// are never permanently lost.
const restoreShelter = (): void => {
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: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
try {
rmSync(shelterDir, { recursive: true, force: true });
} catch (err) {
/* best-effort */
logWarning(
"worktree",
`shelter cleanup failed: ${err instanceof Error ? err.message : String(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}): ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
} catch (err) {
// Non-fatal — proceed with merge; untracked files may block it
logWarning(
"worktree",
`milestone shelter operation failed: ${err instanceof Error ? err.message : String(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: ${err instanceof Error ? err.message : String(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: ${err instanceof Error ? err.message : String(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: ${err instanceof Error ? err.message : String(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 as Error).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: ${err instanceof Error ? err.message : String(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: ${err instanceof Error ? err.message : String(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: ${err instanceof Error ? err.message : String(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: ${err instanceof Error ? err.message : String(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 as Error).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 as Error).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: ${err instanceof Error ? err.message : String(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 as Error).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: ${err instanceof Error ? err.message : String(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: ${err instanceof Error ? err.message : String(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: string | null = 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: ${err instanceof Error ? err.message : String(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: ${err instanceof Error ? err.message : String(err)}`,
);
}
// 14. Clear module state
originalBase = null;
nudgeGitBranchCache(previousCwd);
return { commitMessage, pushed, prCreated, codeFilesChanged };
}