refactor: move .gsd/ to external state directory with symlink (ADR-002) (#1242)

Move mutable .gsd/ state from inside the project directory to
~/.gsd/projects/<repo-hash>/, replacing it with a symlink. All worktrees
share the same external state — eliminating the entire bidirectional
sync layer (~370 lines) that was the source of 15+ bug fixes.

Key changes:
- repo-identity.ts: repoIdentity(), externalGsdRoot(), ensureGsdSymlink()
- gsdRoot() resolves through symlinks via realpathSync
- migrate-external.ts: automatic migration with atomic rollback
- resource-version.ts: kept utilities from deleted sync module
- Worktree detection uses git metadata (.git file) instead of path parsing
- gitignore simplified to single .gsd entry
- Doctor checks for failed_migration and broken_symlink

Deleted: auto-worktree-sync.ts, copyWorktreeDb, reconcileWorktreeDb,
reconcilePlanCheckboxes, copyPlanningArtifacts, dual state derivation.

Net: -1271 lines across 38 files. 0 sync ops per dispatch cycle.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-18 14:05:10 -06:00 committed by GitHub
parent 6e727092ff
commit 3102831db9
41 changed files with 599 additions and 1500 deletions

View file

@ -37,7 +37,6 @@ import { resolveAutoSupervisorConfig, loadEffectiveGSDPreferences } from "./pref
import { runGSDDoctor, rebuildState, summarizeDoctorIssues } from "./doctor.js";
import { COMPLETION_TRANSITION_CODES } from "./doctor-types.js";
import { recordHealthSnapshot, checkHealEscalation } from "./doctor-proactive.js";
import { syncStateToProjectRoot } from "./auto-worktree-sync.js";
import { resetRewriteCircuitBreaker } from "./auto-dispatch.js";
import { isDbAvailable } from "./gsd-db.js";
import { consumeSignal } from "./session-status-io.js";
@ -213,15 +212,6 @@ export async function postUnitPreVerification(pctx: PostUnitContext): Promise<"d
// Non-fatal
}
// Sync worktree state back to project root
if (s.originalBasePath && s.originalBasePath !== s.basePath) {
try {
syncStateToProjectRoot(s.basePath, s.originalBasePath, s.currentMilestoneId);
} catch {
// Non-fatal
}
}
// Rewrite-docs completion
if (s.currentUnit.type === "rewrite-docs") {
try {

View file

@ -35,6 +35,7 @@ import {
resolveMilestoneFile,
clearPathCache,
resolveGsdRootFile,
gsdRoot,
} from "./paths.js";
import { isValidationTerminal } from "./state.js";
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
@ -361,7 +362,7 @@ function isStringArray(data: unknown): data is string[] {
/** Path to the persisted completed-unit keys file. */
export function completedKeysPath(base: string): string {
return join(base, ".gsd", "completed-units.json");
return join(gsdRoot(base), "completed-units.json");
}
/** Write a completed unit key to disk (read-modify-write append to set). */

View file

@ -16,6 +16,8 @@ import type {
import { deriveState } from "./state.js";
import { loadFile, getManifestStatus } from "./files.js";
import { loadEffectiveGSDPreferences, resolveSkillDiscoveryMode, getIsolationMode } from "./preferences.js";
import { isInsideWorktree, ensureGsdSymlink } from "./repo-identity.js";
import { migrateToExternalState, recoverFailedMigration } from "./migrate-external.js";
import { sendDesktopNotification } from "./notifications.js";
import { sendRemoteNotification } from "../remote-questions/notify.js";
import {
@ -48,7 +50,7 @@ import {
getAutoWorktreePath,
isInAutoWorktree,
} from "./auto-worktree.js";
import { readResourceVersion } from "./auto-worktree-sync.js";
import { readResourceVersion } from "./resource-version.js";
import { initMetrics, getLedger } from "./metrics.js";
import { initRoutingHistory } from "./routing-history.js";
import { restoreHookState, resetHookState, clearPersistedHookState } from "./post-unit-hooks.js";
@ -61,7 +63,6 @@ import { debugLog, enableDebug, isDebugEnabled, getDebugLogPath } from "./debug-
import type { AutoSession } from "./auto/session.js";
import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { sep as pathSep } from "node:path";
export interface BootstrapDeps {
shouldUseWorktreeIsolation: () => boolean;
@ -113,8 +114,17 @@ export async function bootstrapAutoSession(
ensureGitignore(base, { commitDocs, manageGitignore });
if (manageGitignore !== false) untrackRuntimeFiles(base);
// Migrate legacy in-project .gsd/ to external state directory
recoverFailedMigration(base);
const migration = migrateToExternalState(base);
if (migration.error) {
ctx.ui.notify(`External state migration warning: ${migration.error}`, "warning");
}
// Ensure symlink exists (handles fresh projects and post-migration)
ensureGsdSymlink(base);
// Bootstrap .gsd/ if it doesn't exist
const gsdDir = join(base, ".gsd");
const gsdDir = gsdRoot(base);
if (!existsSync(gsdDir)) {
mkdirSync(join(gsdDir, "milestones"), { recursive: true });
if (commitDocs !== false) {
@ -204,18 +214,6 @@ export async function bootstrapAutoSession(
let state = await deriveState(base);
// Stale worktree state recovery (#654)
if (
state.activeMilestone &&
shouldUseWorktreeIsolation() &&
!detectWorktreeName(base)
) {
const wtPath = getAutoWorktreePath(base, state.activeMilestone.id);
if (wtPath) {
state = await deriveState(wtPath);
}
}
// Milestone branch recovery (#601)
let hasSurvivorBranch = false;
if (
@ -223,7 +221,7 @@ export async function bootstrapAutoSession(
(state.phase === "pre-planning" || state.phase === "needs-discussion") &&
shouldUseWorktreeIsolation() &&
!detectWorktreeName(base) &&
!base.includes(`${pathSep}.gsd${pathSep}worktrees${pathSep}`)
!isInsideWorktree(base)
) {
const milestoneBranch = `milestone/${state.activeMilestone.id}`;
const { nativeBranchExists } = await import("./native-git-bridge.js");
@ -333,14 +331,7 @@ export async function bootstrapAutoSession(
// ── Auto-worktree setup ──
s.originalBasePath = base;
const isUnderGsdWorktrees = (p: string): boolean => {
const marker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`;
if (p.includes(marker)) return true;
const worktreesSuffix = `${pathSep}.gsd${pathSep}worktrees`;
return p.endsWith(worktreesSuffix);
};
if (s.currentMilestoneId && shouldUseWorktreeIsolation() && !detectWorktreeName(base) && !isUnderGsdWorktrees(base)) {
if (s.currentMilestoneId && shouldUseWorktreeIsolation() && !detectWorktreeName(base) && !isInsideWorktree(base)) {
try {
const existingWtPath = getAutoWorktreePath(base, s.currentMilestoneId);
if (existingWtPath) {
@ -355,11 +346,6 @@ export async function bootstrapAutoSession(
ctx.ui.notify(`Created auto-worktree at ${wtPath}`, "info");
}
registerSigtermHandler(s.originalBasePath);
// Load completed keys from BOTH locations
if (s.basePath !== s.originalBasePath) {
loadPersistedKeys(s.basePath, s.completedKeySet);
}
} catch (err) {
ctx.ui.notify(
`Auto-worktree setup failed: ${err instanceof Error ? err.message : String(err)}. Continuing in project root.`,
@ -369,8 +355,8 @@ export async function bootstrapAutoSession(
}
// ── DB lifecycle ──
const gsdDbPath = join(s.basePath, ".gsd", "gsd.db");
const gsdDirPath = join(s.basePath, ".gsd");
const gsdDbPath = join(gsdRoot(s.basePath), "gsd.db");
const gsdDirPath = gsdRoot(s.basePath);
if (existsSync(gsdDirPath) && !existsSync(gsdDbPath)) {
const hasDecisions = existsSync(join(gsdDirPath, "DECISIONS.md"));
const hasRequirements = existsSync(join(gsdDirPath, "REQUIREMENTS.md"));
@ -476,7 +462,7 @@ export async function bootstrapAutoSession(
// Pre-flight: validate milestone queue
try {
const msDir = join(base, ".gsd", "milestones");
const msDir = join(gsdRoot(base), "milestones");
if (existsSync(msDir)) {
const milestoneIds = readdirSync(msDir, { withFileTypes: true })
.filter(d => d.isDirectory() && /^M\d{3}/.test(d.name))

View file

@ -1,199 +0,0 @@
/**
* Worktree project root state synchronization for auto-mode.
*
* When auto-mode runs inside a worktree, dispatch-critical state files
* (.gsd/ metadata) diverge between the worktree (where work happens)
* and the project root (where startAutoMode reads initial state on restart).
* Without syncing, restarting auto-mode reads stale state from the project
* root and re-dispatches already-completed units.
*
* Also contains resource staleness detection and stale worktree escape.
*/
import { existsSync, mkdirSync, readFileSync, cpSync, unlinkSync, readdirSync } from "node:fs";
import { loadJsonFileOrNull } from "./json-persistence.js";
import { join, sep as pathSep } from "node:path";
import { homedir } from "node:os";
import { safeCopy, safeCopyRecursive } from "./safe-fs.js";
import { atomicWriteSync } from "./atomic-write.js";
// ─── Project Root → Worktree Sync ─────────────────────────────────────────
/**
* 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
* gsd.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 prGsd = join(projectRoot, ".gsd");
const wtGsd = join(worktreePath, ".gsd");
// Copy milestone directory from project root to worktree if the project root
// has newer artifacts (e.g. slices that don't exist in the worktree yet)
safeCopyRecursive(join(prGsd, "milestones", milestoneId), join(wtGsd, "milestones", milestoneId))
// Copy living documents from project root to worktree so agents have the
// latest decisions, requirements, project state, and knowledge.
for (const doc of ["DECISIONS.md", "REQUIREMENTS.md", "PROJECT.md", "KNOWLEDGE.md"]) {
safeCopy(join(prGsd, doc), join(wtGsd, doc), { force: true });
}
// Delete worktree gsd.db so it rebuilds from the freshly synced files.
// Stale DB rows are the root cause of the infinite skip loop (#853).
try {
const wtDb = join(wtGsd, "gsd.db");
if (existsSync(wtDb)) {
unlinkSync(wtDb);
}
} catch { /* non-fatal */ }
}
// ─── Worktree → Project Root Sync ─────────────────────────────────────────
/**
* Sync dispatch-critical .gsd/ 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 wtGsd = join(worktreePath, ".gsd");
const prGsd = join(projectRoot, ".gsd");
// 1. STATE.md — the quick-glance status used by initial deriveState()
safeCopy(join(wtGsd, "STATE.md"), join(prGsd, "STATE.md"), { force: true })
// 2. Milestone directory — ROADMAP, slice PLANs, task summaries
// Copy the entire milestone .gsd subtree so deriveState reads current checkboxes
safeCopyRecursive(join(wtGsd, "milestones", milestoneId), join(prGsd, "milestones", milestoneId), { force: true })
// 3. Merge completed-units.json (set-union of both locations)
// Prevents already-completed units from being re-dispatched after crash/restart.
const srcKeysFile = join(wtGsd, "completed-units.json");
const dstKeysFile = join(prGsd, "completed-units.json");
if (existsSync(srcKeysFile)) {
try {
const srcKeys: string[] = JSON.parse(readFileSync(srcKeysFile, "utf8"));
let dstKeys: string[] = [];
if (existsSync(dstKeysFile)) {
try { dstKeys = JSON.parse(readFileSync(dstKeysFile, "utf8")); } catch { /* ignore corrupt dst */ }
}
const merged = [...new Set([...dstKeys, ...srcKeys])];
atomicWriteSync(dstKeysFile, JSON.stringify(merged, null, 2));
} catch { /* non-fatal */ }
}
// 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(wtGsd, "runtime", "units"), join(prGsd, "runtime", "units"), { force: true })
// 5. Living documents — decisions, requirements, project description, knowledge.
// Agents update these during slice execution. Without syncing, a new session
// reads stale copies from the project root, losing architectural decisions,
// requirement status updates, and accumulated knowledge (#1168).
for (const doc of ["DECISIONS.md", "REQUIREMENTS.md", "PROJECT.md", "KNOWLEDGE.md"]) {
safeCopy(join(wtGsd, doc), join(prGsd, doc), { force: true });
}
}
// ─── Resource Staleness ───────────────────────────────────────────────────
/**
* Read the resource version (semver) from the managed-resources manifest.
* Uses gsdVersion instead of syncedAt so that launching a second session
* doesn't falsely trigger staleness (#804).
*/
function isManifestWithVersion(data: unknown): data is { gsdVersion: string } {
return data !== null && typeof data === "object" && "gsdVersion" in data! && typeof (data as Record<string, unknown>).gsdVersion === "string";
}
export function readResourceVersion(): string | null {
const agentDir = process.env.GSD_CODING_AGENT_DIR || join(homedir(), ".gsd", "agent");
const manifestPath = join(agentDir, "managed-resources.json");
const manifest = loadJsonFileOrNull(manifestPath, isManifestWithVersion);
return manifest?.gsdVersion ?? 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 "GSD resources were updated since this session started. Restart gsd 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 `.gsd/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 {
const marker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`;
const idx = base.indexOf(marker);
if (idx === -1) return base;
// base is inside .gsd/worktrees/<something> — extract the project root
const projectRoot = base.slice(0, idx);
try {
process.chdir(projectRoot);
} catch {
// If chdir fails, return the original — caller will handle errors downstream
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(
gsdRootPath: string,
hasMilestoneSummary: (mid: string) => boolean,
): number {
const runtimeUnitsDir = join(gsdRootPath, "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 { /* non-fatal */ }
}
}
} catch { /* non-fatal */ }
return cleaned;
}

View file

@ -6,25 +6,24 @@
* manages create, enter, detect, and teardown for auto-mode worktrees.
*/
import { existsSync, cpSync, readFileSync, readdirSync, mkdirSync, realpathSync, unlinkSync, statSync } from "node:fs";
import { existsSync, readFileSync, realpathSync, unlinkSync, statSync } from "node:fs";
import { isAbsolute, join, sep } from "node:path";
import { GSDError, GSD_IO_ERROR, GSD_GIT_ERROR } from "./errors.js";
import { copyWorktreeDb, reconcileWorktreeDb, isDbAvailable } from "./gsd-db.js";
import { atomicWriteSync } from "./atomic-write.js";
import { execSync, execFileSync } from "node:child_process";
import { safeCopy, safeCopyRecursive } from "./safe-fs.js";
import {
createWorktree,
removeWorktree,
worktreePath,
} from "./worktree-manager.js";
import { detectWorktreeName, resolveGitHeadPath, nudgeGitBranchCache } from "./worktree.js";
import { ensureGsdSymlink } from "./repo-identity.js";
import {
MergeConflictError,
readIntegrationBranch,
} from "./git-service.js";
import { parseRoadmap } from "./files.js";
import { loadEffectiveGSDPreferences } from "./preferences.js";
import { gsdRoot } from "./paths.js";
import {
nativeGetCurrentBranch,
nativeWorkingTreeStatus,
@ -103,111 +102,6 @@ export function autoWorktreeBranch(milestoneId: string): string {
* 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, ".gsd", "milestones", milestoneId);
const dstMilestone = join(wtPath, ".gsd", "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 { /* non-fatal */ }
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 { 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 { /* non-fatal */ }
}
}
// Also forward-merge completed-units.json (set-union)
const srcKeys = join(projectRoot, ".gsd", "completed-units.json");
const dstKeys = join(wtPath, ".gsd", "completed-units.json");
if (existsSync(srcKeys)) {
try {
const src: string[] = JSON.parse(readFileSync(srcKeys, "utf-8"));
let dst: string[] = [];
if (existsSync(dstKeys)) {
try { dst = JSON.parse(readFileSync(dstKeys, "utf-8")); } catch { /* ignore corrupt */ }
}
const merged = [...new Set([...dst, ...src])];
if (merged.length > dst.length) {
mkdirSync(join(wtPath, ".gsd"), { recursive: true });
atomicWriteSync(dstKeys, JSON.stringify(merged), "utf-8");
}
} catch { /* non-fatal */ }
}
}
export function createAutoWorktree(basePath: string, milestoneId: string): string {
const branch = autoWorktreeBranch(milestoneId);
@ -227,32 +121,8 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin
info = createWorktree(basePath, milestoneId, { branch, startPoint: integrationBranch });
}
// Copy .gsd/ 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 .gsd/ 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);
}
// Ensure worktree shares external state via symlink
ensureGsdSymlink(info.path);
// Run user-configured post-create hook (#597) — e.g. copy .env, symlink assets
const hookError = runWorktreePostCreateHook(basePath, info.path);
@ -279,36 +149,6 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin
return info.path;
}
/**
* Copy .gsd/ 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 srcGsd = join(srcBase, ".gsd");
const dstGsd = join(wtPath, ".gsd");
if (!existsSync(srcGsd)) return;
// Copy milestones/ directory (planning files, roadmaps, plans, research)
safeCopyRecursive(join(srcGsd, "milestones"), join(dstGsd, "milestones"), { force: true });
// Copy top-level planning files
for (const file of ["DECISIONS.md", "REQUIREMENTS.md", "PROJECT.md", "QUEUE.md", "STATE.md", "KNOWLEDGE.md", "OVERRIDES.md"]) {
safeCopy(join(srcGsd, file), join(dstGsd, file), { force: true });
}
// Copy gsd.db if present in source
const srcDb = join(srcGsd, "gsd.db");
const destDb = join(dstGsd, "gsd.db");
if (existsSync(srcDb)) {
try {
copyWorktreeDb(srcDb, destDb);
} catch { /* non-fatal */ }
}
}
/**
* Teardown an auto-worktree: chdir back to original base, then remove
* the worktree and its branch.
@ -346,7 +186,7 @@ export function isInAutoWorktree(basePath: string): boolean {
// Primary check: use originalBase if available (fast path)
if (originalBase) {
const resolvedBase = existsSync(basePath) ? realpathSync(basePath) : basePath;
const wtDir = join(resolvedBase, ".gsd", "worktrees");
const wtDir = join(gsdRoot(resolvedBase), "worktrees");
if (!cwd.startsWith(wtDir)) return false;
const branch = nativeGetCurrentBranch(cwd);
return branch.startsWith("milestone/");
@ -364,8 +204,8 @@ export function isInAutoWorktree(basePath: string): boolean {
// Worktrees have a .git file with "gitdir: ..." pointing to the main repo
const gitContent = readFileSync(worktreeMarker, "utf-8").trim();
if (!gitContent.startsWith("gitdir:")) return false;
// Verify cwd path contains .gsd/worktrees/
if (!cwd.includes(`${sep}.gsd${sep}worktrees${sep}`) && !cwd.includes("/.gsd/worktrees/")) return false;
// Verify we're inside a GSD-managed worktree
if (!detectWorktreeName(cwd)) return false;
const branch = nativeGetCurrentBranch(cwd);
return branch.startsWith("milestone/");
} catch {
@ -458,7 +298,7 @@ export function getActiveAutoWorktreeContext(): {
if (!originalBase) return null;
const cwd = process.cwd();
const resolvedBase = existsSync(originalBase) ? realpathSync(originalBase) : originalBase;
const wtDir = join(resolvedBase, ".gsd", "worktrees");
const wtDir = join(gsdRoot(resolvedBase), "worktrees");
if (!cwd.startsWith(wtDir)) return null;
const worktreeName = detectWorktreeName(cwd);
if (!worktreeName) return null;
@ -518,15 +358,6 @@ export function mergeMilestoneToMain(
// 1. Auto-commit dirty state in worktree before leaving
autoCommitDirtyState(worktreeCwd);
// Reconcile worktree DB into main DB before leaving worktree context
if (isDbAvailable()) {
try {
const worktreeDbPath = join(worktreeCwd, ".gsd", "gsd.db");
const mainDbPath = join(originalBasePath_, ".gsd", "gsd.db");
reconcileWorktreeDb(mainDbPath, worktreeDbPath);
} catch { /* non-fatal */ }
}
// 2. Parse roadmap for slice listing
const roadmap = parseRoadmap(roadmapContent);
const completedSlices = roadmap.slices.filter(s => s.done);
@ -535,9 +366,8 @@ export function mergeMilestoneToMain(
const previousCwd = process.cwd();
process.chdir(originalBasePath_);
// 3a. Auto-commit any dirty state in the project root that syncStateToProjectRoot
// wrote during execution. Without this, the squash merge can fail with
// "Your local changes to the following files would be overwritten by merge" (#1127).
// 3a. Auto-commit any dirty state in the project root. Without this, the
// squash merge can fail with "Your local changes would be overwritten" (#1127).
autoCommitDirtyState(originalBasePath_);
// 3b. Remove untracked .gsd/ files that syncStateToProjectRoot copied.
@ -565,7 +395,7 @@ export function mergeMilestoneToMain(
// "Your local changes would be overwritten" (#827).
const gsdStateFiles = ["STATE.md", "completed-units.json", "auto.lock"];
for (const f of gsdStateFiles) {
const p = join(originalBasePath_, ".gsd", f);
const p = join(gsdRoot(originalBasePath_), f);
try { unlinkSync(p); } catch { /* non-fatal — file may not exist */ }
}
nativeCheckoutBranch(originalBasePath_, mainBranch);

View file

@ -69,12 +69,10 @@ import { closeoutUnit } from "./auto-unit-closeout.js";
import { recoverTimedOutUnit } from "./auto-timeout-recovery.js";
import { selectAndApplyModel } from "./auto-model-selection.js";
import {
syncProjectRootToWorktree,
syncStateToProjectRoot,
readResourceVersion,
checkResourcesStale,
escapeStaleWorktree,
} from "./auto-worktree-sync.js";
} from "./resource-version.js";
import { initRoutingHistory, resetRoutingHistory, recordOutcome } from "./routing-history.js";
import {
checkPostUnitHooks,
@ -180,7 +178,7 @@ import { runPostUnitVerification, type VerificationContext } from "./auto-verifi
import { postUnitPreVerification, postUnitPostVerification, type PostUnitContext } from "./auto-post-unit.js";
import { bootstrapAutoSession, type BootstrapDeps } from "./auto-start.js";
// Worktree sync, resource staleness, stale worktree escape → auto-worktree-sync.ts
// Resource staleness, stale worktree escape → resource-version.ts
// ─── Session State ─────────────────────────────────────────────────────────
@ -1018,11 +1016,6 @@ async function dispatchNextUnit(
// Non-fatal
}
// ── Sync project root artifacts into worktree ──
if (s.originalBasePath && s.basePath !== s.originalBasePath && s.currentMilestoneId) {
syncProjectRootToWorktree(s.originalBasePath, s.basePath, s.currentMilestoneId);
}
const stopDeriveTimer = debugTime("derive-state");
let state = await deriveState(s.basePath);
stopDeriveTimer({

View file

@ -9,9 +9,10 @@
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join, resolve, sep } from "node:path";
import { join, resolve } from "node:path";
import { randomUUID } from "node:crypto";
import { gsdRoot } from "./paths.js";
import { resolveProjectRoot } from "./worktree.js";
// ─── Types ────────────────────────────────────────────────────────────────────
@ -58,15 +59,8 @@ const VALID_CLASSIFICATIONS: readonly string[] = [
* directory that contains `.gsd/worktrees/` that's the project root.
*/
export function resolveCapturesPath(basePath: string): string {
const resolved = resolve(basePath);
const worktreeMarker = `${sep}.gsd${sep}worktrees${sep}`;
const idx = resolved.indexOf(worktreeMarker);
if (idx !== -1) {
// basePath is inside a worktree — resolve to project root
const projectRoot = resolved.slice(0, idx);
return join(projectRoot, ".gsd", CAPTURES_FILENAME);
}
return join(gsdRoot(basePath), CAPTURES_FILENAME);
const projectRoot = resolveProjectRoot(resolve(basePath));
return join(gsdRoot(projectRoot), CAPTURES_FILENAME);
}
// ─── File I/O ─────────────────────────────────────────────────────────────────

View file

@ -9,6 +9,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent
import { existsSync, readFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { deriveState } from "./state.js";
import { gsdRoot } from "./paths.js";
import { appendCapture, hasPendingCaptures, loadPendingCaptures } from "./captures.js";
import { appendOverride, appendKnowledge } from "./files.js";
import {
@ -136,7 +137,7 @@ export async function handleCapture(args: string, ctx: ExtensionCommandContext):
const basePath = process.cwd();
// Ensure .gsd/ exists — capture should work even without a milestone
const gsdDir = join(basePath, ".gsd");
const gsdDir = gsdRoot(basePath);
if (!existsSync(gsdDir)) {
mkdirSync(gsdDir, { recursive: true });
}

View file

@ -8,6 +8,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent
import type { GSDState } from "./types.js";
import { existsSync, readFileSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { gsdRoot } from "./paths.js";
import { enableDebug } from "./debug-logger.js";
import { deriveState } from "./state.js";
import { GSDDashboardOverlay } from "./dashboard-overlay.js";
@ -698,7 +699,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
if (trimmed === "new-milestone") {
const basePath = projectRoot();
const headlessContextPath = join(basePath, ".gsd", "runtime", "headless-context.md");
const headlessContextPath = join(gsdRoot(basePath), "runtime", "headless-context.md");
if (existsSync(headlessContextPath)) {
const seedContext = readFileSync(headlessContextPath, "utf-8");
try { unlinkSync(headlessContextPath); } catch { /* non-fatal */ }

View file

@ -9,6 +9,7 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { gsdRoot } from "./paths.js";
// ─── Types ──────────────────────────────────────────────────────────────────────
@ -214,7 +215,7 @@ export function detectV1Planning(basePath: string): V1Detection | null {
// ─── V2 GSD Detection ──────────────────────────────────────────────────────────
function detectV2Gsd(basePath: string): V2Detection | null {
const gsdPath = join(basePath, ".gsd");
const gsdPath = gsdRoot(basePath);
if (!existsSync(gsdPath)) return null;

View file

@ -1,4 +1,4 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
import { join, sep } from "node:path";
import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js";
@ -13,6 +13,7 @@ import { nativeIsRepo, nativeWorktreeRemove, nativeBranchList, nativeBranchDelet
import { readCrashLock, isLockProcessAlive, clearLock } from "./crash-recovery.js";
import { ensureGitignore } from "./gitignore.js";
import { readAllSessionStatuses, isSessionStale, removeSessionStatus } from "./session-status-io.js";
import { recoverFailedMigration } from "./migrate-external.js";
export async function checkGitHealth(
basePath: string,
@ -508,6 +509,53 @@ export async function checkRuntimeHealth(
} catch {
// Non-fatal — gitignore check failed
}
// ── External state symlink health ──────────────────────────────────────
try {
const localGsd = join(basePath, ".gsd");
if (existsSync(localGsd)) {
const stat = lstatSync(localGsd);
// Check for .gsd.migrating (failed migration)
const migratingPath = join(basePath, ".gsd.migrating");
if (existsSync(migratingPath)) {
issues.push({
severity: "error",
code: "failed_migration",
scope: "project",
unitId: "project",
message: "Found .gsd.migrating — a previous external state migration failed. State may be incomplete.",
file: ".gsd.migrating",
fixable: true,
});
if (shouldFix("failed_migration")) {
if (recoverFailedMigration(basePath)) {
fixesApplied.push("recovered failed migration (.gsd.migrating → .gsd)");
}
}
}
// Check symlink target exists
if (stat.isSymbolicLink()) {
try {
realpathSync(localGsd);
} catch {
issues.push({
severity: "error",
code: "broken_symlink",
scope: "project",
unitId: "project",
message: ".gsd symlink target does not exist. External state directory may have been deleted.",
file: ".gsd",
fixable: false,
});
}
}
}
} catch {
// Non-fatal — external state check failed
}
}
/**

View file

@ -30,7 +30,9 @@ export type DoctorIssueCode =
| "state_file_stale"
| "state_file_missing"
| "gitignore_missing_patterns"
| "unresolvable_dependency";
| "unresolvable_dependency"
| "failed_migration"
| "broken_symlink";
/**
* Issue codes that represent expected completion-transition states.

View file

@ -268,7 +268,7 @@ function resolveActivityDirs(basePath: string, activeMilestone?: string | null):
if (activeMilestone) {
const wtPath = getAutoWorktreePath(basePath, activeMilestone);
if (wtPath) {
const wtActivityDir = join(wtPath, ".gsd", "activity");
const wtActivityDir = join(gsdRoot(wtPath), "activity");
if (existsSync(wtActivityDir)) {
dirs.push(wtActivityDir);
}
@ -285,7 +285,7 @@ function resolveActivityDirs(basePath: string, activeMilestone?: string | null):
// ─── Completed Keys Loader ────────────────────────────────────────────────────
function loadCompletedKeys(basePath: string): string[] {
const file = join(basePath, ".gsd", "completed-units.json");
const file = join(gsdRoot(basePath), "completed-units.json");
try {
if (existsSync(file)) {
return JSON.parse(readFileSync(file, "utf-8"));

View file

@ -11,6 +11,7 @@
import { execFileSync, execSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { gsdRoot } from "./paths.js";
import { GIT_NO_PROMPT_ENV } from "./git-constants.js";
import {
@ -193,7 +194,7 @@ export const RUNTIME_EXCLUSION_PATHS: readonly string[] = [
* Format: .gsd/milestones/<MID>/<MID>-META.json
*/
function milestoneMetaPath(basePath: string, milestoneId: string): string {
return join(basePath, ".gsd", "milestones", milestoneId, `${milestoneId}-META.json`);
return join(gsdRoot(basePath), "milestones", milestoneId, `${milestoneId}-META.json`);
}
/**
@ -237,7 +238,7 @@ export function writeIntegrationBranch(basePath: string, milestoneId: string, br
if (existingBranch === branch) return;
const metaFile = milestoneMetaPath(basePath, milestoneId);
mkdirSync(join(basePath, ".gsd", "milestones", milestoneId), { recursive: true });
mkdirSync(join(gsdRoot(basePath), "milestones", milestoneId), { recursive: true });
// Merge with existing metadata if present
let existing: Record<string, unknown> = {};

View file

@ -9,10 +9,12 @@
import { join } from "node:path";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { nativeRmCached } from "./native-git-bridge.js";
import { gsdRoot } from "./paths.js";
/**
* Patterns that are always correct regardless of project type.
* No one ever wants these tracked.
* GSD runtime patterns for git index cleanup.
* With external state (symlink), these are a no-op in most cases,
* but retained for backwards compatibility during migration.
*/
const GSD_RUNTIME_PATTERNS = [
".gsd/activity/",
@ -31,8 +33,8 @@ const GSD_RUNTIME_PATTERNS = [
] as const;
const BASELINE_PATTERNS = [
// ── GSD runtime (not source artifacts — planning files are tracked) ──
...GSD_RUNTIME_PATTERNS,
// ── GSD state directory (symlink to external storage) ──
".gsd",
// ── OS junk ──
".DS_Store",
@ -90,41 +92,12 @@ export function ensureGitignore(basePath: string, options?: { commitDocs?: boole
if (options?.manageGitignore === false) return false;
const gitignorePath = join(basePath, ".gitignore");
const commitDocs = options?.commitDocs !== false; // default true
let existing = "";
if (existsSync(gitignorePath)) {
existing = readFileSync(gitignorePath, "utf-8");
}
// When commit_docs is false, ensure blanket ".gsd/" is in .gitignore
// and skip the self-heal that would remove it.
if (!commitDocs) {
return ensureBlanketGsdIgnore(gitignorePath, existing);
}
// Self-heal: remove blanket ".gsd/" lines from pre-v2.14.0 projects.
// The blanket ignore prevented planning artifacts (.gsd/milestones/) from
// being tracked in git, causing artifacts to vanish in worktrees and
// triggering loop detection failures. Replace with explicit runtime-only
// ignores so planning files are tracked naturally.
let modified = false;
const lines = existing.split("\n");
const filteredLines = lines.filter(line => {
const trimmed = line.trim();
// Remove standalone ".gsd/" lines (blanket ignore) but keep specific
// .gsd/ subpath patterns like ".gsd/activity/" or ".gsd/auto.lock"
if (trimmed === ".gsd/" || trimmed === ".gsd") {
modified = true;
return false;
}
return true;
});
if (modified) {
existing = filteredLines.join("\n");
writeFileSync(gitignorePath, existing, "utf-8");
}
// Parse existing lines (trimmed, ignoring comments and blanks)
const existingLines = new Set(
existing
@ -136,7 +109,7 @@ export function ensureGitignore(basePath: string, options?: { commitDocs?: boole
// Find patterns not yet present
const missing = BASELINE_PATTERNS.filter((p) => !existingLines.has(p));
if (missing.length === 0) return modified;
if (missing.length === 0) return false;
// Build the block to append
const block = [
@ -184,8 +157,8 @@ export function untrackRuntimeFiles(basePath: string): void {
* creating a duplicate when an uppercase file already exists.
*/
export function ensurePreferences(basePath: string): boolean {
const preferencesPath = join(basePath, ".gsd", "preferences.md");
const legacyPath = join(basePath, ".gsd", "PREFERENCES.md");
const preferencesPath = join(gsdRoot(basePath), "preferences.md");
const legacyPath = join(gsdRoot(basePath), "PREFERENCES.md");
if (existsSync(preferencesPath) || existsSync(legacyPath)) {
return false;
@ -240,31 +213,4 @@ custom_instructions:
return true;
}
/**
* When commit_docs is false, ensure `.gsd/` is in .gitignore as a blanket
* pattern. This keeps all GSD artifacts local-only.
* Returns true if the file was modified, false if already complete.
*/
function ensureBlanketGsdIgnore(gitignorePath: string, existing: string): boolean {
const existingLines = new Set(
existing
.split("\n")
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("#")),
);
// Already has blanket .gsd/ ignore
if (existingLines.has(".gsd/") || existingLines.has(".gsd")) return false;
const block = [
"",
"# ── GSD (local-only, commit_docs: false) ──",
".gsd/",
"",
].join("\n");
const prefix = existing && !existing.endsWith("\n") ? "\n" : "";
writeFileSync(gitignorePath, existing + prefix + block, "utf-8");
return true;
}

View file

@ -6,8 +6,7 @@
// Schema is initialized on first open with WAL mode for file-backed DBs.
import { createRequire } from 'node:module';
import { copyFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname } from 'node:path';
import { existsSync } from 'node:fs';
import type { Decision, Requirement } from './types.js';
import { GSDError, GSD_STALE_STATE } from './errors.js';
@ -565,169 +564,6 @@ export function getActiveRequirements(): Requirement[] {
}));
}
// ─── Worktree DB Operations ────────────────────────────────────────────────
/**
* Copy a gsd.db file to a new worktree location.
* Copies only the .db file skips -wal and -shm files so the copy starts clean.
* Returns true on success, false on failure (never throws).
*/
export function copyWorktreeDb(srcDbPath: string, destDbPath: string): boolean {
try {
if (!existsSync(srcDbPath)) {
return false; // source doesn't exist — expected when no DB yet
}
const destDir = dirname(destDbPath);
mkdirSync(destDir, { recursive: true });
copyFileSync(srcDbPath, destDbPath);
return true;
} catch (err) {
process.stderr.write(`gsd-db: failed to copy DB to worktree: ${(err as Error).message}\n`);
return false;
}
}
/**
* Reconcile rows from a worktree DB back into the main DB using ATTACH DATABASE.
* Merges all three tables (decisions, requirements, artifacts) via INSERT OR REPLACE.
* Detects conflicts where both DBs modified the same row.
*
* ATTACH must happen outside any transaction. INSERT OR REPLACE runs inside a transaction.
* DETACH happens after commit (or rollback on error).
*/
export function reconcileWorktreeDb(
mainDbPath: string,
worktreeDbPath: string,
): { decisions: number; requirements: number; artifacts: number; conflicts: string[] } {
const zero = { decisions: 0, requirements: 0, artifacts: 0, conflicts: [] as string[] };
// Validate worktree DB exists
if (!existsSync(worktreeDbPath)) {
return zero;
}
// Safety: reject single quotes which could break the ATTACH DATABASE '...' SQL literal.
// SQLite ATTACH doesn't support parameterized binding. We block the one dangerous char
// rather than allowlisting, since OS temp paths vary widely (tildes, parens, unicode).
if (worktreeDbPath.includes("'")) {
process.stderr.write(`gsd-db: worktree DB reconciliation failed: path contains unsafe characters\n`);
return zero;
}
// Ensure main DB is open
if (!currentDb) {
const opened = openDatabase(mainDbPath);
if (!opened) {
process.stderr.write(`gsd-db: worktree DB reconciliation failed: cannot open main DB\n`);
return zero;
}
}
const adapter = currentDb!;
const conflicts: string[] = [];
try {
// ATTACH must be outside transaction
adapter.exec(`ATTACH DATABASE '${worktreeDbPath}' AS wt`);
try {
// ── Conflict detection phase ──
// Decisions: same id, different content
const decisionConflicts = adapter.prepare(
`SELECT m.id FROM decisions m
INNER JOIN wt.decisions w ON m.id = w.id
WHERE m.decision != w.decision
OR m.choice != w.choice
OR m.rationale != w.rationale
OR m.superseded_by IS NOT w.superseded_by`,
).all();
for (const row of decisionConflicts) {
conflicts.push(`decision ${row['id']}: modified in both main and worktree`);
}
// Requirements: same id, different content
const reqConflicts = adapter.prepare(
`SELECT m.id FROM requirements m
INNER JOIN wt.requirements w ON m.id = w.id
WHERE m.description != w.description
OR m.status != w.status
OR m.notes != w.notes
OR m.superseded_by IS NOT w.superseded_by`,
).all();
for (const row of reqConflicts) {
conflicts.push(`requirement ${row['id']}: modified in both main and worktree`);
}
// Artifacts: same path, different content
const artifactConflicts = adapter.prepare(
`SELECT m.path FROM artifacts m
INNER JOIN wt.artifacts w ON m.path = w.path
WHERE m.full_content != w.full_content
OR m.artifact_type != w.artifact_type`,
).all();
for (const row of artifactConflicts) {
conflicts.push(`artifact ${row['path']}: modified in both main and worktree`);
}
// ── Merge phase (inside manual transaction) ──
adapter.exec('BEGIN');
try {
// Decisions: exclude seq to let main auto-assign
adapter.exec(
`INSERT OR REPLACE INTO decisions (id, when_context, scope, decision, choice, rationale, revisable, superseded_by)
SELECT id, when_context, scope, decision, choice, rationale, revisable, superseded_by FROM wt.decisions`,
);
const dCount = adapter.prepare('SELECT changes() as cnt').get();
// Requirements: full row copy
adapter.exec(
`INSERT OR REPLACE INTO requirements (id, class, status, description, why, source, primary_owner, supporting_slices, validation, notes, full_content, superseded_by)
SELECT id, class, status, description, why, source, primary_owner, supporting_slices, validation, notes, full_content, superseded_by FROM wt.requirements`,
);
const rCount = adapter.prepare('SELECT changes() as cnt').get();
// Artifacts: copy with fresh imported_at timestamp
adapter.exec(
`INSERT OR REPLACE INTO artifacts (path, artifact_type, milestone_id, slice_id, task_id, full_content, imported_at)
SELECT path, artifact_type, milestone_id, slice_id, task_id, full_content, datetime('now') FROM wt.artifacts`,
);
const aCount = adapter.prepare('SELECT changes() as cnt').get();
adapter.exec('COMMIT');
const result = {
decisions: (dCount?.['cnt'] as number) || 0,
requirements: (rCount?.['cnt'] as number) || 0,
artifacts: (aCount?.['cnt'] as number) || 0,
conflicts,
};
if (conflicts.length > 0) {
process.stderr.write(`gsd-db: reconciliation conflicts:\n${conflicts.map(c => ` - ${c}`).join('\n')}\n`);
}
process.stderr.write(
`gsd-db: reconciled ${result.decisions} decisions, ${result.requirements} requirements, ${result.artifacts} artifacts (${conflicts.length} conflicts)\n`,
);
return result;
} catch (err) {
adapter.exec('ROLLBACK');
throw err;
}
} finally {
// DETACH always, even on error
try {
adapter.exec('DETACH DATABASE wt');
} catch {
// swallow — may already be detached
}
}
} catch (err) {
process.stderr.write(`gsd-db: worktree DB reconciliation failed: ${(err as Error).message}\n`);
return zero;
}
}
/**
* Returns the PID of the process that opened the current DB connection.
* Returns 0 if no connection is open.

View file

@ -104,7 +104,7 @@ export function checkAutoStartAfterDiscuss(): boolean {
const missing = milestoneIds.filter(id => {
const hasContext = !!resolveMilestoneFile(basePath, id, "CONTEXT");
const hasDraft = !!resolveMilestoneFile(basePath, id, "CONTEXT-DRAFT");
const hasDir = existsSync(join(basePath, ".gsd", "milestones", id));
const hasDir = existsSync(join(gsdRoot(basePath), "milestones", id));
return !hasContext && !hasDraft && !hasDir;
});
if (missing.length > 0) {
@ -122,7 +122,7 @@ export function checkAutoStartAfterDiscuss(): boolean {
// The LLM writes DISCUSSION-MANIFEST.json after each Phase 3 gate decision.
// If the manifest exists but gates_completed < total, the LLM hasn't finished
// presenting all readiness gates to the user — block auto-start.
const manifestPath = join(basePath, ".gsd", "DISCUSSION-MANIFEST.json");
const manifestPath = join(gsdRoot(basePath), "DISCUSSION-MANIFEST.json");
if (existsSync(manifestPath)) {
try {
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
@ -295,7 +295,7 @@ export async function showHeadlessMilestoneCreation(
const nextId = nextMilestoneId(existingIds, prefs?.preferences?.unique_milestone_ids ?? false);
// Create milestone directory
const milestoneDir = join(basePath, ".gsd", "milestones", nextId, "slices");
const milestoneDir = join(gsdRoot(basePath), "milestones", nextId, "slices");
mkdirSync(milestoneDir, { recursive: true });
// Build and dispatch the headless discuss prompt
@ -410,7 +410,7 @@ export async function showDiscuss(
basePath: string,
): Promise<void> {
// Guard: no .gsd/ project
if (!existsSync(join(basePath, ".gsd"))) {
if (!existsSync(gsdRoot(basePath))) {
ctx.ui.notify("No GSD project found. Run /gsd to start one first.", "warning");
return;
}
@ -753,7 +753,7 @@ export async function showSmartEntry(
}
// ── Detection preamble — run before any bootstrap ────────────────────
if (!existsSync(join(basePath, ".gsd"))) {
if (!existsSync(gsdRoot(basePath))) {
const detection = detectProjectState(basePath);
// v1 .planning/ detected — offer migration before anything else

View file

@ -75,7 +75,7 @@ async function ensureDbAvailable(): Promise<boolean> {
if (db.isDbAvailable()) return true;
// Auto-initialize: open (and create if needed) the DB at the standard path
const gsdDir = join(process.cwd(), ".gsd");
const gsdDir = gsdRoot(process.cwd());
if (!existsSync(gsdDir)) return false; // No GSD project — can't create DB
const dbPath = join(gsdDir, "gsd.db");
return db.openDatabase(dbPath);
@ -629,7 +629,7 @@ export default function (pi: ExtensionAPI) {
description: shortcutDesc("Open GSD dashboard", "/gsd status"),
handler: async (ctx) => {
// Only show if .gsd/ exists
if (!existsSync(join(process.cwd(), ".gsd"))) {
if (!existsSync(gsdRoot(process.cwd()))) {
ctx.ui.notify("No .gsd/ directory found. Run /gsd to start.", "info");
return;
}
@ -659,7 +659,7 @@ export default function (pi: ExtensionAPI) {
// ── before_agent_start: inject GSD contract into true system prompt ─────
pi.on("before_agent_start", async (event, ctx: ExtensionContext) => {
if (!existsSync(join(process.cwd(), ".gsd"))) return;
if (!existsSync(gsdRoot(process.cwd()))) return;
const stopContextTimer = debugTime("context-inject");
const systemContent = loadPrompt("system");

View file

@ -18,6 +18,7 @@ import {
import {
resolveGsdRootFile,
milestonesDir,
gsdRoot,
resolveTaskFiles,
} from './paths.js';
import { findMilestoneIds } from './guided-flow.js';
@ -298,7 +299,7 @@ const TASK_SUFFIXES = ['PLAN', 'SUMMARY', 'CONTINUE', 'CONTEXT', 'RESEARCH'];
*/
function importHierarchyArtifacts(gsdDir: string): number {
let count = 0;
const gsdPath = join(gsdDir, '.gsd');
const gsdPath = gsdRoot(gsdDir);
// Root-level artifacts: PROJECT.md, QUEUE.md
const rootFiles = ['PROJECT.md', 'QUEUE.md', 'SECRETS-MANIFEST.md'];
@ -487,7 +488,7 @@ export function migrateFromMarkdown(gsdDir: string): {
requirements: number;
artifacts: number;
} {
const dbPath = join(gsdDir, '.gsd', 'gsd.db');
const dbPath = join(gsdRoot(gsdDir), 'gsd.db');
// Open DB if not already open
if (!_getAdapter()) {

View file

@ -0,0 +1,123 @@
/**
* GSD External State Migration
*
* Migrates legacy in-project `.gsd/` directories to the external
* `~/.gsd/projects/<hash>/` state directory. After migration, a
* symlink replaces the original directory so all paths remain valid.
*/
import { existsSync, lstatSync, mkdirSync, readdirSync, renameSync, cpSync, rmSync, symlinkSync } from "node:fs";
import { join } from "node:path";
import { externalGsdRoot } from "./repo-identity.js";
export interface MigrationResult {
migrated: boolean;
error?: string;
}
/**
* Migrate a legacy in-project `.gsd/` directory to external storage.
*
* Algorithm:
* 1. If `<project>/.gsd` is a symlink or doesn't exist -> skip
* 2. If `<project>/.gsd` is a real directory:
* a. Compute external path from repoIdentity
* b. mkdir -p external dir
* c. Rename `.gsd` -> `.gsd.migrating` (atomic on same FS, acts as lock)
* d. Copy contents to external dir (skip `worktrees/` subdirectory)
* e. Create symlink `.gsd -> external path`
* f. Remove `.gsd.migrating`
* 3. On failure: rename `.gsd.migrating` back to `.gsd` (rollback)
*/
export function migrateToExternalState(basePath: string): MigrationResult {
const localGsd = join(basePath, ".gsd");
// Skip if doesn't exist
if (!existsSync(localGsd)) {
return { migrated: false };
}
// Skip if already a symlink
try {
const stat = lstatSync(localGsd);
if (stat.isSymbolicLink()) {
return { migrated: false };
}
if (!stat.isDirectory()) {
return { migrated: false, error: ".gsd exists but is not a directory or symlink" };
}
} catch (err) {
return { migrated: false, error: `Cannot stat .gsd: ${err instanceof Error ? err.message : String(err)}` };
}
const externalPath = externalGsdRoot(basePath);
const migratingPath = join(basePath, ".gsd.migrating");
try {
// mkdir -p the external dir
mkdirSync(externalPath, { recursive: true });
// Rename .gsd -> .gsd.migrating (atomic lock)
renameSync(localGsd, migratingPath);
// Copy contents to external dir, skipping worktrees/
const entries = readdirSync(migratingPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === "worktrees") continue; // worktrees stay local
const src = join(migratingPath, entry.name);
const dst = join(externalPath, entry.name);
try {
if (entry.isDirectory()) {
cpSync(src, dst, { recursive: true, force: true });
} else {
cpSync(src, dst, { force: true });
}
} catch {
// Non-fatal: continue with other files
}
}
// Create symlink .gsd -> external path
symlinkSync(externalPath, localGsd, "junction");
// Remove .gsd.migrating
rmSync(migratingPath, { recursive: true, force: true });
return { migrated: true };
} catch (err) {
// Rollback: rename .gsd.migrating back to .gsd
try {
if (existsSync(migratingPath) && !existsSync(localGsd)) {
renameSync(migratingPath, localGsd);
}
} catch {
// Rollback failed -- leave .gsd.migrating for doctor to detect
}
return {
migrated: false,
error: `Migration failed: ${err instanceof Error ? err.message : String(err)}`,
};
}
}
/**
* Recover from a failed migration (`.gsd.migrating` exists).
* Moves `.gsd.migrating` back to `.gsd` if `.gsd` doesn't exist.
*/
export function recoverFailedMigration(basePath: string): boolean {
const localGsd = join(basePath, ".gsd");
const migratingPath = join(basePath, ".gsd.migrating");
if (!existsSync(migratingPath)) return false;
if (existsSync(localGsd)) return false; // both exist -- ambiguous, don't touch
try {
renameSync(migratingPath, localGsd);
return true;
} catch {
return false;
}
}

View file

@ -12,6 +12,7 @@
import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent";
import { existsSync, readFileSync } from "node:fs";
import { resolve, join, dirname } from "node:path";
import { gsdRoot } from "../paths.js";
import { fileURLToPath } from "node:url";
import { showNextAction } from "../../shared/mod.js";
import {
@ -144,7 +145,7 @@ export async function handleMigrate(
);
}
const targetGsdExists = existsSync(join(process.cwd(), ".gsd"));
const targetGsdExists = existsSync(gsdRoot(process.cwd()));
if (targetGsdExists) {
lines.push("");
lines.push("⚠ A .gsd directory already exists in the current working directory — it will be overwritten.");
@ -179,7 +180,7 @@ export async function handleMigrate(
ctx.ui.notify("Writing .gsd directory…", "info");
const result = await writeGSDDirectory(project, process.cwd());
const gsdPath = join(process.cwd(), ".gsd");
const gsdPath = gsdRoot(process.cwd());
ctx.ui.notify(
`✓ Migration complete — ${result.paths.length} file(s) written to .gsd/`,

View file

@ -5,6 +5,7 @@
import { join } from 'node:path';
import { saveFile } from '../files.js';
import { gsdRoot } from '../paths.js';
import type {
GSDMilestone,
@ -421,7 +422,7 @@ export async function writeGSDDirectory(
project: GSDProject,
targetPath: string,
): Promise<WrittenFiles> {
const gsdDir = join(targetPath, '.gsd');
const gsdDir = gsdRoot(targetPath);
const milestonesBase = join(gsdDir, 'milestones');
const paths: string[] = [];
const counts: WrittenFiles['counts'] = {

View file

@ -9,7 +9,7 @@
* via prefix matching, so existing projects work without migration.
*/
import { readdirSync, existsSync, Dirent } from "node:fs";
import { readdirSync, existsSync, realpathSync, Dirent } from "node:fs";
import { join } from "node:path";
import { nativeScanGsdTree, type GsdTreeEntry } from "./native-parser-bridge.js";
import { DIR_CACHE_MAX } from "./constants.js";
@ -278,7 +278,12 @@ const LEGACY_GSD_ROOT_FILES: Record<GSDRootFileKey, string> = {
};
export function gsdRoot(basePath: string): string {
return join(basePath, ".gsd");
const local = join(basePath, ".gsd");
try {
const resolved = realpathSync(local);
if (resolved !== local) return resolved; // symlink resolved
} catch { /* doesn't exist yet — fall through */ }
return local; // backwards compat: unmigrated projects
}
export function milestonesDir(basePath: string): string {

View file

@ -14,6 +14,7 @@ import type {
import { resolvePostUnitHooks, resolvePreDispatchHooks } from "./preferences.js";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { gsdRoot } from "./paths.js";
// ─── Hook Queue State ──────────────────────────────────────────────────────
@ -210,13 +211,13 @@ export function resolveHookArtifactPath(basePath: string, unitId: string, artifa
const parts = unitId.split("/");
if (parts.length === 3) {
const [mid, sid, tid] = parts;
return join(basePath, ".gsd", mid, "slices", sid, "tasks", `${tid}-${artifactName}`);
return join(gsdRoot(basePath), mid, "slices", sid, "tasks", `${tid}-${artifactName}`);
}
if (parts.length === 2) {
const [mid, sid] = parts;
return join(basePath, ".gsd", mid, "slices", sid, artifactName);
return join(gsdRoot(basePath), mid, "slices", sid, artifactName);
}
return join(basePath, ".gsd", parts[0], artifactName);
return join(gsdRoot(basePath), parts[0], artifactName);
}
// ═══════════════════════════════════════════════════════════════════════════
@ -310,7 +311,7 @@ export function runPreDispatchHooks(
const HOOK_STATE_FILE = "hook-state.json";
function hookStatePath(basePath: string): string {
return join(basePath, ".gsd", HOOK_STATE_FILE);
return join(gsdRoot(basePath), HOOK_STATE_FILE);
}
/**
@ -323,7 +324,7 @@ export function persistHookState(basePath: string): void {
savedAt: new Date().toISOString(),
};
try {
const dir = join(basePath, ".gsd");
const dir = gsdRoot(basePath);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(hookStatePath(basePath), JSON.stringify(state, null, 2), "utf-8");
} catch {

View file

@ -13,6 +13,7 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { gsdRoot } from "./paths.js";
import { parse as parseYaml } from "yaml";
import type { PostUnitHookConfig, PreDispatchHookConfig } from "./types.js";
import type { DynamicRoutingConfig } from "./model-router.js";
@ -81,11 +82,15 @@ export {
const GLOBAL_PREFERENCES_PATH = join(homedir(), ".gsd", "preferences.md");
const LEGACY_GLOBAL_PREFERENCES_PATH = join(homedir(), ".pi", "agent", "gsd-preferences.md");
const PROJECT_PREFERENCES_PATH = join(process.cwd(), ".gsd", "preferences.md");
function projectPreferencesPath(): string {
return join(gsdRoot(process.cwd()), "preferences.md");
}
// Bootstrap in gitignore.ts historically created PREFERENCES.md (uppercase) by mistake.
// Check uppercase as a fallback so those files aren't silently ignored.
const GLOBAL_PREFERENCES_PATH_UPPERCASE = join(homedir(), ".gsd", "PREFERENCES.md");
const PROJECT_PREFERENCES_PATH_UPPERCASE = join(process.cwd(), ".gsd", "PREFERENCES.md");
function projectPreferencesPathUppercase(): string {
return join(gsdRoot(process.cwd()), "PREFERENCES.md");
}
export function getGlobalGSDPreferencesPath(): string {
return GLOBAL_PREFERENCES_PATH;
@ -96,7 +101,7 @@ export function getLegacyGlobalGSDPreferencesPath(): string {
}
export function getProjectGSDPreferencesPath(): string {
return PROJECT_PREFERENCES_PATH;
return projectPreferencesPath();
}
// ─── Loading ────────────────────────────────────────────────────────────────
@ -108,8 +113,8 @@ export function loadGlobalGSDPreferences(): LoadedGSDPreferences | null {
}
export function loadProjectGSDPreferences(): LoadedGSDPreferences | null {
return loadPreferencesFile(PROJECT_PREFERENCES_PATH, "project")
?? loadPreferencesFile(PROJECT_PREFERENCES_PATH_UPPERCASE, "project");
return loadPreferencesFile(projectPreferencesPath(), "project")
?? loadPreferencesFile(projectPreferencesPathUppercase(), "project");
}
export function loadEffectiveGSDPreferences(): LoadedGSDPreferences | null {

View file

@ -0,0 +1,148 @@
/**
* GSD Repo Identity external state directory primitives.
*
* Computes a stable per-repo identity hash, resolves the external
* `~/.gsd/projects/<hash>/` state directory, and manages the
* `<project>/.gsd → external` symlink.
*/
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, symlinkSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
// ─── Repo Identity ──────────────────────────────────────────────────────────
/**
* Get the git remote URL for "origin", or "" if no remote is configured.
* Uses `git config` rather than `git remote get-url` for broader compat.
*/
function getRemoteUrl(basePath: string): string {
try {
return execFileSync("git", ["config", "--get", "remote.origin.url"], {
cwd: basePath,
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 5_000,
}).trim();
} catch {
return "";
}
}
/**
* Resolve the git toplevel (real root) for the given path.
* For worktrees this returns the main repo root, not the worktree path.
*/
function resolveGitRoot(basePath: string): string {
try {
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd: basePath,
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 5_000,
}).trim();
} catch {
return resolve(basePath);
}
}
/**
* Compute a stable identity for a repository.
*
* SHA-256 of `${remoteUrl}\n${resolvedRoot}`, truncated to 12 hex chars.
* Deterministic: same repo always produces the same hash regardless of
* which worktree the caller is inside.
*/
export function repoIdentity(basePath: string): string {
const remoteUrl = getRemoteUrl(basePath);
const root = resolveGitRoot(basePath);
const input = `${remoteUrl}\n${root}`;
return createHash("sha256").update(input).digest("hex").slice(0, 12);
}
// ─── External State Directory ───────────────────────────────────────────────
/**
* Compute the external GSD state directory for a repository.
*
* Returns `$GSD_STATE_DIR/projects/<hash>` if `GSD_STATE_DIR` is set,
* otherwise `~/.gsd/projects/<hash>`.
*/
export function externalGsdRoot(basePath: string): string {
const base = process.env.GSD_STATE_DIR || join(homedir(), ".gsd");
return join(base, "projects", repoIdentity(basePath));
}
// ─── Symlink Management ─────────────────────────────────────────────────────
/**
* Ensure the `<project>/.gsd` symlink points to the external state directory.
*
* 1. mkdir -p the external dir
* 2. If `<project>/.gsd` doesn't exist create symlink
* 3. If `<project>/.gsd` is already the correct symlink no-op
* 4. If `<project>/.gsd` is a real directory return as-is (migration handles later)
*
* Returns the resolved external path.
*/
export function ensureGsdSymlink(projectPath: string): string {
const externalPath = externalGsdRoot(projectPath);
const localGsd = join(projectPath, ".gsd");
// Ensure external directory exists
mkdirSync(externalPath, { recursive: true });
if (!existsSync(localGsd)) {
// Nothing exists yet — create symlink
symlinkSync(externalPath, localGsd, "junction");
return externalPath;
}
try {
const stat = lstatSync(localGsd);
if (stat.isSymbolicLink()) {
// Already a symlink — verify it points to the right place
const target = realpathSync(localGsd);
if (target === externalPath) {
return externalPath; // correct symlink, no-op
}
// Symlink exists but points elsewhere — leave it for now
// (could be a custom override or stale symlink)
return target;
}
if (stat.isDirectory()) {
// Real directory — migration will handle this later.
// Return the local path so existing code still works.
return localGsd;
}
} catch {
// lstat failed — path exists but we can't stat it
}
return localGsd;
}
// ─── Worktree Detection ─────────────────────────────────────────────────────
/**
* Check if the given directory is a git worktree (not the main repo).
*
* Git worktrees have a `.git` *file* (not directory) containing a
* `gitdir:` pointer. This is git's native worktree indicator no
* string marker parsing needed.
*/
export function isInsideWorktree(cwd: string): boolean {
const gitPath = join(cwd, ".git");
try {
const stat = lstatSync(gitPath);
if (!stat.isFile()) return false;
const content = readFileSync(gitPath, "utf-8").trim();
return content.startsWith("gitdir:");
} catch {
return false;
}
}

View file

@ -0,0 +1,99 @@
/**
* Resource version tracking and stale worktree detection.
*
* Staleness detection for managed GSD resources and utilities
* for escaping stale worktree cwd after milestone teardown.
*/
import { existsSync, readdirSync, unlinkSync } from "node:fs";
import { loadJsonFileOrNull } from "./json-persistence.js";
import { join } from "node:path";
import { homedir } from "node:os";
import { resolveProjectRoot } from "./worktree.js";
// ─── Resource Staleness ───────────────────────────────────────────────────
/**
* Read the resource version (semver) from the managed-resources manifest.
* Uses gsdVersion instead of syncedAt so that launching a second session
* doesn't falsely trigger staleness (#804).
*/
function isManifestWithVersion(data: unknown): data is { gsdVersion: string } {
return data !== null && typeof data === "object" && "gsdVersion" in data! && typeof (data as Record<string, unknown>).gsdVersion === "string";
}
export function readResourceVersion(): string | null {
const agentDir = process.env.GSD_CODING_AGENT_DIR || join(homedir(), ".gsd", "agent");
const manifestPath = join(agentDir, "managed-resources.json");
const manifest = loadJsonFileOrNull(manifestPath, isManifestWithVersion);
return manifest?.gsdVersion ?? 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 "GSD resources were updated since this session started. Restart gsd 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 `.gsd/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 {
const projectRoot = resolveProjectRoot(base);
if (projectRoot === base) return base;
try {
process.chdir(projectRoot);
} catch {
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(
gsdRootPath: string,
hasMilestoneSummary: (mid: string) => boolean,
): number {
const runtimeUnitsDir = join(gsdRootPath, "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 { /* non-fatal */ }
}
}
} catch { /* non-fatal */ }
return cleaned;
}

View file

@ -20,6 +20,7 @@
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
import { basename, join } from "node:path";
import { gsdRoot } from "./paths.js";
import { truncateWithEllipsis } from "../shared/format-utils.js";
import { nativeParseJsonlTail } from "./native-parser-bridge.js";
import { MAX_JSONL_BYTES, parseJSONL } from "./jsonl-utils.js";
@ -292,7 +293,7 @@ export function getDeepDiagnostic(basePath: string): string | null {
if (mid) {
const wtPath = getAutoWorktreePath(basePath, mid);
if (wtPath) {
const wtActivityDir = join(wtPath, ".gsd", "activity");
const wtActivityDir = join(gsdRoot(wtPath), "activity");
trace = readLastActivityLog(wtActivityDir);
}
}
@ -300,7 +301,7 @@ export function getDeepDiagnostic(basePath: string): string | null {
// Fall back to root activity logs
if (!trace || trace.toolCallCount === 0) {
const activityDir = join(basePath, ".gsd", "activity");
const activityDir = join(gsdRoot(basePath), "activity");
trace = readLastActivityLog(activityDir);
}
@ -314,7 +315,7 @@ export function getDeepDiagnostic(basePath: string): string | null {
*/
function readActiveMilestoneId(basePath: string): string | null {
try {
const statePath = join(basePath, ".gsd", "STATE.md");
const statePath = join(gsdRoot(basePath), "STATE.md");
if (!existsSync(statePath)) return null;
const content = readFileSync(statePath, "utf-8");
const match = /\*\*Active Milestone:\*\*\s*(\S+)/i.exec(content);

View file

@ -6,7 +6,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, mkdirSync, readdirSync, rmSync, utimesSync, writeFileSync, readFileSync } from "node:fs";
import { existsSync, mkdtempSync, mkdirSync, readdirSync, realpathSync, rmSync, utimesSync, writeFileSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
@ -18,7 +18,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
// ── Helpers ──────────────────────────────────────────────────────────────────
function createTmpDir(): string {
return mkdtempSync(join(tmpdir(), "gsd-activity-test-"));
return realpathSync(mkdtempSync(join(tmpdir(), "gsd-activity-test-")));
}
function writeActivityFile(dir: string, seq: string, name: string): string {

View file

@ -308,8 +308,8 @@ test("loadPersistedKeys unions keys from project root and worktree", () => {
});
test("completed-units.json set-union merge produces correct result", () => {
// Verify that a manual set-union merge (as done in syncStateToProjectRoot)
// correctly merges two JSON arrays of keys.
// Verify that a manual set-union merge correctly merges two JSON arrays
// of completed-unit keys.
const projectRoot = makeTmpBase();
const worktree = makeTmpBase();
try {
@ -320,7 +320,7 @@ test("completed-units.json set-union merge produces correct result", () => {
writeFileSync(prKeysFile, JSON.stringify(["a", "b"]));
writeFileSync(wtKeysFile, JSON.stringify(["b", "c", "d"]));
// Perform the same merge logic used in syncStateToProjectRoot
// Perform a set-union merge of two JSON key arrays
const srcKeys: string[] = JSON.parse(readFileSync(wtKeysFile, "utf8"));
let dstKeys: string[] = [];
if (existsSync(prKeysFile)) {

View file

@ -153,64 +153,6 @@ async function main(): Promise<void> {
// After teardown, originalBase should be null
assertEq(getAutoWorktreeOriginalBase(), null, "no split-brain: originalBase cleared");
// ─── #778: reconcile plan checkboxes on re-attach ─────────────────
console.log("\n=== #778: reconcile plan checkboxes on re-attach ===");
{
// Simulate: T01 [x] was committed to milestone branch, T02 [x] was
// written to project root by syncStateToProjectRoot() but the
// auto-commit crashed before it fired. On restart the worktree is
// re-created from the milestone branch HEAD (T02 still [ ]).
// reconcilePlanCheckboxes should forward-apply T02 [x] from the root.
const planRelPath = join(".gsd", "milestones", "M004", "slices", "S01", "S01-PLAN.md");
const planDir = join(tempDir, ".gsd", "milestones", "M004", "slices", "S01");
const { mkdirSync: mkdir, writeFileSync: write, readFileSync: read } = await import("node:fs");
// Plan on integration branch (project root): T01 [x], T02 [x]
mkdir(planDir, { recursive: true });
write(
join(tempDir, planRelPath),
"# S01 Plan\n- [x] **T01:** task one\n- [x] **T02:** task two\n- [ ] **T03:** task three\n",
);
// Write integration-branch plan to git so milestone branch starts from it
run(`git add .`, tempDir);
run(`git commit -m "add plan with T01 and T02 checked" --allow-empty`, tempDir);
// Create milestone branch with only T01 [x] (simulating crash before T02 commit)
const milestoneBranch = "milestone/M004";
run(`git checkout -b ${milestoneBranch}`, tempDir);
mkdir(planDir, { recursive: true });
write(
join(tempDir, planRelPath),
"# S01 Plan\n- [x] **T01:** task one\n- [ ] **T02:** task two\n- [ ] **T03:** task three\n",
);
run(`git add .`, tempDir);
run(`git commit -m "milestone: only T01 checked"`, tempDir);
run(`git checkout main`, tempDir);
// Restore project root plan (T01+T02 [x]) — simulates syncStateToProjectRoot
write(
join(tempDir, planRelPath),
"# S01 Plan\n- [x] **T01:** task one\n- [x] **T02:** task two\n- [ ] **T03:** task three\n",
);
// Create worktree re-attached to existing milestone branch (T02 still [ ] in branch)
const wtPath = createAutoWorktree(tempDir, "M004");
try {
const wtPlanPath = join(wtPath, planRelPath);
assertTrue(existsSync(wtPlanPath), "plan file exists in worktree after re-attach");
const wtPlan = read(wtPlanPath, "utf-8");
assertTrue(wtPlan.includes("- [x] **T02:"), "T02 should be [x] after reconciliation (was [ ] on branch)");
assertTrue(wtPlan.includes("- [x] **T01:"), "T01 stays [x]");
assertTrue(wtPlan.includes("- [ ] **T03:"), "T03 stays [ ] (not in root either)");
} finally {
teardownAutoWorktree(tempDir, "M004");
}
}
} finally {
// Always restore cwd and clean up
process.chdir(savedCwd);

View file

@ -231,15 +231,14 @@ None
const detect = await runGSDDoctor(dir);
const gitignoreIssues = detect.issues.filter(i => i.code === "gitignore_missing_patterns");
assertTrue(gitignoreIssues.length > 0, "detects missing gitignore patterns");
assertTrue(gitignoreIssues[0]?.message.includes(".gsd/activity/"), "message lists missing patterns");
assertTrue(gitignoreIssues[0]?.message.includes(".gsd"), "message lists missing .gsd pattern");
const fixed = await runGSDDoctor(dir, { fix: true });
assertTrue(fixed.fixesApplied.some(f => f.includes("added missing GSD runtime patterns")), "fix adds patterns");
// Verify patterns were added
// Verify .gsd entry was added (external state symlink)
const content = readFileSync(join(dir, ".gitignore"), "utf-8");
assertTrue(content.includes(".gsd/activity/"), "gitignore now has activity pattern");
assertTrue(content.includes(".gsd/auto.lock"), "gitignore now has auto.lock pattern");
assertTrue(content.includes(".gsd"), "gitignore now has .gsd entry");
}
} else {
console.log("\n=== gitignore_missing_patterns (skipped on Windows) ===");

View file

@ -311,7 +311,7 @@ async function main(): Promise<void> {
// Test 2: Uncommitted .gsd/ planning files are available in worktree
//
// When auto-mode starts, .gsd/ files may be untracked/uncommitted.
// copyPlanningArtifacts should carry them into the worktree even if
// Planning artifacts should be carried into the worktree even if
// they weren't committed on the feature branch.
// ================================================================
console.log("\n=== Untracked planning files copied to worktree ===");
@ -341,23 +341,10 @@ async function main(): Promise<void> {
const wtPath = createAutoWorktree(repo, milestoneId);
tempDirs.push(wtPath);
// Planning files should exist in the worktree (via copyPlanningArtifacts)
assertTrue(
existsSync(join(wtPath, ".gsd", "milestones", milestoneId, `${milestoneId}-ROADMAP.md`)),
"ROADMAP.md copied to worktree",
);
assertTrue(
existsSync(join(wtPath, ".gsd", "milestones", milestoneId, "slices", "S01", "S01-PLAN.md")),
"S01-PLAN.md copied to worktree",
);
assertTrue(
existsSync(join(wtPath, ".gsd", "PROJECT.md")),
"PROJECT.md copied to worktree",
);
assertTrue(
existsSync(join(wtPath, ".gsd", "DECISIONS.md")),
"DECISIONS.md copied to worktree",
);
// With external state, worktree .gsd is a symlink to shared state.
// Verify symlink was created (planning files are shared, not copied).
const wtGsd = join(wtPath, ".gsd");
assertTrue(existsSync(wtGsd), "worktree .gsd exists (symlink or dir)");
// Clean up: chdir back before teardown
process.chdir(savedCwd);

View file

@ -1167,53 +1167,26 @@ async function main(): Promise<void> {
rmSync(repo, { recursive: true, force: true });
}
// ─── ensureGitignore: commit_docs false adds blanket .gsd/ ──────────
// ─── ensureGitignore: always adds .gsd to gitignore ──────────────────
console.log("\n=== ensureGitignore: commit_docs false ===");
console.log("\n=== ensureGitignore: adds .gsd entry ===");
{
const { ensureGitignore } = await import("../gitignore.ts");
const repo = mkdtempSync(join(tmpdir(), "gsd-gitignore-commit-docs-"));
const repo = mkdtempSync(join(tmpdir(), "gsd-gitignore-external-state-"));
// When commit_docs is false, should add blanket .gsd/ to gitignore
const modified = ensureGitignore(repo, { commitDocs: false });
assertTrue(modified, "commit_docs=false: gitignore was modified");
// Should add .gsd to gitignore (external state dir is a symlink)
const modified = ensureGitignore(repo);
assertTrue(modified, "ensureGitignore: gitignore was modified");
const { readFileSync } = await import("node:fs");
const content = readFileSync(join(repo, ".gitignore"), "utf-8");
assertTrue(content.includes(".gsd/"), "commit_docs=false: .gitignore contains blanket .gsd/");
assertTrue(content.includes("commit_docs: false"), "commit_docs=false: .gitignore contains explanatory comment");
// Should NOT contain individual runtime patterns (those are subsumed by blanket .gsd/)
// But it's OK if it does — the blanket .gsd/ covers everything
const lines = content.split("\n").map(l => l.trim()).filter(l => l && !l.startsWith("#"));
assertTrue(lines.includes(".gsd"), "ensureGitignore: .gitignore contains .gsd");
// Idempotent — calling again doesn't add duplicates
const modified2 = ensureGitignore(repo, { commitDocs: false });
assertTrue(!modified2, "commit_docs=false: second call is idempotent");
rmSync(repo, { recursive: true, force: true });
}
// ─── ensureGitignore: commit_docs true removes blanket .gsd/ ────────
console.log("\n=== ensureGitignore: commit_docs true self-heals ===");
{
const { ensureGitignore } = await import("../gitignore.ts");
const repo = mkdtempSync(join(tmpdir(), "gsd-gitignore-selfheal-"));
// Start with a gitignore that has a blanket .gsd/ (e.g., user switched setting)
writeFileSync(join(repo, ".gitignore"), ".gsd/\n");
const modified = ensureGitignore(repo, { commitDocs: true });
assertTrue(modified, "commit_docs=true: gitignore was modified");
const { readFileSync } = await import("node:fs");
const content = readFileSync(join(repo, ".gitignore"), "utf-8");
// Blanket .gsd/ should be removed
const lines = content.split("\n").map(l => l.trim()).filter(l => l && !l.startsWith("#"));
assertTrue(!lines.includes(".gsd/"), "commit_docs=true: blanket .gsd/ was removed");
assertTrue(!lines.includes(".gsd"), "commit_docs=true: blanket .gsd was removed");
const modified2 = ensureGitignore(repo);
assertTrue(!modified2, "ensureGitignore: second call is idempotent");
rmSync(repo, { recursive: true, force: true });
}

View file

@ -10,7 +10,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, realpathSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { GSD_ROOT_FILES, resolveGsdRootFile } from '../paths.ts';
@ -27,7 +27,7 @@ test('knowledge: KNOWLEDGE key exists in GSD_ROOT_FILES', () => {
// ─── resolveGsdRootFile resolves KNOWLEDGE.md ───────────────────────────────
test('knowledge: resolveGsdRootFile returns canonical path when KNOWLEDGE.md exists', () => {
const tmp = mkdtempSync(join(tmpdir(), 'gsd-knowledge-'));
const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'gsd-knowledge-')));
const gsdDir = join(tmp, '.gsd');
mkdirSync(gsdDir, { recursive: true });
writeFileSync(join(gsdDir, 'KNOWLEDGE.md'), '# Project Knowledge\n');
@ -39,7 +39,7 @@ test('knowledge: resolveGsdRootFile returns canonical path when KNOWLEDGE.md exi
});
test('knowledge: resolveGsdRootFile resolves when legacy knowledge.md exists', () => {
const tmp = mkdtempSync(join(tmpdir(), 'gsd-knowledge-'));
const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'gsd-knowledge-')));
const gsdDir = join(tmp, '.gsd');
mkdirSync(gsdDir, { recursive: true });
writeFileSync(join(gsdDir, 'knowledge.md'), '# Project Knowledge\n');
@ -58,7 +58,7 @@ test('knowledge: resolveGsdRootFile resolves when legacy knowledge.md exists', (
});
test('knowledge: resolveGsdRootFile returns canonical path when file does not exist', () => {
const tmp = mkdtempSync(join(tmpdir(), 'gsd-knowledge-'));
const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'gsd-knowledge-')));
const gsdDir = join(tmp, '.gsd');
mkdirSync(gsdDir, { recursive: true });

View file

@ -1,205 +0,0 @@
/**
* worktree-db-integration.test.ts
*
* Integration tests for the worktree DB copy and reconcile hooks.
* Uses real temp git repos and real SQLite databases.
*
* Test cases:
* 1. Copy: createAutoWorktree seeds .gsd/gsd.db into the worktree when main has one
* 2. Copy-skip: createAutoWorktree silently skips when main has no gsd.db
* 3. Reconcile: reconcileWorktreeDb merges worktree rows into main DB
* 4. Reconcile-skip: reconcileWorktreeDb is non-fatal when both paths are nonexistent
* 5. Failure path: reconcileWorktreeDb emits to stderr on open failure (observable)
*/
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import { createAutoWorktree } from "../auto-worktree.ts";
import { worktreePath } from "../worktree-manager.ts";
import {
copyWorktreeDb,
reconcileWorktreeDb,
openDatabase,
closeDatabase,
upsertDecision,
getActiveDecisions,
isDbAvailable,
} from "../gsd-db.ts";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, assertTrue, report } = createTestContext();
function run(command: string, cwd: string): string {
return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
}
function createTempRepo(): string {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "wt-db-int-test-")));
run("git init", dir);
run("git config user.email test@test.com", dir);
run("git config user.name Test", dir);
writeFileSync(join(dir, "README.md"), "# test\n");
run("git add .", dir);
run("git commit -m init", dir);
run("git branch -M main", dir);
return dir;
}
async function main(): Promise<void> {
const savedCwd = process.cwd();
const tempDirs: string[] = [];
function makeTempDir(): string {
const dir = realpathSync(mkdtempSync(join(tmpdir(), "wt-db-int-")));
tempDirs.push(dir);
return dir;
}
try {
// ─── Test 1: copy on worktree creation ───────────────────────────
console.log("\n=== Test 1: copy on worktree creation ===");
{
const tempDir = createTempRepo();
tempDirs.push(tempDir);
// Seed a gsd.db in the main repo
const gsdDir = join(tempDir, ".gsd");
mkdirSync(gsdDir, { recursive: true });
const mainDbPath = join(gsdDir, "gsd.db");
openDatabase(mainDbPath);
closeDatabase();
// Commit so createAutoWorktree can copy planning artifacts
run("git add .", tempDir);
run('git commit -m "add gsd dir"', tempDir);
// createAutoWorktree should copy the DB into the worktree
const wtPath = createAutoWorktree(tempDir, "M004");
const worktreeDbPath = join(worktreePath(tempDir, "M004"), ".gsd", "gsd.db");
assertTrue(
existsSync(worktreeDbPath),
"gsd.db exists in worktree .gsd after createAutoWorktree",
);
// Restore cwd for next test
process.chdir(savedCwd);
}
// ─── Test 2: copy skip when no source DB ─────────────────────────
console.log("\n=== Test 2: copy skip when no source DB ===");
{
const tempDir = createTempRepo();
tempDirs.push(tempDir);
// No gsd.db — just a bare repo
let threw = false;
let wtPath: string | null = null;
try {
wtPath = createAutoWorktree(tempDir, "M004");
} catch (err) {
threw = true;
console.error(" Unexpected throw:", err);
}
assertTrue(!threw, "createAutoWorktree does not throw when no source DB");
const worktreeDbPath = join(worktreePath(tempDir, "M004"), ".gsd", "gsd.db");
assertTrue(
!existsSync(worktreeDbPath),
"gsd.db is absent in worktree when source had none",
);
process.chdir(savedCwd);
}
// ─── Test 3: reconcile inserts worktree rows into main ───────────
console.log("\n=== Test 3: reconcile merges worktree rows into main ===");
{
const mainDbPath = join(makeTempDir(), "main.db");
const worktreeDbPath = join(makeTempDir(), "wt.db");
// Seed main DB (empty schema)
openDatabase(mainDbPath);
closeDatabase();
// Seed worktree DB with one decision
openDatabase(worktreeDbPath);
upsertDecision({
id: "D-WT-001",
when_context: "integration test",
scope: "test",
decision: "use reconcile",
choice: "reconcile on merge",
rationale: "test coverage",
revisable: "no",
superseded_by: null,
});
closeDatabase();
// Reconcile worktree → main
const result = reconcileWorktreeDb(mainDbPath, worktreeDbPath);
assertTrue(result.decisions >= 1, "reconcile reports at least 1 decision merged");
// Open main DB and verify the row is present
openDatabase(mainDbPath);
const decisions = getActiveDecisions();
closeDatabase();
const found = decisions.some((d) => d.id === "D-WT-001");
assertTrue(found, "worktree decision D-WT-001 present in main DB after reconcile");
}
// ─── Test 4: reconcile non-fatal when both paths nonexistent ─────
console.log("\n=== Test 4: reconcile non-fatal on nonexistent paths ===");
{
let threw = false;
try {
reconcileWorktreeDb("/nonexistent/path/gsd.db", "/also/nonexistent/gsd.db");
} catch {
threw = true;
}
assertTrue(!threw, "reconcileWorktreeDb does not throw when worktree DB is absent");
}
// ─── Test 5: failure path observable via stderr (diagnostic) ─────
// reconcileWorktreeDb emits to stderr on reconciliation failures.
// We can't easily intercept stderr in this test harness, but we verify
// that the function returns the zero-result shape (not undefined/throws)
// when the worktree DB is missing — confirming the failure path is non-fatal
// and returns a structured result.
console.log("\n=== Test 5: reconcile returns zero-shape when worktree DB absent ===");
{
const mainDbPath = join(makeTempDir(), "main2.db");
openDatabase(mainDbPath);
closeDatabase();
const result = reconcileWorktreeDb(mainDbPath, "/definitely/does/not/exist.db");
assertEq(result.decisions, 0, "decisions is 0 when worktree DB absent");
assertEq(result.requirements, 0, "requirements is 0 when worktree DB absent");
assertEq(result.artifacts, 0, "artifacts is 0 when worktree DB absent");
assertEq(result.conflicts.length, 0, "conflicts is empty when worktree DB absent");
}
} finally {
// Always restore cwd
process.chdir(savedCwd);
// Ensure DB is closed
if (isDbAvailable()) closeDatabase();
// Remove all temp dirs
for (const dir of tempDirs) {
if (existsSync(dir)) {
rmSync(dir, { recursive: true, force: true });
}
}
}
report();
}
main();

View file

@ -1,442 +0,0 @@
import { createTestContext } from './test-helpers.ts';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {
openDatabase,
closeDatabase,
isDbAvailable,
insertDecision,
insertRequirement,
insertArtifact,
getDecisionById,
getRequirementById,
_getAdapter,
copyWorktreeDb,
reconcileWorktreeDb,
} from '../gsd-db.ts';
const { assertEq, assertTrue, report } = createTestContext();
// ═══════════════════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════════════════
function tempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-wt-test-'));
}
function cleanup(...dirs: string[]): void {
closeDatabase();
for (const dir of dirs) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// best effort
}
}
}
function seedMainDb(dbPath: string): void {
openDatabase(dbPath);
insertDecision({
id: 'D001',
when_context: '2025-01-01',
scope: 'M001/S01',
decision: 'Use SQLite',
choice: 'node:sqlite',
rationale: 'Built-in',
revisable: 'yes',
superseded_by: null,
});
insertRequirement({
id: 'R001',
class: 'functional',
status: 'active',
description: 'Must store decisions',
why: 'Core feature',
source: 'design',
primary_owner: 'S01',
supporting_slices: '',
validation: 'test',
notes: '',
full_content: 'Full requirement text',
superseded_by: null,
});
insertArtifact({
path: 'docs/arch.md',
artifact_type: 'plan',
milestone_id: 'M001',
slice_id: null,
task_id: null,
full_content: 'Architecture document',
});
}
// ═══════════════════════════════════════════════════════════════════════════
// copyWorktreeDb tests
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== worktree-db: copyWorktreeDb ===');
// Test: copies DB file and data is queryable
{
const srcDir = tempDir();
const destDir = tempDir();
const srcDb = path.join(srcDir, 'gsd.db');
const destDb = path.join(destDir, 'nested', 'gsd.db');
seedMainDb(srcDb);
closeDatabase();
const result = copyWorktreeDb(srcDb, destDb);
assertTrue(result === true, 'copyWorktreeDb returns true on success');
assertTrue(fs.existsSync(destDb), 'dest DB file exists after copy');
// Open the copy and verify data is queryable
openDatabase(destDb);
const d = getDecisionById('D001');
assertTrue(d !== null, 'decision queryable in copied DB');
assertEq(d?.choice, 'node:sqlite', 'decision data preserved in copy');
const r = getRequirementById('R001');
assertTrue(r !== null, 'requirement queryable in copied DB');
assertEq(r?.description, 'Must store decisions', 'requirement data preserved in copy');
cleanup(srcDir, destDir);
}
// Test: skips -wal and -shm files
{
const srcDir = tempDir();
const destDir = tempDir();
const srcDb = path.join(srcDir, 'gsd.db');
const destDb = path.join(destDir, 'gsd.db');
seedMainDb(srcDb);
closeDatabase();
// Create fake WAL/SHM files
fs.writeFileSync(srcDb + '-wal', 'fake wal data');
fs.writeFileSync(srcDb + '-shm', 'fake shm data');
copyWorktreeDb(srcDb, destDb);
assertTrue(fs.existsSync(destDb), 'DB file copied');
assertTrue(!fs.existsSync(destDb + '-wal'), 'WAL file NOT copied');
assertTrue(!fs.existsSync(destDb + '-shm'), 'SHM file NOT copied');
cleanup(srcDir, destDir);
}
// Test: returns false when source doesn't exist (no throw)
{
const destDir = tempDir();
const result = copyWorktreeDb('/nonexistent/path/gsd.db', path.join(destDir, 'gsd.db'));
assertEq(result, false, 'returns false for missing source');
cleanup(destDir);
}
// Test: creates dest directory if needed
{
const srcDir = tempDir();
const destDir = tempDir();
const srcDb = path.join(srcDir, 'gsd.db');
const deepDest = path.join(destDir, 'a', 'b', 'c', 'gsd.db');
seedMainDb(srcDb);
closeDatabase();
const result = copyWorktreeDb(srcDb, deepDest);
assertTrue(result === true, 'copyWorktreeDb succeeds with nested dest');
assertTrue(fs.existsSync(deepDest), 'DB file created at deeply nested path');
cleanup(srcDir, destDir);
}
// ═══════════════════════════════════════════════════════════════════════════
// reconcileWorktreeDb tests
// ═══════════════════════════════════════════════════════════════════════════
console.log('\n=== worktree-db: reconcileWorktreeDb ===');
// Test: merges new decisions from worktree into main
{
const mainDir = tempDir();
const wtDir = tempDir();
const mainDb = path.join(mainDir, 'gsd.db');
const wtDb = path.join(wtDir, 'gsd.db');
// Seed main with D001
seedMainDb(mainDb);
closeDatabase();
// Copy to worktree, add D002 in worktree
copyWorktreeDb(mainDb, wtDb);
openDatabase(wtDb);
insertDecision({
id: 'D002',
when_context: '2025-02-01',
scope: 'M001/S02',
decision: 'Use WAL mode',
choice: 'WAL',
rationale: 'Performance',
revisable: 'yes',
superseded_by: null,
});
closeDatabase();
// Re-open main and reconcile
openDatabase(mainDb);
const result = reconcileWorktreeDb(mainDb, wtDb);
assertTrue(result.decisions > 0, 'decisions merged count > 0');
const d2 = getDecisionById('D002');
assertTrue(d2 !== null, 'D002 from worktree now in main');
assertEq(d2?.choice, 'WAL', 'D002 data correct after merge');
cleanup(mainDir, wtDir);
}
// Test: merges new requirements from worktree into main
{
const mainDir = tempDir();
const wtDir = tempDir();
const mainDb = path.join(mainDir, 'gsd.db');
const wtDb = path.join(wtDir, 'gsd.db');
seedMainDb(mainDb);
closeDatabase();
copyWorktreeDb(mainDb, wtDb);
openDatabase(wtDb);
insertRequirement({
id: 'R002',
class: 'non-functional',
status: 'active',
description: 'Must be fast',
why: 'UX',
source: 'design',
primary_owner: 'S02',
supporting_slices: '',
validation: 'benchmark',
notes: '',
full_content: 'Performance requirement',
superseded_by: null,
});
closeDatabase();
openDatabase(mainDb);
const result = reconcileWorktreeDb(mainDb, wtDb);
assertTrue(result.requirements > 0, 'requirements merged count > 0');
const r2 = getRequirementById('R002');
assertTrue(r2 !== null, 'R002 from worktree now in main');
assertEq(r2?.description, 'Must be fast', 'R002 data correct after merge');
cleanup(mainDir, wtDir);
}
// Test: merges new artifacts from worktree into main
{
const mainDir = tempDir();
const wtDir = tempDir();
const mainDb = path.join(mainDir, 'gsd.db');
const wtDb = path.join(wtDir, 'gsd.db');
seedMainDb(mainDb);
closeDatabase();
copyWorktreeDb(mainDb, wtDb);
openDatabase(wtDb);
insertArtifact({
path: 'docs/api.md',
artifact_type: 'reference',
milestone_id: 'M001',
slice_id: 'S01',
task_id: 'T01',
full_content: 'API documentation',
});
closeDatabase();
openDatabase(mainDb);
const result = reconcileWorktreeDb(mainDb, wtDb);
assertTrue(result.artifacts > 0, 'artifacts merged count > 0');
const adapter = _getAdapter()!;
const row = adapter.prepare('SELECT * FROM artifacts WHERE path = ?').get('docs/api.md');
assertTrue(row !== null, 'artifact from worktree now in main');
assertEq(row?.['artifact_type'], 'reference', 'artifact data correct after merge');
cleanup(mainDir, wtDir);
}
// Test: detects conflicts (same PK, different content in both DBs)
{
const mainDir = tempDir();
const wtDir = tempDir();
const mainDb = path.join(mainDir, 'gsd.db');
const wtDb = path.join(wtDir, 'gsd.db');
// Seed main with D001
seedMainDb(mainDb);
closeDatabase();
copyWorktreeDb(mainDb, wtDb);
// Modify D001 in main
openDatabase(mainDb);
const mainAdapter = _getAdapter()!;
mainAdapter.prepare(
`UPDATE decisions SET choice = 'better-sqlite3' WHERE id = 'D001'`,
).run();
closeDatabase();
// Modify D001 in worktree differently
openDatabase(wtDb);
const wtAdapter = _getAdapter()!;
wtAdapter.prepare(
`UPDATE decisions SET choice = 'sql.js' WHERE id = 'D001'`,
).run();
closeDatabase();
// Reconcile
openDatabase(mainDb);
const result = reconcileWorktreeDb(mainDb, wtDb);
assertTrue(result.conflicts.length > 0, 'conflicts detected');
assertTrue(
result.conflicts.some(c => c.includes('D001')),
'conflict mentions D001',
);
// Worktree-wins: D001 should now have worktree's value
const d1 = getDecisionById('D001');
assertEq(d1?.choice, 'sql.js', 'worktree wins on conflict (INSERT OR REPLACE)');
cleanup(mainDir, wtDir);
}
// Test: handles missing worktree DB gracefully
{
const mainDir = tempDir();
const mainDb = path.join(mainDir, 'gsd.db');
seedMainDb(mainDb);
const result = reconcileWorktreeDb(mainDb, '/nonexistent/worktree.db');
assertEq(result.decisions, 0, 'no decisions merged for missing worktree DB');
assertEq(result.requirements, 0, 'no requirements merged for missing worktree DB');
assertEq(result.artifacts, 0, 'no artifacts merged for missing worktree DB');
assertEq(result.conflicts.length, 0, 'no conflicts for missing worktree DB');
cleanup(mainDir);
}
// Test: path with spaces works
{
const baseDir = tempDir();
const mainDir = path.join(baseDir, 'main dir');
const wtDir = path.join(baseDir, 'worktree dir');
fs.mkdirSync(mainDir, { recursive: true });
fs.mkdirSync(wtDir, { recursive: true });
const mainDb = path.join(mainDir, 'gsd.db');
const wtDb = path.join(wtDir, 'gsd.db');
seedMainDb(mainDb);
closeDatabase();
copyWorktreeDb(mainDb, wtDb);
// Add a decision in worktree
openDatabase(wtDb);
insertDecision({
id: 'D003',
when_context: '2025-03-01',
scope: 'M001/S03',
decision: 'Path spaces test',
choice: 'yes',
rationale: 'Robustness',
revisable: 'no',
superseded_by: null,
});
closeDatabase();
openDatabase(mainDb);
const result = reconcileWorktreeDb(mainDb, wtDb);
assertTrue(result.decisions > 0, 'reconciliation works with spaces in path');
const d3 = getDecisionById('D003');
assertTrue(d3 !== null, 'D003 merged from worktree with spaces in path');
cleanup(baseDir);
}
// Test: main DB is usable after reconciliation (DETACH cleanup verified)
{
const mainDir = tempDir();
const wtDir = tempDir();
const mainDb = path.join(mainDir, 'gsd.db');
const wtDb = path.join(wtDir, 'gsd.db');
seedMainDb(mainDb);
closeDatabase();
copyWorktreeDb(mainDb, wtDb);
openDatabase(mainDb);
reconcileWorktreeDb(mainDb, wtDb);
// Verify main DB is still fully usable after DETACH
assertTrue(isDbAvailable(), 'DB still available after reconciliation');
insertDecision({
id: 'D099',
when_context: '2025-12-01',
scope: 'test',
decision: 'Post-reconcile insert',
choice: 'works',
rationale: 'Verify DETACH cleanup',
revisable: 'no',
superseded_by: null,
});
const d99 = getDecisionById('D099');
assertTrue(d99 !== null, 'can insert and query after reconciliation');
assertEq(d99?.choice, 'works', 'post-reconcile data correct');
// Verify no "wt" database still attached
const adapter = _getAdapter()!;
let wtAccessible = false;
try {
adapter.prepare('SELECT count(*) FROM wt.decisions').get();
wtAccessible = true;
} catch {
// Expected — wt should be detached
}
assertTrue(!wtAccessible, 'wt database is detached after reconciliation');
cleanup(mainDir, wtDir);
}
// Test: reconcile with empty worktree DB (no new rows, no conflicts)
{
const mainDir = tempDir();
const wtDir = tempDir();
const mainDb = path.join(mainDir, 'gsd.db');
const wtDb = path.join(wtDir, 'gsd.db');
seedMainDb(mainDb);
closeDatabase();
copyWorktreeDb(mainDb, wtDb);
// Don't modify the worktree DB at all — reconcile the identical copy
openDatabase(mainDb);
const result = reconcileWorktreeDb(mainDb, wtDb);
// Should still report counts for the existing rows (INSERT OR REPLACE touches them)
assertTrue(result.conflicts.length === 0, 'no conflicts when DBs are identical');
assertTrue(isDbAvailable(), 'DB usable after no-change reconciliation');
cleanup(mainDir, wtDir);
}
// ─── Final Report ──────────────────────────────────────────────────────────
report();

View file

@ -12,6 +12,7 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { gsdRoot } from "./paths.js";
import type { Classification, CaptureEntry } from "./captures.js";
import {
loadPendingCaptures,
@ -36,7 +37,7 @@ export function executeInject(
): string | null {
try {
// Resolve the plan file path
const planPath = join(basePath, ".gsd", "milestones", mid, "slices", sid, `${sid}-PLAN.md`);
const planPath = join(gsdRoot(basePath), "milestones", mid, "slices", sid, `${sid}-PLAN.md`);
if (!existsSync(planPath)) return null;
const content = readFileSync(planPath, "utf-8");

View file

@ -50,7 +50,7 @@ export function getWorktreeOriginalCwd(): string | null {
export function getActiveWorktreeName(): string | null {
if (!originalCwd) return null;
const cwd = process.cwd();
const wtDir = join(originalCwd, ".gsd", "worktrees");
const wtDir = join(gsdRoot(originalCwd), "worktrees");
if (!cwd.startsWith(wtDir)) return null;
const rel = cwd.slice(wtDir.length + 1);
const name = rel.split("/")[0] ?? rel.split("\\")[0];
@ -633,16 +633,6 @@ async function handleMerge(
const commitType = inferCommitType(name);
const commitMessage = `${commitType}(${name}): merge worktree ${name}`;
// Reconcile worktree DB into main DB before squash merge
const wtDbPath = join(worktreePath(basePath, name), ".gsd", "gsd.db");
const mainDbPath = join(basePath, ".gsd", "gsd.db");
if (existsSync(wtDbPath) && existsSync(mainDbPath)) {
try {
const { reconcileWorktreeDb } = await import("./gsd-db.js");
reconcileWorktreeDb(mainDbPath, wtDbPath);
} catch { /* non-fatal */ }
}
try {
mergeWorktreeToMain(basePath, name, commitMessage);
ctx.ui.notify(

View file

@ -17,6 +17,7 @@
import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
import { join, resolve, sep } from "node:path";
import { gsdRoot } from "./paths.js";
import { GSDError, GSD_PARSE_ERROR, GSD_STALE_STATE, GSD_LOCK_HELD, GSD_GIT_ERROR, GSD_MERGE_CONFLICT } from "./errors.js";
import {
nativeBranchDelete,
@ -100,7 +101,7 @@ export function resolveGitDir(basePath: string): string {
}
export function worktreesDir(basePath: string): string {
return join(basePath, ".gsd", "worktrees");
return join(gsdRoot(basePath), "worktrees");
}
export function worktreePath(basePath: string, name: string): string {
@ -193,7 +194,7 @@ export function listWorktrees(basePath: string): WorktreeInfo[] {
const seenRoots = new Set<string>();
const worktreeRoots = baseVariants
.map(baseVariant => {
const path = join(baseVariant, ".gsd", "worktrees");
const path = join(gsdRoot(baseVariant), "worktrees");
return {
normalized: normalizePathForComparison(path),
};

View file

@ -12,7 +12,7 @@
* SLICE_BRANCH_RE) remain for backwards compatibility with legacy branches.
*/
import { existsSync, readFileSync, utimesSync } from "node:fs";
import { existsSync, lstatSync, readFileSync, utimesSync } from "node:fs";
import { join, resolve, sep } from "node:path";
import { GitServiceImpl, writeIntegrationBranch, type TaskCommitContext } from "./git-service.js";
@ -72,6 +72,25 @@ export function captureIntegrationBranch(basePath: string, milestoneId: string,
* Returns null if not inside a GSD worktree (.gsd/worktrees/<name>/).
*/
export function detectWorktreeName(basePath: string): string | null {
// Primary: use git metadata — .git file with gitdir: pointer
const gitPath = join(basePath, ".git");
try {
const stat = lstatSync(gitPath);
if (stat.isFile()) {
const content = readFileSync(gitPath, "utf-8").trim();
if (content.startsWith("gitdir:")) {
const gitdir = content.slice(7).trim();
// Git worktree gitdir format: <repo>/.git/worktrees/<name>
const parts = gitdir.replace(/\\/g, "/").split("/");
const wtIdx = parts.lastIndexOf("worktrees");
if (wtIdx !== -1 && wtIdx < parts.length - 1) {
return parts[wtIdx + 1] || null;
}
}
}
} catch { /* fall through */ }
// Fallback: path-based detection for legacy setups
const normalizedPath = basePath.replaceAll("\\", "/");
const marker = "/.gsd/worktrees/";
const idx = normalizedPath.indexOf(marker);
@ -90,14 +109,32 @@ export function detectWorktreeName(basePath: string): string | null {
* operate against the real project root, not a worktree subdirectory.
*/
export function resolveProjectRoot(basePath: string): string {
// Primary: use git metadata to resolve the main worktree root
const gitPath = join(basePath, ".git");
try {
const stat = lstatSync(gitPath);
if (stat.isFile()) {
const content = readFileSync(gitPath, "utf-8").trim();
if (content.startsWith("gitdir:")) {
const gitdir = resolve(basePath, content.slice(7).trim());
// Git worktree gitdir: <repo>/.git/worktrees/<name>
// Walk up to <repo>
const parts = gitdir.replace(/\\/g, "/").split("/");
const wtIdx = parts.lastIndexOf("worktrees");
if (wtIdx >= 2 && parts[wtIdx - 1] === ".git") {
return parts.slice(0, wtIdx - 1).join("/");
}
}
}
} catch { /* fall through */ }
// Fallback: legacy path-based detection
const normalizedPath = basePath.replaceAll("\\", "/");
const marker = "/.gsd/worktrees/";
const idx = normalizedPath.indexOf(marker);
if (idx === -1) return basePath;
// Return the original path up to the .gsd/ marker (un-normalized)
// Account for potential OS-specific separators
const sep = basePath.includes("\\") ? "\\" : "/";
const markerOs = `${sep}.gsd${sep}worktrees${sep}`;
const osSep = basePath.includes("\\") ? "\\" : "/";
const markerOs = `${osSep}.gsd${osSep}worktrees${osSep}`;
const idxOs = basePath.indexOf(markerOs);
if (idxOs !== -1) return basePath.slice(0, idxOs);
return basePath.slice(0, idx);