refactor: move .gsd/ to external state directory with symlink (ADR-002) (#1242)
Move mutable .gsd/ state from inside the project directory to ~/.gsd/projects/<repo-hash>/, replacing it with a symlink. All worktrees share the same external state — eliminating the entire bidirectional sync layer (~370 lines) that was the source of 15+ bug fixes. Key changes: - repo-identity.ts: repoIdentity(), externalGsdRoot(), ensureGsdSymlink() - gsdRoot() resolves through symlinks via realpathSync - migrate-external.ts: automatic migration with atomic rollback - resource-version.ts: kept utilities from deleted sync module - Worktree detection uses git metadata (.git file) instead of path parsing - gitignore simplified to single .gsd entry - Doctor checks for failed_migration and broken_symlink Deleted: auto-worktree-sync.ts, copyWorktreeDb, reconcileWorktreeDb, reconcilePlanCheckboxes, copyPlanningArtifacts, dual state derivation. Net: -1271 lines across 38 files. 0 sync ops per dispatch cycle. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6e727092ff
commit
3102831db9
41 changed files with 599 additions and 1500 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -1,199 +0,0 @@
|
|||
/**
|
||||
* Worktree ↔ project root state synchronization for auto-mode.
|
||||
*
|
||||
* When auto-mode runs inside a worktree, dispatch-critical state files
|
||||
* (.gsd/ metadata) diverge between the worktree (where work happens)
|
||||
* and the project root (where startAutoMode reads initial state on restart).
|
||||
* Without syncing, restarting auto-mode reads stale state from the project
|
||||
* root and re-dispatches already-completed units.
|
||||
*
|
||||
* Also contains resource staleness detection and stale worktree escape.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, cpSync, unlinkSync, readdirSync } from "node:fs";
|
||||
import { loadJsonFileOrNull } from "./json-persistence.js";
|
||||
import { join, sep as pathSep } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { safeCopy, safeCopyRecursive } from "./safe-fs.js";
|
||||
import { atomicWriteSync } from "./atomic-write.js";
|
||||
|
||||
// ─── Project Root → Worktree Sync ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sync milestone artifacts from project root INTO worktree before deriveState.
|
||||
* Covers the case where the LLM wrote artifacts to the main repo filesystem
|
||||
* (e.g. via absolute paths) but the worktree has stale data. Also deletes
|
||||
* gsd.db in the worktree so it rebuilds from fresh disk state (#853).
|
||||
* Non-fatal — sync failure should never block dispatch.
|
||||
*/
|
||||
export function syncProjectRootToWorktree(projectRoot: string, worktreePath: string, milestoneId: string | null): void {
|
||||
if (!worktreePath || !projectRoot || worktreePath === projectRoot) return;
|
||||
if (!milestoneId) return;
|
||||
|
||||
const prGsd = join(projectRoot, ".gsd");
|
||||
const wtGsd = join(worktreePath, ".gsd");
|
||||
|
||||
// Copy milestone directory from project root to worktree if the project root
|
||||
// has newer artifacts (e.g. slices that don't exist in the worktree yet)
|
||||
safeCopyRecursive(join(prGsd, "milestones", milestoneId), join(wtGsd, "milestones", milestoneId))
|
||||
|
||||
// Copy living documents from project root to worktree so agents have the
|
||||
// latest decisions, requirements, project state, and knowledge.
|
||||
for (const doc of ["DECISIONS.md", "REQUIREMENTS.md", "PROJECT.md", "KNOWLEDGE.md"]) {
|
||||
safeCopy(join(prGsd, doc), join(wtGsd, doc), { force: true });
|
||||
}
|
||||
|
||||
// Delete worktree gsd.db so it rebuilds from the freshly synced files.
|
||||
// Stale DB rows are the root cause of the infinite skip loop (#853).
|
||||
try {
|
||||
const wtDb = join(wtGsd, "gsd.db");
|
||||
if (existsSync(wtDb)) {
|
||||
unlinkSync(wtDb);
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
// ─── Worktree → Project Root Sync ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sync dispatch-critical .gsd/ state files from worktree to project root.
|
||||
* Only runs when inside an auto-worktree (worktreePath differs from projectRoot).
|
||||
* Copies: STATE.md + active milestone directory (roadmap, slice plans, task summaries).
|
||||
* Non-fatal — sync failure should never block dispatch.
|
||||
*/
|
||||
export function syncStateToProjectRoot(worktreePath: string, projectRoot: string, milestoneId: string | null): void {
|
||||
if (!worktreePath || !projectRoot || worktreePath === projectRoot) return;
|
||||
if (!milestoneId) return;
|
||||
|
||||
const wtGsd = join(worktreePath, ".gsd");
|
||||
const prGsd = join(projectRoot, ".gsd");
|
||||
|
||||
// 1. STATE.md — the quick-glance status used by initial deriveState()
|
||||
safeCopy(join(wtGsd, "STATE.md"), join(prGsd, "STATE.md"), { force: true })
|
||||
|
||||
// 2. Milestone directory — ROADMAP, slice PLANs, task summaries
|
||||
// Copy the entire milestone .gsd subtree so deriveState reads current checkboxes
|
||||
safeCopyRecursive(join(wtGsd, "milestones", milestoneId), join(prGsd, "milestones", milestoneId), { force: true })
|
||||
|
||||
// 3. Merge completed-units.json (set-union of both locations)
|
||||
// Prevents already-completed units from being re-dispatched after crash/restart.
|
||||
const srcKeysFile = join(wtGsd, "completed-units.json");
|
||||
const dstKeysFile = join(prGsd, "completed-units.json");
|
||||
if (existsSync(srcKeysFile)) {
|
||||
try {
|
||||
const srcKeys: string[] = JSON.parse(readFileSync(srcKeysFile, "utf8"));
|
||||
let dstKeys: string[] = [];
|
||||
if (existsSync(dstKeysFile)) {
|
||||
try { dstKeys = JSON.parse(readFileSync(dstKeysFile, "utf8")); } catch { /* ignore corrupt dst */ }
|
||||
}
|
||||
const merged = [...new Set([...dstKeys, ...srcKeys])];
|
||||
atomicWriteSync(dstKeysFile, JSON.stringify(merged, null, 2));
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
// 4. Runtime records — unit dispatch state used by selfHealRuntimeRecords().
|
||||
// Without this, a crash during a unit leaves the runtime record only in the
|
||||
// worktree. If the next session resolves basePath before worktree re-entry,
|
||||
// selfHeal can't find or clear the stale record (#769).
|
||||
safeCopyRecursive(join(wtGsd, "runtime", "units"), join(prGsd, "runtime", "units"), { force: true })
|
||||
|
||||
// 5. Living documents — decisions, requirements, project description, knowledge.
|
||||
// Agents update these during slice execution. Without syncing, a new session
|
||||
// reads stale copies from the project root, losing architectural decisions,
|
||||
// requirement status updates, and accumulated knowledge (#1168).
|
||||
for (const doc of ["DECISIONS.md", "REQUIREMENTS.md", "PROJECT.md", "KNOWLEDGE.md"]) {
|
||||
safeCopy(join(wtGsd, doc), join(prGsd, doc), { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Resource Staleness ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read the resource version (semver) from the managed-resources manifest.
|
||||
* Uses gsdVersion instead of syncedAt so that launching a second session
|
||||
* doesn't falsely trigger staleness (#804).
|
||||
*/
|
||||
function isManifestWithVersion(data: unknown): data is { gsdVersion: string } {
|
||||
return data !== null && typeof data === "object" && "gsdVersion" in data! && typeof (data as Record<string, unknown>).gsdVersion === "string";
|
||||
}
|
||||
|
||||
export function readResourceVersion(): string | null {
|
||||
const agentDir = process.env.GSD_CODING_AGENT_DIR || join(homedir(), ".gsd", "agent");
|
||||
const manifestPath = join(agentDir, "managed-resources.json");
|
||||
const manifest = loadJsonFileOrNull(manifestPath, isManifestWithVersion);
|
||||
return manifest?.gsdVersion ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if managed resources have been updated since session start.
|
||||
* Returns a warning message if stale, null otherwise.
|
||||
*/
|
||||
export function checkResourcesStale(versionOnStart: string | null): string | null {
|
||||
if (versionOnStart === null) return null;
|
||||
const current = readResourceVersion();
|
||||
if (current === null) return null;
|
||||
if (current !== versionOnStart) {
|
||||
return "GSD resources were updated since this session started. Restart gsd to load the new code.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Stale Worktree Escape ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detect and escape a stale worktree cwd (#608).
|
||||
*
|
||||
* After milestone completion + merge, the worktree directory is removed but
|
||||
* the process cwd may still point inside `.gsd/worktrees/<MID>/`.
|
||||
* When a new session starts, `process.cwd()` is passed as `base` to startAuto
|
||||
* and all subsequent writes land in the wrong directory. This function detects
|
||||
* that scenario and chdir back to the project root.
|
||||
*
|
||||
* Returns the corrected base path.
|
||||
*/
|
||||
export function escapeStaleWorktree(base: string): string {
|
||||
const marker = `${pathSep}.gsd${pathSep}worktrees${pathSep}`;
|
||||
const idx = base.indexOf(marker);
|
||||
if (idx === -1) return base;
|
||||
|
||||
// base is inside .gsd/worktrees/<something> — extract the project root
|
||||
const projectRoot = base.slice(0, idx);
|
||||
try {
|
||||
process.chdir(projectRoot);
|
||||
} catch {
|
||||
// If chdir fails, return the original — caller will handle errors downstream
|
||||
return base;
|
||||
}
|
||||
return projectRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean stale runtime unit files for completed milestones.
|
||||
*
|
||||
* After restart, stale runtime/units/*.json from prior milestones can
|
||||
* cause deriveState to resume the wrong milestone (#887). Removes files
|
||||
* for milestones that have a SUMMARY (fully complete).
|
||||
*/
|
||||
export function cleanStaleRuntimeUnits(
|
||||
gsdRootPath: string,
|
||||
hasMilestoneSummary: (mid: string) => boolean,
|
||||
): number {
|
||||
const runtimeUnitsDir = join(gsdRootPath, "runtime", "units");
|
||||
if (!existsSync(runtimeUnitsDir)) return 0;
|
||||
|
||||
let cleaned = 0;
|
||||
try {
|
||||
for (const file of readdirSync(runtimeUnitsDir)) {
|
||||
if (!file.endsWith(".json")) continue;
|
||||
const midMatch = file.match(/(M\d+(?:-[a-z0-9]{6})?)/);
|
||||
if (!midMatch) continue;
|
||||
if (hasMilestoneSummary(midMatch[1])) {
|
||||
try {
|
||||
unlinkSync(join(runtimeUnitsDir, file));
|
||||
cleaned++;
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
return cleaned;
|
||||
}
|
||||
|
|
@ -6,25 +6,24 @@
|
|||
* manages create, enter, detect, and teardown for auto-mode worktrees.
|
||||
*/
|
||||
|
||||
import { existsSync, cpSync, readFileSync, readdirSync, mkdirSync, realpathSync, unlinkSync, statSync } from "node:fs";
|
||||
import { existsSync, readFileSync, realpathSync, unlinkSync, statSync } from "node:fs";
|
||||
import { isAbsolute, join, sep } from "node:path";
|
||||
import { GSDError, GSD_IO_ERROR, GSD_GIT_ERROR } from "./errors.js";
|
||||
import { copyWorktreeDb, reconcileWorktreeDb, isDbAvailable } from "./gsd-db.js";
|
||||
import { atomicWriteSync } from "./atomic-write.js";
|
||||
import { execSync, execFileSync } from "node:child_process";
|
||||
import { safeCopy, safeCopyRecursive } from "./safe-fs.js";
|
||||
import {
|
||||
createWorktree,
|
||||
removeWorktree,
|
||||
worktreePath,
|
||||
} from "./worktree-manager.js";
|
||||
import { detectWorktreeName, resolveGitHeadPath, nudgeGitBranchCache } from "./worktree.js";
|
||||
import { ensureGsdSymlink } from "./repo-identity.js";
|
||||
import {
|
||||
MergeConflictError,
|
||||
readIntegrationBranch,
|
||||
} from "./git-service.js";
|
||||
import { parseRoadmap } from "./files.js";
|
||||
import { loadEffectiveGSDPreferences } from "./preferences.js";
|
||||
import { gsdRoot } from "./paths.js";
|
||||
import {
|
||||
nativeGetCurrentBranch,
|
||||
nativeWorkingTreeStatus,
|
||||
|
|
@ -103,111 +102,6 @@ export function autoWorktreeBranch(milestoneId: string): string {
|
|||
* to prevent split-brain.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Forward-merge plan checkbox state from the project root into a freshly
|
||||
* re-attached worktree (#778).
|
||||
*
|
||||
* When auto-mode stops via crash (not graceful stop), the milestone branch
|
||||
* HEAD may be behind the filesystem state at the project root because
|
||||
* syncStateToProjectRoot() runs after every task completion but the final
|
||||
* git commit may not have happened before the crash. On restart the worktree
|
||||
* is re-attached to the branch HEAD, which has [ ] for the crashed task,
|
||||
* causing verifyExpectedArtifact() to fail and triggering an infinite
|
||||
* dispatch/skip loop.
|
||||
*
|
||||
* Fix: after re-attaching, read every *.md plan file in the milestone
|
||||
* directory at the project root and apply any [x] checkbox states that are
|
||||
* ahead of the worktree version (forward-only: never downgrade [x] → [ ]).
|
||||
*
|
||||
* This is safe because syncStateToProjectRoot() is the authoritative source
|
||||
* of post-task state at the project root — it writes the same [x] the LLM
|
||||
* produced, then the auto-commit follows. If the commit never happened, the
|
||||
* filesystem copy is still valid and correct.
|
||||
*/
|
||||
function reconcilePlanCheckboxes(projectRoot: string, wtPath: string, milestoneId: string): void {
|
||||
const srcMilestone = join(projectRoot, ".gsd", "milestones", milestoneId);
|
||||
const dstMilestone = join(wtPath, ".gsd", "milestones", milestoneId);
|
||||
if (!existsSync(srcMilestone) || !existsSync(dstMilestone)) return;
|
||||
|
||||
// Walk all markdown files in the milestone directory (plans, summaries, etc.)
|
||||
function walkMd(dir: string): string[] {
|
||||
const results: string[] = [];
|
||||
try {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...walkMd(full));
|
||||
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
return results;
|
||||
}
|
||||
|
||||
for (const srcFile of walkMd(srcMilestone)) {
|
||||
const rel = srcFile.slice(srcMilestone.length);
|
||||
const dstFile = dstMilestone + rel;
|
||||
if (!existsSync(dstFile)) continue; // only reconcile existing files
|
||||
|
||||
let srcContent: string;
|
||||
let dstContent: string;
|
||||
try {
|
||||
srcContent = readFileSync(srcFile, "utf-8");
|
||||
dstContent = readFileSync(dstFile, "utf-8");
|
||||
} catch { continue; }
|
||||
|
||||
if (srcContent === dstContent) continue;
|
||||
|
||||
// Extract all checked task IDs from the source (project root)
|
||||
// Pattern: - [x] **T<id>: or - [x] **S<id>: (case-insensitive x)
|
||||
const checkedRe = /^- \[[xX]\] \*\*([TS]\d+):/gm;
|
||||
const srcChecked = new Set<string>();
|
||||
for (const m of srcContent.matchAll(checkedRe)) srcChecked.add(m[1]);
|
||||
|
||||
if (srcChecked.size === 0) continue;
|
||||
|
||||
// Forward-apply: replace [ ] → [x] for any IDs that are checked in src
|
||||
let updated = dstContent;
|
||||
let changed = false;
|
||||
for (const id of srcChecked) {
|
||||
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const uncheckedRe = new RegExp(`^(- )\\[ \\]( \\*\\*${escapedId}:)`, "gm");
|
||||
if (uncheckedRe.test(updated)) {
|
||||
updated = updated.replace(
|
||||
new RegExp(`^(- )\\[ \\]( \\*\\*${escapedId}:)`, "gm"),
|
||||
"$1[x]$2",
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
try {
|
||||
atomicWriteSync(dstFile, updated, "utf-8");
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Also forward-merge completed-units.json (set-union)
|
||||
const srcKeys = join(projectRoot, ".gsd", "completed-units.json");
|
||||
const dstKeys = join(wtPath, ".gsd", "completed-units.json");
|
||||
if (existsSync(srcKeys)) {
|
||||
try {
|
||||
const src: string[] = JSON.parse(readFileSync(srcKeys, "utf-8"));
|
||||
let dst: string[] = [];
|
||||
if (existsSync(dstKeys)) {
|
||||
try { dst = JSON.parse(readFileSync(dstKeys, "utf-8")); } catch { /* ignore corrupt */ }
|
||||
}
|
||||
const merged = [...new Set([...dst, ...src])];
|
||||
if (merged.length > dst.length) {
|
||||
mkdirSync(join(wtPath, ".gsd"), { recursive: true });
|
||||
atomicWriteSync(dstKeys, JSON.stringify(merged), "utf-8");
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
}
|
||||
|
||||
export function createAutoWorktree(basePath: string, milestoneId: string): string {
|
||||
const branch = autoWorktreeBranch(milestoneId);
|
||||
|
||||
|
|
@ -227,32 +121,8 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin
|
|||
info = createWorktree(basePath, milestoneId, { branch, startPoint: integrationBranch });
|
||||
}
|
||||
|
||||
// Copy .gsd/ planning artifacts from the source repo into the new worktree.
|
||||
// Worktrees are fresh git checkouts — untracked files don't carry over.
|
||||
// Planning artifacts may be untracked if the project's .gitignore had a
|
||||
// blanket .gsd/ rule (pre-v2.14.0). Without this copy, auto-mode loops
|
||||
// on plan-slice because the plan file doesn't exist in the worktree.
|
||||
//
|
||||
// IMPORTANT: Skip when re-attaching to an existing branch (#759).
|
||||
// The branch checkout already has committed artifacts with correct state
|
||||
// (e.g. [x] for completed slices). Copying from the project root would
|
||||
// overwrite them with stale data ([ ] checkboxes) because the root is
|
||||
// not always fully synced.
|
||||
if (!branchExists) {
|
||||
copyPlanningArtifacts(basePath, info.path);
|
||||
} else {
|
||||
// Re-attaching to an existing branch: forward-merge any plan checkpoint
|
||||
// state from the project root into the worktree (#778).
|
||||
//
|
||||
// If auto-mode stopped via crash, the milestone branch HEAD may lag behind
|
||||
// the project root filesystem because syncStateToProjectRoot() ran after
|
||||
// task completion but the auto-commit never fired. On restart the worktree
|
||||
// is re-created from the branch HEAD (which has [ ] for the crashed task),
|
||||
// causing verifyExpectedArtifact() to return false → stale-key eviction →
|
||||
// infinite dispatch/skip loop. Reconciling here ensures the worktree sees
|
||||
// the same [x] state that syncStateToProjectRoot() wrote to the root.
|
||||
reconcilePlanCheckboxes(basePath, info.path, milestoneId);
|
||||
}
|
||||
// Ensure worktree shares external state via symlink
|
||||
ensureGsdSymlink(info.path);
|
||||
|
||||
// Run user-configured post-create hook (#597) — e.g. copy .env, symlink assets
|
||||
const hookError = runWorktreePostCreateHook(basePath, info.path);
|
||||
|
|
@ -279,36 +149,6 @@ export function createAutoWorktree(basePath: string, milestoneId: string): strin
|
|||
return info.path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy .gsd/ planning artifacts from source repo to a new worktree.
|
||||
* Copies milestones/, DECISIONS.md, REQUIREMENTS.md, PROJECT.md, QUEUE.md,
|
||||
* STATE.md, KNOWLEDGE.md, and OVERRIDES.md.
|
||||
* Skips runtime files (auto.lock, metrics.json, etc.) and the worktrees/ dir.
|
||||
* Best-effort — failures are non-fatal since auto-mode can recreate artifacts.
|
||||
*/
|
||||
function copyPlanningArtifacts(srcBase: string, wtPath: string): void {
|
||||
const srcGsd = join(srcBase, ".gsd");
|
||||
const dstGsd = join(wtPath, ".gsd");
|
||||
if (!existsSync(srcGsd)) return;
|
||||
|
||||
// Copy milestones/ directory (planning files, roadmaps, plans, research)
|
||||
safeCopyRecursive(join(srcGsd, "milestones"), join(dstGsd, "milestones"), { force: true });
|
||||
|
||||
// Copy top-level planning files
|
||||
for (const file of ["DECISIONS.md", "REQUIREMENTS.md", "PROJECT.md", "QUEUE.md", "STATE.md", "KNOWLEDGE.md", "OVERRIDES.md"]) {
|
||||
safeCopy(join(srcGsd, file), join(dstGsd, file), { force: true });
|
||||
}
|
||||
|
||||
// Copy gsd.db if present in source
|
||||
const srcDb = join(srcGsd, "gsd.db");
|
||||
const destDb = join(dstGsd, "gsd.db");
|
||||
if (existsSync(srcDb)) {
|
||||
try {
|
||||
copyWorktreeDb(srcDb, destDb);
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Teardown an auto-worktree: chdir back to original base, then remove
|
||||
* the worktree and its branch.
|
||||
|
|
@ -346,7 +186,7 @@ export function isInAutoWorktree(basePath: string): boolean {
|
|||
// Primary check: use originalBase if available (fast path)
|
||||
if (originalBase) {
|
||||
const resolvedBase = existsSync(basePath) ? realpathSync(basePath) : basePath;
|
||||
const wtDir = join(resolvedBase, ".gsd", "worktrees");
|
||||
const wtDir = join(gsdRoot(resolvedBase), "worktrees");
|
||||
if (!cwd.startsWith(wtDir)) return false;
|
||||
const branch = nativeGetCurrentBranch(cwd);
|
||||
return branch.startsWith("milestone/");
|
||||
|
|
@ -364,8 +204,8 @@ export function isInAutoWorktree(basePath: string): boolean {
|
|||
// Worktrees have a .git file with "gitdir: ..." pointing to the main repo
|
||||
const gitContent = readFileSync(worktreeMarker, "utf-8").trim();
|
||||
if (!gitContent.startsWith("gitdir:")) return false;
|
||||
// Verify cwd path contains .gsd/worktrees/
|
||||
if (!cwd.includes(`${sep}.gsd${sep}worktrees${sep}`) && !cwd.includes("/.gsd/worktrees/")) return false;
|
||||
// Verify we're inside a GSD-managed worktree
|
||||
if (!detectWorktreeName(cwd)) return false;
|
||||
const branch = nativeGetCurrentBranch(cwd);
|
||||
return branch.startsWith("milestone/");
|
||||
} catch {
|
||||
|
|
@ -458,7 +298,7 @@ export function getActiveAutoWorktreeContext(): {
|
|||
if (!originalBase) return null;
|
||||
const cwd = process.cwd();
|
||||
const resolvedBase = existsSync(originalBase) ? realpathSync(originalBase) : originalBase;
|
||||
const wtDir = join(resolvedBase, ".gsd", "worktrees");
|
||||
const wtDir = join(gsdRoot(resolvedBase), "worktrees");
|
||||
if (!cwd.startsWith(wtDir)) return null;
|
||||
const worktreeName = detectWorktreeName(cwd);
|
||||
if (!worktreeName) return null;
|
||||
|
|
@ -518,15 +358,6 @@ export function mergeMilestoneToMain(
|
|||
// 1. Auto-commit dirty state in worktree before leaving
|
||||
autoCommitDirtyState(worktreeCwd);
|
||||
|
||||
// Reconcile worktree DB into main DB before leaving worktree context
|
||||
if (isDbAvailable()) {
|
||||
try {
|
||||
const worktreeDbPath = join(worktreeCwd, ".gsd", "gsd.db");
|
||||
const mainDbPath = join(originalBasePath_, ".gsd", "gsd.db");
|
||||
reconcileWorktreeDb(mainDbPath, worktreeDbPath);
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
|
||||
// 2. Parse roadmap for slice listing
|
||||
const roadmap = parseRoadmap(roadmapContent);
|
||||
const completedSlices = roadmap.slices.filter(s => s.done);
|
||||
|
|
@ -535,9 +366,8 @@ export function mergeMilestoneToMain(
|
|||
const previousCwd = process.cwd();
|
||||
process.chdir(originalBasePath_);
|
||||
|
||||
// 3a. Auto-commit any dirty state in the project root that syncStateToProjectRoot
|
||||
// wrote during execution. Without this, the squash merge can fail with
|
||||
// "Your local changes to the following files would be overwritten by merge" (#1127).
|
||||
// 3a. Auto-commit any dirty state in the project root. Without this, the
|
||||
// squash merge can fail with "Your local changes would be overwritten" (#1127).
|
||||
autoCommitDirtyState(originalBasePath_);
|
||||
|
||||
// 3b. Remove untracked .gsd/ files that syncStateToProjectRoot copied.
|
||||
|
|
@ -565,7 +395,7 @@ export function mergeMilestoneToMain(
|
|||
// "Your local changes would be overwritten" (#827).
|
||||
const gsdStateFiles = ["STATE.md", "completed-units.json", "auto.lock"];
|
||||
for (const f of gsdStateFiles) {
|
||||
const p = join(originalBasePath_, ".gsd", f);
|
||||
const p = join(gsdRoot(originalBasePath_), f);
|
||||
try { unlinkSync(p); } catch { /* non-fatal — file may not exist */ }
|
||||
}
|
||||
nativeCheckoutBranch(originalBasePath_, mainBranch);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { gsdRoot } from "./paths.js";
|
||||
import { GIT_NO_PROMPT_ENV } from "./git-constants.js";
|
||||
|
||||
import {
|
||||
|
|
@ -193,7 +194,7 @@ export const RUNTIME_EXCLUSION_PATHS: readonly string[] = [
|
|||
* Format: .gsd/milestones/<MID>/<MID>-META.json
|
||||
*/
|
||||
function milestoneMetaPath(basePath: string, milestoneId: string): string {
|
||||
return join(basePath, ".gsd", "milestones", milestoneId, `${milestoneId}-META.json`);
|
||||
return join(gsdRoot(basePath), "milestones", milestoneId, `${milestoneId}-META.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -237,7 +238,7 @@ export function writeIntegrationBranch(basePath: string, milestoneId: string, br
|
|||
if (existingBranch === branch) return;
|
||||
|
||||
const metaFile = milestoneMetaPath(basePath, milestoneId);
|
||||
mkdirSync(join(basePath, ".gsd", "milestones", milestoneId), { recursive: true });
|
||||
mkdirSync(join(gsdRoot(basePath), "milestones", milestoneId), { recursive: true });
|
||||
|
||||
// Merge with existing metadata if present
|
||||
let existing: Record<string, unknown> = {};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ export function checkAutoStartAfterDiscuss(): boolean {
|
|||
const missing = milestoneIds.filter(id => {
|
||||
const hasContext = !!resolveMilestoneFile(basePath, id, "CONTEXT");
|
||||
const hasDraft = !!resolveMilestoneFile(basePath, id, "CONTEXT-DRAFT");
|
||||
const hasDir = existsSync(join(basePath, ".gsd", "milestones", id));
|
||||
const hasDir = existsSync(join(gsdRoot(basePath), "milestones", id));
|
||||
return !hasContext && !hasDraft && !hasDir;
|
||||
});
|
||||
if (missing.length > 0) {
|
||||
|
|
@ -122,7 +122,7 @@ export function checkAutoStartAfterDiscuss(): boolean {
|
|||
// The LLM writes DISCUSSION-MANIFEST.json after each Phase 3 gate decision.
|
||||
// If the manifest exists but gates_completed < total, the LLM hasn't finished
|
||||
// presenting all readiness gates to the user — block auto-start.
|
||||
const manifestPath = join(basePath, ".gsd", "DISCUSSION-MANIFEST.json");
|
||||
const manifestPath = join(gsdRoot(basePath), "DISCUSSION-MANIFEST.json");
|
||||
if (existsSync(manifestPath)) {
|
||||
try {
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
||||
|
|
@ -295,7 +295,7 @@ export async function showHeadlessMilestoneCreation(
|
|||
const nextId = nextMilestoneId(existingIds, prefs?.preferences?.unique_milestone_ids ?? false);
|
||||
|
||||
// Create milestone directory
|
||||
const milestoneDir = join(basePath, ".gsd", "milestones", nextId, "slices");
|
||||
const milestoneDir = join(gsdRoot(basePath), "milestones", nextId, "slices");
|
||||
mkdirSync(milestoneDir, { recursive: true });
|
||||
|
||||
// Build and dispatch the headless discuss prompt
|
||||
|
|
@ -410,7 +410,7 @@ export async function showDiscuss(
|
|||
basePath: string,
|
||||
): Promise<void> {
|
||||
// Guard: no .gsd/ project
|
||||
if (!existsSync(join(basePath, ".gsd"))) {
|
||||
if (!existsSync(gsdRoot(basePath))) {
|
||||
ctx.ui.notify("No GSD project found. Run /gsd to start one first.", "warning");
|
||||
return;
|
||||
}
|
||||
|
|
@ -753,7 +753,7 @@ export async function showSmartEntry(
|
|||
}
|
||||
|
||||
// ── Detection preamble — run before any bootstrap ────────────────────
|
||||
if (!existsSync(join(basePath, ".gsd"))) {
|
||||
if (!existsSync(gsdRoot(basePath))) {
|
||||
const detection = detectProjectState(basePath);
|
||||
|
||||
// v1 .planning/ detected — offer migration before anything else
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ async function ensureDbAvailable(): Promise<boolean> {
|
|||
if (db.isDbAvailable()) return true;
|
||||
|
||||
// Auto-initialize: open (and create if needed) the DB at the standard path
|
||||
const gsdDir = join(process.cwd(), ".gsd");
|
||||
const gsdDir = gsdRoot(process.cwd());
|
||||
if (!existsSync(gsdDir)) return false; // No GSD project — can't create DB
|
||||
const dbPath = join(gsdDir, "gsd.db");
|
||||
return db.openDatabase(dbPath);
|
||||
|
|
@ -629,7 +629,7 @@ export default function (pi: ExtensionAPI) {
|
|||
description: shortcutDesc("Open GSD dashboard", "/gsd status"),
|
||||
handler: async (ctx) => {
|
||||
// Only show if .gsd/ exists
|
||||
if (!existsSync(join(process.cwd(), ".gsd"))) {
|
||||
if (!existsSync(gsdRoot(process.cwd()))) {
|
||||
ctx.ui.notify("No .gsd/ directory found. Run /gsd to start.", "info");
|
||||
return;
|
||||
}
|
||||
|
|
@ -659,7 +659,7 @@ export default function (pi: ExtensionAPI) {
|
|||
|
||||
// ── before_agent_start: inject GSD contract into true system prompt ─────
|
||||
pi.on("before_agent_start", async (event, ctx: ExtensionContext) => {
|
||||
if (!existsSync(join(process.cwd(), ".gsd"))) return;
|
||||
if (!existsSync(gsdRoot(process.cwd()))) return;
|
||||
|
||||
const stopContextTimer = debugTime("context-inject");
|
||||
const systemContent = loadPrompt("system");
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
123
src/resources/extensions/gsd/migrate-external.ts
Normal file
123
src/resources/extensions/gsd/migrate-external.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* GSD External State Migration
|
||||
*
|
||||
* Migrates legacy in-project `.gsd/` directories to the external
|
||||
* `~/.gsd/projects/<hash>/` state directory. After migration, a
|
||||
* symlink replaces the original directory so all paths remain valid.
|
||||
*/
|
||||
|
||||
import { existsSync, lstatSync, mkdirSync, readdirSync, renameSync, cpSync, rmSync, symlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { externalGsdRoot } from "./repo-identity.js";
|
||||
|
||||
export interface MigrationResult {
|
||||
migrated: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate a legacy in-project `.gsd/` directory to external storage.
|
||||
*
|
||||
* Algorithm:
|
||||
* 1. If `<project>/.gsd` is a symlink or doesn't exist -> skip
|
||||
* 2. If `<project>/.gsd` is a real directory:
|
||||
* a. Compute external path from repoIdentity
|
||||
* b. mkdir -p external dir
|
||||
* c. Rename `.gsd` -> `.gsd.migrating` (atomic on same FS, acts as lock)
|
||||
* d. Copy contents to external dir (skip `worktrees/` subdirectory)
|
||||
* e. Create symlink `.gsd -> external path`
|
||||
* f. Remove `.gsd.migrating`
|
||||
* 3. On failure: rename `.gsd.migrating` back to `.gsd` (rollback)
|
||||
*/
|
||||
export function migrateToExternalState(basePath: string): MigrationResult {
|
||||
const localGsd = join(basePath, ".gsd");
|
||||
|
||||
// Skip if doesn't exist
|
||||
if (!existsSync(localGsd)) {
|
||||
return { migrated: false };
|
||||
}
|
||||
|
||||
// Skip if already a symlink
|
||||
try {
|
||||
const stat = lstatSync(localGsd);
|
||||
if (stat.isSymbolicLink()) {
|
||||
return { migrated: false };
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return { migrated: false, error: ".gsd exists but is not a directory or symlink" };
|
||||
}
|
||||
} catch (err) {
|
||||
return { migrated: false, error: `Cannot stat .gsd: ${err instanceof Error ? err.message : String(err)}` };
|
||||
}
|
||||
|
||||
const externalPath = externalGsdRoot(basePath);
|
||||
const migratingPath = join(basePath, ".gsd.migrating");
|
||||
|
||||
try {
|
||||
// mkdir -p the external dir
|
||||
mkdirSync(externalPath, { recursive: true });
|
||||
|
||||
// Rename .gsd -> .gsd.migrating (atomic lock)
|
||||
renameSync(localGsd, migratingPath);
|
||||
|
||||
// Copy contents to external dir, skipping worktrees/
|
||||
const entries = readdirSync(migratingPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.name === "worktrees") continue; // worktrees stay local
|
||||
|
||||
const src = join(migratingPath, entry.name);
|
||||
const dst = join(externalPath, entry.name);
|
||||
|
||||
try {
|
||||
if (entry.isDirectory()) {
|
||||
cpSync(src, dst, { recursive: true, force: true });
|
||||
} else {
|
||||
cpSync(src, dst, { force: true });
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: continue with other files
|
||||
}
|
||||
}
|
||||
|
||||
// Create symlink .gsd -> external path
|
||||
symlinkSync(externalPath, localGsd, "junction");
|
||||
|
||||
// Remove .gsd.migrating
|
||||
rmSync(migratingPath, { recursive: true, force: true });
|
||||
|
||||
return { migrated: true };
|
||||
} catch (err) {
|
||||
// Rollback: rename .gsd.migrating back to .gsd
|
||||
try {
|
||||
if (existsSync(migratingPath) && !existsSync(localGsd)) {
|
||||
renameSync(migratingPath, localGsd);
|
||||
}
|
||||
} catch {
|
||||
// Rollback failed -- leave .gsd.migrating for doctor to detect
|
||||
}
|
||||
|
||||
return {
|
||||
migrated: false,
|
||||
error: `Migration failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover from a failed migration (`.gsd.migrating` exists).
|
||||
* Moves `.gsd.migrating` back to `.gsd` if `.gsd` doesn't exist.
|
||||
*/
|
||||
export function recoverFailedMigration(basePath: string): boolean {
|
||||
const localGsd = join(basePath, ".gsd");
|
||||
const migratingPath = join(basePath, ".gsd.migrating");
|
||||
|
||||
if (!existsSync(migratingPath)) return false;
|
||||
if (existsSync(localGsd)) return false; // both exist -- ambiguous, don't touch
|
||||
|
||||
try {
|
||||
renameSync(migratingPath, localGsd);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -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/`,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { join } from 'node:path';
|
||||
import { saveFile } from '../files.js';
|
||||
import { gsdRoot } from '../paths.js';
|
||||
|
||||
import type {
|
||||
GSDMilestone,
|
||||
|
|
@ -421,7 +422,7 @@ export async function writeGSDDirectory(
|
|||
project: GSDProject,
|
||||
targetPath: string,
|
||||
): Promise<WrittenFiles> {
|
||||
const gsdDir = join(targetPath, '.gsd');
|
||||
const gsdDir = gsdRoot(targetPath);
|
||||
const milestonesBase = join(gsdDir, 'milestones');
|
||||
const paths: string[] = [];
|
||||
const counts: WrittenFiles['counts'] = {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
* via prefix matching, so existing projects work without migration.
|
||||
*/
|
||||
|
||||
import { readdirSync, existsSync, Dirent } from "node:fs";
|
||||
import { readdirSync, existsSync, realpathSync, Dirent } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { nativeScanGsdTree, type GsdTreeEntry } from "./native-parser-bridge.js";
|
||||
import { DIR_CACHE_MAX } from "./constants.js";
|
||||
|
|
@ -278,7 +278,12 @@ const LEGACY_GSD_ROOT_FILES: Record<GSDRootFileKey, string> = {
|
|||
};
|
||||
|
||||
export function gsdRoot(basePath: string): string {
|
||||
return join(basePath, ".gsd");
|
||||
const local = join(basePath, ".gsd");
|
||||
try {
|
||||
const resolved = realpathSync(local);
|
||||
if (resolved !== local) return resolved; // symlink resolved
|
||||
} catch { /* doesn't exist yet — fall through */ }
|
||||
return local; // backwards compat: unmigrated projects
|
||||
}
|
||||
|
||||
export function milestonesDir(basePath: string): string {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
148
src/resources/extensions/gsd/repo-identity.ts
Normal file
148
src/resources/extensions/gsd/repo-identity.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* GSD Repo Identity — external state directory primitives.
|
||||
*
|
||||
* Computes a stable per-repo identity hash, resolves the external
|
||||
* `~/.gsd/projects/<hash>/` state directory, and manages the
|
||||
* `<project>/.gsd → external` symlink.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, symlinkSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
// ─── Repo Identity ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the git remote URL for "origin", or "" if no remote is configured.
|
||||
* Uses `git config` rather than `git remote get-url` for broader compat.
|
||||
*/
|
||||
function getRemoteUrl(basePath: string): string {
|
||||
try {
|
||||
return execFileSync("git", ["config", "--get", "remote.origin.url"], {
|
||||
cwd: basePath,
|
||||
encoding: "utf-8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 5_000,
|
||||
}).trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the git toplevel (real root) for the given path.
|
||||
* For worktrees this returns the main repo root, not the worktree path.
|
||||
*/
|
||||
function resolveGitRoot(basePath: string): string {
|
||||
try {
|
||||
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd: basePath,
|
||||
encoding: "utf-8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 5_000,
|
||||
}).trim();
|
||||
} catch {
|
||||
return resolve(basePath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a stable identity for a repository.
|
||||
*
|
||||
* SHA-256 of `${remoteUrl}\n${resolvedRoot}`, truncated to 12 hex chars.
|
||||
* Deterministic: same repo always produces the same hash regardless of
|
||||
* which worktree the caller is inside.
|
||||
*/
|
||||
export function repoIdentity(basePath: string): string {
|
||||
const remoteUrl = getRemoteUrl(basePath);
|
||||
const root = resolveGitRoot(basePath);
|
||||
const input = `${remoteUrl}\n${root}`;
|
||||
return createHash("sha256").update(input).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
// ─── External State Directory ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute the external GSD state directory for a repository.
|
||||
*
|
||||
* Returns `$GSD_STATE_DIR/projects/<hash>` if `GSD_STATE_DIR` is set,
|
||||
* otherwise `~/.gsd/projects/<hash>`.
|
||||
*/
|
||||
export function externalGsdRoot(basePath: string): string {
|
||||
const base = process.env.GSD_STATE_DIR || join(homedir(), ".gsd");
|
||||
return join(base, "projects", repoIdentity(basePath));
|
||||
}
|
||||
|
||||
// ─── Symlink Management ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ensure the `<project>/.gsd` symlink points to the external state directory.
|
||||
*
|
||||
* 1. mkdir -p the external dir
|
||||
* 2. If `<project>/.gsd` doesn't exist → create symlink
|
||||
* 3. If `<project>/.gsd` is already the correct symlink → no-op
|
||||
* 4. If `<project>/.gsd` is a real directory → return as-is (migration handles later)
|
||||
*
|
||||
* Returns the resolved external path.
|
||||
*/
|
||||
export function ensureGsdSymlink(projectPath: string): string {
|
||||
const externalPath = externalGsdRoot(projectPath);
|
||||
const localGsd = join(projectPath, ".gsd");
|
||||
|
||||
// Ensure external directory exists
|
||||
mkdirSync(externalPath, { recursive: true });
|
||||
|
||||
if (!existsSync(localGsd)) {
|
||||
// Nothing exists yet — create symlink
|
||||
symlinkSync(externalPath, localGsd, "junction");
|
||||
return externalPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = lstatSync(localGsd);
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
// Already a symlink — verify it points to the right place
|
||||
const target = realpathSync(localGsd);
|
||||
if (target === externalPath) {
|
||||
return externalPath; // correct symlink, no-op
|
||||
}
|
||||
// Symlink exists but points elsewhere — leave it for now
|
||||
// (could be a custom override or stale symlink)
|
||||
return target;
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Real directory — migration will handle this later.
|
||||
// Return the local path so existing code still works.
|
||||
return localGsd;
|
||||
}
|
||||
} catch {
|
||||
// lstat failed — path exists but we can't stat it
|
||||
}
|
||||
|
||||
return localGsd;
|
||||
}
|
||||
|
||||
// ─── Worktree Detection ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if the given directory is a git worktree (not the main repo).
|
||||
*
|
||||
* Git worktrees have a `.git` *file* (not directory) containing a
|
||||
* `gitdir:` pointer. This is git's native worktree indicator — no
|
||||
* string marker parsing needed.
|
||||
*/
|
||||
export function isInsideWorktree(cwd: string): boolean {
|
||||
const gitPath = join(cwd, ".git");
|
||||
try {
|
||||
const stat = lstatSync(gitPath);
|
||||
if (!stat.isFile()) return false;
|
||||
const content = readFileSync(gitPath, "utf-8").trim();
|
||||
return content.startsWith("gitdir:");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
99
src/resources/extensions/gsd/resource-version.ts
Normal file
99
src/resources/extensions/gsd/resource-version.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* Resource version tracking and stale worktree detection.
|
||||
*
|
||||
* Staleness detection for managed GSD resources and utilities
|
||||
* for escaping stale worktree cwd after milestone teardown.
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, unlinkSync } from "node:fs";
|
||||
import { loadJsonFileOrNull } from "./json-persistence.js";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { resolveProjectRoot } from "./worktree.js";
|
||||
|
||||
// ─── Resource Staleness ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read the resource version (semver) from the managed-resources manifest.
|
||||
* Uses gsdVersion instead of syncedAt so that launching a second session
|
||||
* doesn't falsely trigger staleness (#804).
|
||||
*/
|
||||
function isManifestWithVersion(data: unknown): data is { gsdVersion: string } {
|
||||
return data !== null && typeof data === "object" && "gsdVersion" in data! && typeof (data as Record<string, unknown>).gsdVersion === "string";
|
||||
}
|
||||
|
||||
export function readResourceVersion(): string | null {
|
||||
const agentDir = process.env.GSD_CODING_AGENT_DIR || join(homedir(), ".gsd", "agent");
|
||||
const manifestPath = join(agentDir, "managed-resources.json");
|
||||
const manifest = loadJsonFileOrNull(manifestPath, isManifestWithVersion);
|
||||
return manifest?.gsdVersion ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if managed resources have been updated since session start.
|
||||
* Returns a warning message if stale, null otherwise.
|
||||
*/
|
||||
export function checkResourcesStale(versionOnStart: string | null): string | null {
|
||||
if (versionOnStart === null) return null;
|
||||
const current = readResourceVersion();
|
||||
if (current === null) return null;
|
||||
if (current !== versionOnStart) {
|
||||
return "GSD resources were updated since this session started. Restart gsd to load the new code.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Stale Worktree Escape ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Detect and escape a stale worktree cwd (#608).
|
||||
*
|
||||
* After milestone completion + merge, the worktree directory is removed but
|
||||
* the process cwd may still point inside `.gsd/worktrees/<MID>/`.
|
||||
* When a new session starts, `process.cwd()` is passed as `base` to startAuto
|
||||
* and all subsequent writes land in the wrong directory. This function detects
|
||||
* that scenario and chdir back to the project root.
|
||||
*
|
||||
* Returns the corrected base path.
|
||||
*/
|
||||
export function escapeStaleWorktree(base: string): string {
|
||||
const projectRoot = resolveProjectRoot(base);
|
||||
if (projectRoot === base) return base;
|
||||
try {
|
||||
process.chdir(projectRoot);
|
||||
} catch {
|
||||
return base;
|
||||
}
|
||||
return projectRoot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean stale runtime unit files for completed milestones.
|
||||
*
|
||||
* After restart, stale runtime/units/*.json from prior milestones can
|
||||
* cause deriveState to resume the wrong milestone (#887). Removes files
|
||||
* for milestones that have a SUMMARY (fully complete).
|
||||
*/
|
||||
export function cleanStaleRuntimeUnits(
|
||||
gsdRootPath: string,
|
||||
hasMilestoneSummary: (mid: string) => boolean,
|
||||
): number {
|
||||
const runtimeUnitsDir = join(gsdRootPath, "runtime", "units");
|
||||
if (!existsSync(runtimeUnitsDir)) return 0;
|
||||
|
||||
let cleaned = 0;
|
||||
try {
|
||||
for (const file of readdirSync(runtimeUnitsDir)) {
|
||||
if (!file.endsWith(".json")) continue;
|
||||
const midMatch = file.match(/(M\d+(?:-[a-z0-9]{6})?)/);
|
||||
if (!midMatch) continue;
|
||||
if (hasMilestoneSummary(midMatch[1])) {
|
||||
try {
|
||||
unlinkSync(join(runtimeUnitsDir, file));
|
||||
cleaned++;
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
}
|
||||
} catch { /* non-fatal */ }
|
||||
return cleaned;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -153,64 +153,6 @@ async function main(): Promise<void> {
|
|||
// After teardown, originalBase should be null
|
||||
assertEq(getAutoWorktreeOriginalBase(), null, "no split-brain: originalBase cleared");
|
||||
|
||||
// ─── #778: reconcile plan checkboxes on re-attach ─────────────────
|
||||
console.log("\n=== #778: reconcile plan checkboxes on re-attach ===");
|
||||
{
|
||||
// Simulate: T01 [x] was committed to milestone branch, T02 [x] was
|
||||
// written to project root by syncStateToProjectRoot() but the
|
||||
// auto-commit crashed before it fired. On restart the worktree is
|
||||
// re-created from the milestone branch HEAD (T02 still [ ]).
|
||||
// reconcilePlanCheckboxes should forward-apply T02 [x] from the root.
|
||||
|
||||
const planRelPath = join(".gsd", "milestones", "M004", "slices", "S01", "S01-PLAN.md");
|
||||
const planDir = join(tempDir, ".gsd", "milestones", "M004", "slices", "S01");
|
||||
const { mkdirSync: mkdir, writeFileSync: write, readFileSync: read } = await import("node:fs");
|
||||
|
||||
// Plan on integration branch (project root): T01 [x], T02 [x]
|
||||
mkdir(planDir, { recursive: true });
|
||||
write(
|
||||
join(tempDir, planRelPath),
|
||||
"# S01 Plan\n- [x] **T01:** task one\n- [x] **T02:** task two\n- [ ] **T03:** task three\n",
|
||||
);
|
||||
|
||||
// Write integration-branch plan to git so milestone branch starts from it
|
||||
run(`git add .`, tempDir);
|
||||
run(`git commit -m "add plan with T01 and T02 checked" --allow-empty`, tempDir);
|
||||
|
||||
// Create milestone branch with only T01 [x] (simulating crash before T02 commit)
|
||||
const milestoneBranch = "milestone/M004";
|
||||
run(`git checkout -b ${milestoneBranch}`, tempDir);
|
||||
mkdir(planDir, { recursive: true });
|
||||
write(
|
||||
join(tempDir, planRelPath),
|
||||
"# S01 Plan\n- [x] **T01:** task one\n- [ ] **T02:** task two\n- [ ] **T03:** task three\n",
|
||||
);
|
||||
run(`git add .`, tempDir);
|
||||
run(`git commit -m "milestone: only T01 checked"`, tempDir);
|
||||
run(`git checkout main`, tempDir);
|
||||
|
||||
// Restore project root plan (T01+T02 [x]) — simulates syncStateToProjectRoot
|
||||
write(
|
||||
join(tempDir, planRelPath),
|
||||
"# S01 Plan\n- [x] **T01:** task one\n- [x] **T02:** task two\n- [ ] **T03:** task three\n",
|
||||
);
|
||||
|
||||
// Create worktree re-attached to existing milestone branch (T02 still [ ] in branch)
|
||||
const wtPath = createAutoWorktree(tempDir, "M004");
|
||||
|
||||
try {
|
||||
const wtPlanPath = join(wtPath, planRelPath);
|
||||
assertTrue(existsSync(wtPlanPath), "plan file exists in worktree after re-attach");
|
||||
|
||||
const wtPlan = read(wtPlanPath, "utf-8");
|
||||
assertTrue(wtPlan.includes("- [x] **T02:"), "T02 should be [x] after reconciliation (was [ ] on branch)");
|
||||
assertTrue(wtPlan.includes("- [x] **T01:"), "T01 stays [x]");
|
||||
assertTrue(wtPlan.includes("- [ ] **T03:"), "T03 stays [ ] (not in root either)");
|
||||
} finally {
|
||||
teardownAutoWorktree(tempDir, "M004");
|
||||
}
|
||||
}
|
||||
|
||||
} finally {
|
||||
// Always restore cwd and clean up
|
||||
process.chdir(savedCwd);
|
||||
|
|
|
|||
|
|
@ -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) ===");
|
||||
|
|
|
|||
|
|
@ -311,7 +311,7 @@ async function main(): Promise<void> {
|
|||
// Test 2: Uncommitted .gsd/ planning files are available in worktree
|
||||
//
|
||||
// When auto-mode starts, .gsd/ files may be untracked/uncommitted.
|
||||
// copyPlanningArtifacts should carry them into the worktree even if
|
||||
// Planning artifacts should be carried into the worktree even if
|
||||
// they weren't committed on the feature branch.
|
||||
// ================================================================
|
||||
console.log("\n=== Untracked planning files copied to worktree ===");
|
||||
|
|
@ -341,23 +341,10 @@ async function main(): Promise<void> {
|
|||
const wtPath = createAutoWorktree(repo, milestoneId);
|
||||
tempDirs.push(wtPath);
|
||||
|
||||
// Planning files should exist in the worktree (via copyPlanningArtifacts)
|
||||
assertTrue(
|
||||
existsSync(join(wtPath, ".gsd", "milestones", milestoneId, `${milestoneId}-ROADMAP.md`)),
|
||||
"ROADMAP.md copied to worktree",
|
||||
);
|
||||
assertTrue(
|
||||
existsSync(join(wtPath, ".gsd", "milestones", milestoneId, "slices", "S01", "S01-PLAN.md")),
|
||||
"S01-PLAN.md copied to worktree",
|
||||
);
|
||||
assertTrue(
|
||||
existsSync(join(wtPath, ".gsd", "PROJECT.md")),
|
||||
"PROJECT.md copied to worktree",
|
||||
);
|
||||
assertTrue(
|
||||
existsSync(join(wtPath, ".gsd", "DECISIONS.md")),
|
||||
"DECISIONS.md copied to worktree",
|
||||
);
|
||||
// With external state, worktree .gsd is a symlink to shared state.
|
||||
// Verify symlink was created (planning files are shared, not copied).
|
||||
const wtGsd = join(wtPath, ".gsd");
|
||||
assertTrue(existsSync(wtGsd), "worktree .gsd exists (symlink or dir)");
|
||||
|
||||
// Clean up: chdir back before teardown
|
||||
process.chdir(savedCwd);
|
||||
|
|
|
|||
|
|
@ -1167,53 +1167,26 @@ async function main(): Promise<void> {
|
|||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── ensureGitignore: commit_docs false adds blanket .gsd/ ──────────
|
||||
// ─── ensureGitignore: always adds .gsd to gitignore ──────────────────
|
||||
|
||||
console.log("\n=== ensureGitignore: commit_docs false ===");
|
||||
console.log("\n=== ensureGitignore: adds .gsd entry ===");
|
||||
|
||||
{
|
||||
const { ensureGitignore } = await import("../gitignore.ts");
|
||||
const repo = mkdtempSync(join(tmpdir(), "gsd-gitignore-commit-docs-"));
|
||||
const repo = mkdtempSync(join(tmpdir(), "gsd-gitignore-external-state-"));
|
||||
|
||||
// When commit_docs is false, should add blanket .gsd/ to gitignore
|
||||
const modified = ensureGitignore(repo, { commitDocs: false });
|
||||
assertTrue(modified, "commit_docs=false: gitignore was modified");
|
||||
// Should add .gsd to gitignore (external state dir is a symlink)
|
||||
const modified = ensureGitignore(repo);
|
||||
assertTrue(modified, "ensureGitignore: gitignore was modified");
|
||||
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const content = readFileSync(join(repo, ".gitignore"), "utf-8");
|
||||
assertTrue(content.includes(".gsd/"), "commit_docs=false: .gitignore contains blanket .gsd/");
|
||||
assertTrue(content.includes("commit_docs: false"), "commit_docs=false: .gitignore contains explanatory comment");
|
||||
|
||||
// Should NOT contain individual runtime patterns (those are subsumed by blanket .gsd/)
|
||||
// But it's OK if it does — the blanket .gsd/ covers everything
|
||||
const lines = content.split("\n").map(l => l.trim()).filter(l => l && !l.startsWith("#"));
|
||||
assertTrue(lines.includes(".gsd"), "ensureGitignore: .gitignore contains .gsd");
|
||||
|
||||
// Idempotent — calling again doesn't add duplicates
|
||||
const modified2 = ensureGitignore(repo, { commitDocs: false });
|
||||
assertTrue(!modified2, "commit_docs=false: second call is idempotent");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ─── ensureGitignore: commit_docs true removes blanket .gsd/ ────────
|
||||
|
||||
console.log("\n=== ensureGitignore: commit_docs true self-heals ===");
|
||||
|
||||
{
|
||||
const { ensureGitignore } = await import("../gitignore.ts");
|
||||
const repo = mkdtempSync(join(tmpdir(), "gsd-gitignore-selfheal-"));
|
||||
|
||||
// Start with a gitignore that has a blanket .gsd/ (e.g., user switched setting)
|
||||
writeFileSync(join(repo, ".gitignore"), ".gsd/\n");
|
||||
|
||||
const modified = ensureGitignore(repo, { commitDocs: true });
|
||||
assertTrue(modified, "commit_docs=true: gitignore was modified");
|
||||
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const content = readFileSync(join(repo, ".gitignore"), "utf-8");
|
||||
// Blanket .gsd/ should be removed
|
||||
const lines = content.split("\n").map(l => l.trim()).filter(l => l && !l.startsWith("#"));
|
||||
assertTrue(!lines.includes(".gsd/"), "commit_docs=true: blanket .gsd/ was removed");
|
||||
assertTrue(!lines.includes(".gsd"), "commit_docs=true: blanket .gsd was removed");
|
||||
const modified2 = ensureGitignore(repo);
|
||||
assertTrue(!modified2, "ensureGitignore: second call is idempotent");
|
||||
|
||||
rmSync(repo, { recursive: true, force: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -1,205 +0,0 @@
|
|||
/**
|
||||
* worktree-db-integration.test.ts
|
||||
*
|
||||
* Integration tests for the worktree DB copy and reconcile hooks.
|
||||
* Uses real temp git repos and real SQLite databases.
|
||||
*
|
||||
* Test cases:
|
||||
* 1. Copy: createAutoWorktree seeds .gsd/gsd.db into the worktree when main has one
|
||||
* 2. Copy-skip: createAutoWorktree silently skips when main has no gsd.db
|
||||
* 3. Reconcile: reconcileWorktreeDb merges worktree rows into main DB
|
||||
* 4. Reconcile-skip: reconcileWorktreeDb is non-fatal when both paths are nonexistent
|
||||
* 5. Failure path: reconcileWorktreeDb emits to stderr on open failure (observable)
|
||||
*/
|
||||
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
import { createAutoWorktree } from "../auto-worktree.ts";
|
||||
import { worktreePath } from "../worktree-manager.ts";
|
||||
import {
|
||||
copyWorktreeDb,
|
||||
reconcileWorktreeDb,
|
||||
openDatabase,
|
||||
closeDatabase,
|
||||
upsertDecision,
|
||||
getActiveDecisions,
|
||||
isDbAvailable,
|
||||
} from "../gsd-db.ts";
|
||||
|
||||
import { createTestContext } from "./test-helpers.ts";
|
||||
|
||||
const { assertEq, assertTrue, report } = createTestContext();
|
||||
|
||||
function run(command: string, cwd: string): string {
|
||||
return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
|
||||
}
|
||||
|
||||
function createTempRepo(): string {
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), "wt-db-int-test-")));
|
||||
run("git init", dir);
|
||||
run("git config user.email test@test.com", dir);
|
||||
run("git config user.name Test", dir);
|
||||
writeFileSync(join(dir, "README.md"), "# test\n");
|
||||
run("git add .", dir);
|
||||
run("git commit -m init", dir);
|
||||
run("git branch -M main", dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const savedCwd = process.cwd();
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = realpathSync(mkdtempSync(join(tmpdir(), "wt-db-int-")));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
// ─── Test 1: copy on worktree creation ───────────────────────────
|
||||
console.log("\n=== Test 1: copy on worktree creation ===");
|
||||
{
|
||||
const tempDir = createTempRepo();
|
||||
tempDirs.push(tempDir);
|
||||
|
||||
// Seed a gsd.db in the main repo
|
||||
const gsdDir = join(tempDir, ".gsd");
|
||||
mkdirSync(gsdDir, { recursive: true });
|
||||
const mainDbPath = join(gsdDir, "gsd.db");
|
||||
openDatabase(mainDbPath);
|
||||
closeDatabase();
|
||||
|
||||
// Commit so createAutoWorktree can copy planning artifacts
|
||||
run("git add .", tempDir);
|
||||
run('git commit -m "add gsd dir"', tempDir);
|
||||
|
||||
// createAutoWorktree should copy the DB into the worktree
|
||||
const wtPath = createAutoWorktree(tempDir, "M004");
|
||||
|
||||
const worktreeDbPath = join(worktreePath(tempDir, "M004"), ".gsd", "gsd.db");
|
||||
assertTrue(
|
||||
existsSync(worktreeDbPath),
|
||||
"gsd.db exists in worktree .gsd after createAutoWorktree",
|
||||
);
|
||||
|
||||
// Restore cwd for next test
|
||||
process.chdir(savedCwd);
|
||||
}
|
||||
|
||||
// ─── Test 2: copy skip when no source DB ─────────────────────────
|
||||
console.log("\n=== Test 2: copy skip when no source DB ===");
|
||||
{
|
||||
const tempDir = createTempRepo();
|
||||
tempDirs.push(tempDir);
|
||||
|
||||
// No gsd.db — just a bare repo
|
||||
let threw = false;
|
||||
let wtPath: string | null = null;
|
||||
try {
|
||||
wtPath = createAutoWorktree(tempDir, "M004");
|
||||
} catch (err) {
|
||||
threw = true;
|
||||
console.error(" Unexpected throw:", err);
|
||||
}
|
||||
|
||||
assertTrue(!threw, "createAutoWorktree does not throw when no source DB");
|
||||
|
||||
const worktreeDbPath = join(worktreePath(tempDir, "M004"), ".gsd", "gsd.db");
|
||||
assertTrue(
|
||||
!existsSync(worktreeDbPath),
|
||||
"gsd.db is absent in worktree when source had none",
|
||||
);
|
||||
|
||||
process.chdir(savedCwd);
|
||||
}
|
||||
|
||||
// ─── Test 3: reconcile inserts worktree rows into main ───────────
|
||||
console.log("\n=== Test 3: reconcile merges worktree rows into main ===");
|
||||
{
|
||||
const mainDbPath = join(makeTempDir(), "main.db");
|
||||
const worktreeDbPath = join(makeTempDir(), "wt.db");
|
||||
|
||||
// Seed main DB (empty schema)
|
||||
openDatabase(mainDbPath);
|
||||
closeDatabase();
|
||||
|
||||
// Seed worktree DB with one decision
|
||||
openDatabase(worktreeDbPath);
|
||||
upsertDecision({
|
||||
id: "D-WT-001",
|
||||
when_context: "integration test",
|
||||
scope: "test",
|
||||
decision: "use reconcile",
|
||||
choice: "reconcile on merge",
|
||||
rationale: "test coverage",
|
||||
revisable: "no",
|
||||
superseded_by: null,
|
||||
});
|
||||
closeDatabase();
|
||||
|
||||
// Reconcile worktree → main
|
||||
const result = reconcileWorktreeDb(mainDbPath, worktreeDbPath);
|
||||
assertTrue(result.decisions >= 1, "reconcile reports at least 1 decision merged");
|
||||
|
||||
// Open main DB and verify the row is present
|
||||
openDatabase(mainDbPath);
|
||||
const decisions = getActiveDecisions();
|
||||
closeDatabase();
|
||||
|
||||
const found = decisions.some((d) => d.id === "D-WT-001");
|
||||
assertTrue(found, "worktree decision D-WT-001 present in main DB after reconcile");
|
||||
}
|
||||
|
||||
// ─── Test 4: reconcile non-fatal when both paths nonexistent ─────
|
||||
console.log("\n=== Test 4: reconcile non-fatal on nonexistent paths ===");
|
||||
{
|
||||
let threw = false;
|
||||
try {
|
||||
reconcileWorktreeDb("/nonexistent/path/gsd.db", "/also/nonexistent/gsd.db");
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
assertTrue(!threw, "reconcileWorktreeDb does not throw when worktree DB is absent");
|
||||
}
|
||||
|
||||
// ─── Test 5: failure path observable via stderr (diagnostic) ─────
|
||||
// reconcileWorktreeDb emits to stderr on reconciliation failures.
|
||||
// We can't easily intercept stderr in this test harness, but we verify
|
||||
// that the function returns the zero-result shape (not undefined/throws)
|
||||
// when the worktree DB is missing — confirming the failure path is non-fatal
|
||||
// and returns a structured result.
|
||||
console.log("\n=== Test 5: reconcile returns zero-shape when worktree DB absent ===");
|
||||
{
|
||||
const mainDbPath = join(makeTempDir(), "main2.db");
|
||||
openDatabase(mainDbPath);
|
||||
closeDatabase();
|
||||
|
||||
const result = reconcileWorktreeDb(mainDbPath, "/definitely/does/not/exist.db");
|
||||
assertEq(result.decisions, 0, "decisions is 0 when worktree DB absent");
|
||||
assertEq(result.requirements, 0, "requirements is 0 when worktree DB absent");
|
||||
assertEq(result.artifacts, 0, "artifacts is 0 when worktree DB absent");
|
||||
assertEq(result.conflicts.length, 0, "conflicts is empty when worktree DB absent");
|
||||
}
|
||||
|
||||
} finally {
|
||||
// Always restore cwd
|
||||
process.chdir(savedCwd);
|
||||
// Ensure DB is closed
|
||||
if (isDbAvailable()) closeDatabase();
|
||||
// Remove all temp dirs
|
||||
for (const dir of tempDirs) {
|
||||
if (existsSync(dir)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
report();
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
|
||||
import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
|
||||
import { join, resolve, sep } from "node:path";
|
||||
import { gsdRoot } from "./paths.js";
|
||||
import { GSDError, GSD_PARSE_ERROR, GSD_STALE_STATE, GSD_LOCK_HELD, GSD_GIT_ERROR, GSD_MERGE_CONFLICT } from "./errors.js";
|
||||
import {
|
||||
nativeBranchDelete,
|
||||
|
|
@ -100,7 +101,7 @@ export function resolveGitDir(basePath: string): string {
|
|||
}
|
||||
|
||||
export function worktreesDir(basePath: string): string {
|
||||
return join(basePath, ".gsd", "worktrees");
|
||||
return join(gsdRoot(basePath), "worktrees");
|
||||
}
|
||||
|
||||
export function worktreePath(basePath: string, name: string): string {
|
||||
|
|
@ -193,7 +194,7 @@ export function listWorktrees(basePath: string): WorktreeInfo[] {
|
|||
const seenRoots = new Set<string>();
|
||||
const worktreeRoots = baseVariants
|
||||
.map(baseVariant => {
|
||||
const path = join(baseVariant, ".gsd", "worktrees");
|
||||
const path = join(gsdRoot(baseVariant), "worktrees");
|
||||
return {
|
||||
normalized: normalizePathForComparison(path),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
* SLICE_BRANCH_RE) remain for backwards compatibility with legacy branches.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, utimesSync } from "node:fs";
|
||||
import { existsSync, lstatSync, readFileSync, utimesSync } from "node:fs";
|
||||
import { join, resolve, sep } from "node:path";
|
||||
|
||||
import { GitServiceImpl, writeIntegrationBranch, type TaskCommitContext } from "./git-service.js";
|
||||
|
|
@ -72,6 +72,25 @@ export function captureIntegrationBranch(basePath: string, milestoneId: string,
|
|||
* Returns null if not inside a GSD worktree (.gsd/worktrees/<name>/).
|
||||
*/
|
||||
export function detectWorktreeName(basePath: string): string | null {
|
||||
// Primary: use git metadata — .git file with gitdir: pointer
|
||||
const gitPath = join(basePath, ".git");
|
||||
try {
|
||||
const stat = lstatSync(gitPath);
|
||||
if (stat.isFile()) {
|
||||
const content = readFileSync(gitPath, "utf-8").trim();
|
||||
if (content.startsWith("gitdir:")) {
|
||||
const gitdir = content.slice(7).trim();
|
||||
// Git worktree gitdir format: <repo>/.git/worktrees/<name>
|
||||
const parts = gitdir.replace(/\\/g, "/").split("/");
|
||||
const wtIdx = parts.lastIndexOf("worktrees");
|
||||
if (wtIdx !== -1 && wtIdx < parts.length - 1) {
|
||||
return parts[wtIdx + 1] || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
|
||||
// Fallback: path-based detection for legacy setups
|
||||
const normalizedPath = basePath.replaceAll("\\", "/");
|
||||
const marker = "/.gsd/worktrees/";
|
||||
const idx = normalizedPath.indexOf(marker);
|
||||
|
|
@ -90,14 +109,32 @@ export function detectWorktreeName(basePath: string): string | null {
|
|||
* operate against the real project root, not a worktree subdirectory.
|
||||
*/
|
||||
export function resolveProjectRoot(basePath: string): string {
|
||||
// Primary: use git metadata to resolve the main worktree root
|
||||
const gitPath = join(basePath, ".git");
|
||||
try {
|
||||
const stat = lstatSync(gitPath);
|
||||
if (stat.isFile()) {
|
||||
const content = readFileSync(gitPath, "utf-8").trim();
|
||||
if (content.startsWith("gitdir:")) {
|
||||
const gitdir = resolve(basePath, content.slice(7).trim());
|
||||
// Git worktree gitdir: <repo>/.git/worktrees/<name>
|
||||
// Walk up to <repo>
|
||||
const parts = gitdir.replace(/\\/g, "/").split("/");
|
||||
const wtIdx = parts.lastIndexOf("worktrees");
|
||||
if (wtIdx >= 2 && parts[wtIdx - 1] === ".git") {
|
||||
return parts.slice(0, wtIdx - 1).join("/");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
|
||||
// Fallback: legacy path-based detection
|
||||
const normalizedPath = basePath.replaceAll("\\", "/");
|
||||
const marker = "/.gsd/worktrees/";
|
||||
const idx = normalizedPath.indexOf(marker);
|
||||
if (idx === -1) return basePath;
|
||||
// Return the original path up to the .gsd/ marker (un-normalized)
|
||||
// Account for potential OS-specific separators
|
||||
const sep = basePath.includes("\\") ? "\\" : "/";
|
||||
const markerOs = `${sep}.gsd${sep}worktrees${sep}`;
|
||||
const osSep = basePath.includes("\\") ? "\\" : "/";
|
||||
const markerOs = `${osSep}.gsd${osSep}worktrees${osSep}`;
|
||||
const idxOs = basePath.indexOf(markerOs);
|
||||
if (idxOs !== -1) return basePath.slice(0, idxOs);
|
||||
return basePath.slice(0, idx);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue