From 3102831db922cf797269fd6e2d0952a15c24ae22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Wed, 18 Mar 2026 14:05:10 -0600 Subject: [PATCH] refactor: move .gsd/ to external state directory with symlink (ADR-002) (#1242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move mutable .gsd/ state from inside the project directory to ~/.gsd/projects//, 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) --- .../extensions/gsd/auto-post-unit.ts | 10 - src/resources/extensions/gsd/auto-recovery.ts | 3 +- src/resources/extensions/gsd/auto-start.ts | 50 +- .../extensions/gsd/auto-worktree-sync.ts | 199 -------- src/resources/extensions/gsd/auto-worktree.ts | 194 +------- src/resources/extensions/gsd/auto.ts | 11 +- src/resources/extensions/gsd/captures.ts | 14 +- .../extensions/gsd/commands-handlers.ts | 3 +- src/resources/extensions/gsd/commands.ts | 3 +- src/resources/extensions/gsd/detection.ts | 3 +- src/resources/extensions/gsd/doctor-checks.ts | 50 +- src/resources/extensions/gsd/doctor-types.ts | 4 +- src/resources/extensions/gsd/forensics.ts | 4 +- src/resources/extensions/gsd/git-service.ts | 5 +- src/resources/extensions/gsd/gitignore.ts | 72 +-- src/resources/extensions/gsd/gsd-db.ts | 166 +------ src/resources/extensions/gsd/guided-flow.ts | 10 +- src/resources/extensions/gsd/index.ts | 6 +- src/resources/extensions/gsd/md-importer.ts | 5 +- .../extensions/gsd/migrate-external.ts | 123 +++++ .../extensions/gsd/migrate/command.ts | 5 +- .../extensions/gsd/migrate/writer.ts | 3 +- src/resources/extensions/gsd/paths.ts | 9 +- .../extensions/gsd/post-unit-hooks.ts | 11 +- src/resources/extensions/gsd/preferences.ts | 15 +- src/resources/extensions/gsd/repo-identity.ts | 148 ++++++ .../extensions/gsd/resource-version.ts | 99 ++++ .../extensions/gsd/session-forensics.ts | 7 +- .../extensions/gsd/tests/activity-log.test.ts | 4 +- .../gsd/tests/auto-recovery.test.ts | 6 +- .../gsd/tests/auto-worktree.test.ts | 58 --- .../gsd/tests/doctor-runtime.test.ts | 7 +- ...ature-branch-lifecycle-integration.test.ts | 23 +- .../extensions/gsd/tests/git-service.test.ts | 47 +- .../extensions/gsd/tests/knowledge.test.ts | 8 +- .../gsd/tests/worktree-db-integration.test.ts | 205 -------- .../extensions/gsd/tests/worktree-db.test.ts | 442 ------------------ .../extensions/gsd/triage-resolution.ts | 3 +- .../extensions/gsd/worktree-command.ts | 12 +- .../extensions/gsd/worktree-manager.ts | 5 +- src/resources/extensions/gsd/worktree.ts | 47 +- 41 files changed, 599 insertions(+), 1500 deletions(-) delete mode 100644 src/resources/extensions/gsd/auto-worktree-sync.ts create mode 100644 src/resources/extensions/gsd/migrate-external.ts create mode 100644 src/resources/extensions/gsd/repo-identity.ts create mode 100644 src/resources/extensions/gsd/resource-version.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-db-integration.test.ts delete mode 100644 src/resources/extensions/gsd/tests/worktree-db.test.ts diff --git a/src/resources/extensions/gsd/auto-post-unit.ts b/src/resources/extensions/gsd/auto-post-unit.ts index 8c478bc17..2371d13b2 100644 --- a/src/resources/extensions/gsd/auto-post-unit.ts +++ b/src/resources/extensions/gsd/auto-post-unit.ts @@ -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 { diff --git a/src/resources/extensions/gsd/auto-recovery.ts b/src/resources/extensions/gsd/auto-recovery.ts index e7705e077..119589c31 100644 --- a/src/resources/extensions/gsd/auto-recovery.ts +++ b/src/resources/extensions/gsd/auto-recovery.ts @@ -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). */ diff --git a/src/resources/extensions/gsd/auto-start.ts b/src/resources/extensions/gsd/auto-start.ts index 1386ba7c1..62af28fef 100644 --- a/src/resources/extensions/gsd/auto-start.ts +++ b/src/resources/extensions/gsd/auto-start.ts @@ -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)) diff --git a/src/resources/extensions/gsd/auto-worktree-sync.ts b/src/resources/extensions/gsd/auto-worktree-sync.ts deleted file mode 100644 index d855438ef..000000000 --- a/src/resources/extensions/gsd/auto-worktree-sync.ts +++ /dev/null @@ -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).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//`. - * 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/ — 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; -} diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index 775d4ff66..b03341715 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -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: or - [x] **S: (case-insensitive x) - const checkedRe = /^- \[[xX]\] \*\*([TS]\d+):/gm; - const srcChecked = new Set(); - for (const m of srcContent.matchAll(checkedRe)) srcChecked.add(m[1]); - - if (srcChecked.size === 0) continue; - - // Forward-apply: replace [ ] → [x] for any IDs that are checked in src - let updated = dstContent; - let changed = false; - for (const id of srcChecked) { - const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const uncheckedRe = new RegExp(`^(- )\\[ \\]( \\*\\*${escapedId}:)`, "gm"); - if (uncheckedRe.test(updated)) { - updated = updated.replace( - new RegExp(`^(- )\\[ \\]( \\*\\*${escapedId}:)`, "gm"), - "$1[x]$2", - ); - changed = true; - } - } - - if (changed) { - try { - atomicWriteSync(dstFile, updated, "utf-8"); - } catch { /* 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); diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 92ed0af81..72a2cad8c 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -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({ diff --git a/src/resources/extensions/gsd/captures.ts b/src/resources/extensions/gsd/captures.ts index 2c18a987c..9837cb907 100644 --- a/src/resources/extensions/gsd/captures.ts +++ b/src/resources/extensions/gsd/captures.ts @@ -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 ───────────────────────────────────────────────────────────────── diff --git a/src/resources/extensions/gsd/commands-handlers.ts b/src/resources/extensions/gsd/commands-handlers.ts index addd2730a..bfe18a3e4 100644 --- a/src/resources/extensions/gsd/commands-handlers.ts +++ b/src/resources/extensions/gsd/commands-handlers.ts @@ -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 }); } diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 65fc6a135..5cb93cdc0 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -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 */ } diff --git a/src/resources/extensions/gsd/detection.ts b/src/resources/extensions/gsd/detection.ts index 5e3e1776b..813e47461 100644 --- a/src/resources/extensions/gsd/detection.ts +++ b/src/resources/extensions/gsd/detection.ts @@ -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; diff --git a/src/resources/extensions/gsd/doctor-checks.ts b/src/resources/extensions/gsd/doctor-checks.ts index fd58fa5ee..85d3efaab 100644 --- a/src/resources/extensions/gsd/doctor-checks.ts +++ b/src/resources/extensions/gsd/doctor-checks.ts @@ -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 + } } /** diff --git a/src/resources/extensions/gsd/doctor-types.ts b/src/resources/extensions/gsd/doctor-types.ts index ae580c553..6bf6c6954 100644 --- a/src/resources/extensions/gsd/doctor-types.ts +++ b/src/resources/extensions/gsd/doctor-types.ts @@ -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. diff --git a/src/resources/extensions/gsd/forensics.ts b/src/resources/extensions/gsd/forensics.ts index 2bf83ebff..2b76b2d5a 100644 --- a/src/resources/extensions/gsd/forensics.ts +++ b/src/resources/extensions/gsd/forensics.ts @@ -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")); diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index d3b3f0a09..71aa0e87d 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -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//-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 = {}; diff --git a/src/resources/extensions/gsd/gitignore.ts b/src/resources/extensions/gsd/gitignore.ts index 1784365ae..22beb2a2e 100644 --- a/src/resources/extensions/gsd/gitignore.ts +++ b/src/resources/extensions/gsd/gitignore.ts @@ -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; -} diff --git a/src/resources/extensions/gsd/gsd-db.ts b/src/resources/extensions/gsd/gsd-db.ts index 0b6222e33..be32bee0b 100644 --- a/src/resources/extensions/gsd/gsd-db.ts +++ b/src/resources/extensions/gsd/gsd-db.ts @@ -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. diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 39cb10965..04852c73f 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -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 { // 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 diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index f738d4f27..a4903f639 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -75,7 +75,7 @@ async function ensureDbAvailable(): Promise { 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"); diff --git a/src/resources/extensions/gsd/md-importer.ts b/src/resources/extensions/gsd/md-importer.ts index 195eb9922..29705a0c9 100644 --- a/src/resources/extensions/gsd/md-importer.ts +++ b/src/resources/extensions/gsd/md-importer.ts @@ -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()) { diff --git a/src/resources/extensions/gsd/migrate-external.ts b/src/resources/extensions/gsd/migrate-external.ts new file mode 100644 index 000000000..a7ae64bce --- /dev/null +++ b/src/resources/extensions/gsd/migrate-external.ts @@ -0,0 +1,123 @@ +/** + * GSD External State Migration + * + * Migrates legacy in-project `.gsd/` directories to the external + * `~/.gsd/projects//` 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 `/.gsd` is a symlink or doesn't exist -> skip + * 2. If `/.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; + } +} diff --git a/src/resources/extensions/gsd/migrate/command.ts b/src/resources/extensions/gsd/migrate/command.ts index a13739535..233ab61f3 100644 --- a/src/resources/extensions/gsd/migrate/command.ts +++ b/src/resources/extensions/gsd/migrate/command.ts @@ -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/`, diff --git a/src/resources/extensions/gsd/migrate/writer.ts b/src/resources/extensions/gsd/migrate/writer.ts index 4fa12eaf9..698a3290f 100644 --- a/src/resources/extensions/gsd/migrate/writer.ts +++ b/src/resources/extensions/gsd/migrate/writer.ts @@ -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 { - const gsdDir = join(targetPath, '.gsd'); + const gsdDir = gsdRoot(targetPath); const milestonesBase = join(gsdDir, 'milestones'); const paths: string[] = []; const counts: WrittenFiles['counts'] = { diff --git a/src/resources/extensions/gsd/paths.ts b/src/resources/extensions/gsd/paths.ts index 0b1fca887..4c523837a 100644 --- a/src/resources/extensions/gsd/paths.ts +++ b/src/resources/extensions/gsd/paths.ts @@ -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 = { }; 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 { diff --git a/src/resources/extensions/gsd/post-unit-hooks.ts b/src/resources/extensions/gsd/post-unit-hooks.ts index 649566259..0dd2a4d92 100644 --- a/src/resources/extensions/gsd/post-unit-hooks.ts +++ b/src/resources/extensions/gsd/post-unit-hooks.ts @@ -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 { diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 85ca4b6c7..e4a5725b8 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -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 { diff --git a/src/resources/extensions/gsd/repo-identity.ts b/src/resources/extensions/gsd/repo-identity.ts new file mode 100644 index 000000000..3bde2f1a0 --- /dev/null +++ b/src/resources/extensions/gsd/repo-identity.ts @@ -0,0 +1,148 @@ +/** + * GSD Repo Identity — external state directory primitives. + * + * Computes a stable per-repo identity hash, resolves the external + * `~/.gsd/projects//` state directory, and manages the + * `/.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/` if `GSD_STATE_DIR` is set, + * otherwise `~/.gsd/projects/`. + */ +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 `/.gsd` symlink points to the external state directory. + * + * 1. mkdir -p the external dir + * 2. If `/.gsd` doesn't exist → create symlink + * 3. If `/.gsd` is already the correct symlink → no-op + * 4. If `/.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; + } +} diff --git a/src/resources/extensions/gsd/resource-version.ts b/src/resources/extensions/gsd/resource-version.ts new file mode 100644 index 000000000..060eb3fa1 --- /dev/null +++ b/src/resources/extensions/gsd/resource-version.ts @@ -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).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//`. + * 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; +} diff --git a/src/resources/extensions/gsd/session-forensics.ts b/src/resources/extensions/gsd/session-forensics.ts index f389b6ad5..04894fe1f 100644 --- a/src/resources/extensions/gsd/session-forensics.ts +++ b/src/resources/extensions/gsd/session-forensics.ts @@ -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); diff --git a/src/resources/extensions/gsd/tests/activity-log.test.ts b/src/resources/extensions/gsd/tests/activity-log.test.ts index 646d36f09..423701723 100644 --- a/src/resources/extensions/gsd/tests/activity-log.test.ts +++ b/src/resources/extensions/gsd/tests/activity-log.test.ts @@ -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 { diff --git a/src/resources/extensions/gsd/tests/auto-recovery.test.ts b/src/resources/extensions/gsd/tests/auto-recovery.test.ts index 1e193cb11..011470e2c 100644 --- a/src/resources/extensions/gsd/tests/auto-recovery.test.ts +++ b/src/resources/extensions/gsd/tests/auto-recovery.test.ts @@ -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)) { diff --git a/src/resources/extensions/gsd/tests/auto-worktree.test.ts b/src/resources/extensions/gsd/tests/auto-worktree.test.ts index 5ddd3c0f1..abb93baa2 100644 --- a/src/resources/extensions/gsd/tests/auto-worktree.test.ts +++ b/src/resources/extensions/gsd/tests/auto-worktree.test.ts @@ -153,64 +153,6 @@ async function main(): Promise { // 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); diff --git a/src/resources/extensions/gsd/tests/doctor-runtime.test.ts b/src/resources/extensions/gsd/tests/doctor-runtime.test.ts index 794ee0fe7..dda4ea9ab 100644 --- a/src/resources/extensions/gsd/tests/doctor-runtime.test.ts +++ b/src/resources/extensions/gsd/tests/doctor-runtime.test.ts @@ -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) ==="); diff --git a/src/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts b/src/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts index 26be12465..10158295a 100644 --- a/src/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +++ b/src/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts @@ -311,7 +311,7 @@ async function main(): Promise { // 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 { 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); diff --git a/src/resources/extensions/gsd/tests/git-service.test.ts b/src/resources/extensions/gsd/tests/git-service.test.ts index d5e73a888..4bb14f636 100644 --- a/src/resources/extensions/gsd/tests/git-service.test.ts +++ b/src/resources/extensions/gsd/tests/git-service.test.ts @@ -1167,53 +1167,26 @@ async function main(): Promise { 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 }); } diff --git a/src/resources/extensions/gsd/tests/knowledge.test.ts b/src/resources/extensions/gsd/tests/knowledge.test.ts index 907d43d2b..5fa832577 100644 --- a/src/resources/extensions/gsd/tests/knowledge.test.ts +++ b/src/resources/extensions/gsd/tests/knowledge.test.ts @@ -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 }); diff --git a/src/resources/extensions/gsd/tests/worktree-db-integration.test.ts b/src/resources/extensions/gsd/tests/worktree-db-integration.test.ts deleted file mode 100644 index 791a5f494..000000000 --- a/src/resources/extensions/gsd/tests/worktree-db-integration.test.ts +++ /dev/null @@ -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 { - 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(); diff --git a/src/resources/extensions/gsd/tests/worktree-db.test.ts b/src/resources/extensions/gsd/tests/worktree-db.test.ts deleted file mode 100644 index 131f47a84..000000000 --- a/src/resources/extensions/gsd/tests/worktree-db.test.ts +++ /dev/null @@ -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(); diff --git a/src/resources/extensions/gsd/triage-resolution.ts b/src/resources/extensions/gsd/triage-resolution.ts index 8ddc4a6ba..3765c63dd 100644 --- a/src/resources/extensions/gsd/triage-resolution.ts +++ b/src/resources/extensions/gsd/triage-resolution.ts @@ -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"); diff --git a/src/resources/extensions/gsd/worktree-command.ts b/src/resources/extensions/gsd/worktree-command.ts index 4e3801149..84281ab0e 100644 --- a/src/resources/extensions/gsd/worktree-command.ts +++ b/src/resources/extensions/gsd/worktree-command.ts @@ -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( diff --git a/src/resources/extensions/gsd/worktree-manager.ts b/src/resources/extensions/gsd/worktree-manager.ts index 191676ccf..e10f2707f 100644 --- a/src/resources/extensions/gsd/worktree-manager.ts +++ b/src/resources/extensions/gsd/worktree-manager.ts @@ -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(); const worktreeRoots = baseVariants .map(baseVariant => { - const path = join(baseVariant, ".gsd", "worktrees"); + const path = join(gsdRoot(baseVariant), "worktrees"); return { normalized: normalizePathForComparison(path), }; diff --git a/src/resources/extensions/gsd/worktree.ts b/src/resources/extensions/gsd/worktree.ts index 7669aa9db..48d6d83fc 100644 --- a/src/resources/extensions/gsd/worktree.ts +++ b/src/resources/extensions/gsd/worktree.ts @@ -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//). */ 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: /.git/worktrees/ + 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: /.git/worktrees/ + // Walk up to + 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);