2391 lines
80 KiB
TypeScript
2391 lines
80 KiB
TypeScript
/**
|
||
* 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 };
|
||
}
|