diff --git a/get-shit-done/bin/lib/init.cjs b/get-shit-done/bin/lib/init.cjs index 672a86484..4db7ef0c5 100644 --- a/get-shit-done/bin/lib/init.cjs +++ b/get-shit-done/bin/lib/init.cjs @@ -76,6 +76,54 @@ function withProjectRoot(cwd, result) { return result; } +/** + * Return git-worktree state for init payloads with robust nested-subdir + * detection across Windows short/long path forms and slash variants. + */ +function getInitGitState(cwd) { + const info = gitWorktreeInfoInternal(cwd); + const worktreeRoot = info.worktreeRoot; + const normalizeForCompare = (p) => { + if (typeof p !== 'string' || p.length === 0) return null; + let resolved; + try { + resolved = fs.realpathSync.native(p); + } catch { + resolved = path.resolve(p); + } + resolved = path.resolve(resolved); + if (process.platform === 'win32') { + return resolved.replace(/\//g, '\\').toLowerCase(); + } + return resolved; + }; + + let inNestedSubdir = info.inside && worktreeRoot !== null; + if (inNestedSubdir) { + const rootNorm = normalizeForCompare(worktreeRoot); + const cwdNorm = normalizeForCompare(cwd); + if (rootNorm && cwdNorm) { + if (rootNorm === cwdNorm) { + inNestedSubdir = false; + } else { + const rel = path.relative(rootNorm, cwdNorm); + const relNorm = process.platform === 'win32' ? rel.replace(/\//g, '\\') : rel; + inNestedSubdir = + relNorm !== '' && + relNorm !== '.' && + !relNorm.startsWith('..') && + !path.isAbsolute(relNorm); + } + } + } + + return { + has_git: info.inside, + git_worktree_root: worktreeRoot, + in_nested_subdir: inNestedSubdir, + }; +} + function cmdInitExecutePhase(cwd, phase, raw, options = {}) { if (!phase) { error('phase required for init execute-phase'); @@ -475,31 +523,7 @@ function cmdInitNewProject(cwd, raw) { needs_codebase_map: (hasCode || hasPackageFile) && !pathExistsInternal(cwd, '.planning/codebase'), // Git state (Bug #3491: detect parent worktree to avoid nested .git init) - ...(() => { - const info = gitWorktreeInfoInternal(cwd); - const worktreeRoot = info.worktreeRoot; - // Canonicalize both sides before comparing: on Windows the runner's - // cwd may be the 8.3 short-name form (RUNNER~1) while git's - // --show-toplevel emits the long-form path with forward slashes. - // Without canonicalization, in_nested_subdir is `true` even at the - // worktree root (bug #3491). realpathSync.native handles 8.3→long - // expansion; path.resolve normalizes separators. Wrap in try so - // a missing path falls back to the original string compare. - let inNestedSubdir = info.inside && worktreeRoot !== null && worktreeRoot !== cwd; - if (inNestedSubdir) { - try { - const canonRoot = fs.realpathSync.native(worktreeRoot); - const canonCwd = fs.realpathSync.native(cwd); - const rel = path.relative(canonRoot, canonCwd); - inNestedSubdir = rel !== '' && !rel.startsWith('..'); - } catch { /* keep raw-string compare result */ } - } - return { - has_git: info.inside, - git_worktree_root: worktreeRoot, - in_nested_subdir: inNestedSubdir, - }; - })(), + ...getInitGitState(cwd), // Enhanced search brave_search_available: hasBraveSearch, @@ -633,32 +657,7 @@ function cmdInitIngestDocs(cwd, raw) { const result = { project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'), planning_exists: fs.existsSync(planningRoot(cwd)), - ...(() => { - // Bug #3491 — see cmdInitNewProject above. Same shallow-check bug. - const info = gitWorktreeInfoInternal(cwd); - const worktreeRoot = info.worktreeRoot; - // Canonicalize both sides before comparing: on Windows the runner's - // cwd may be the 8.3 short-name form (RUNNER~1) while git's - // --show-toplevel emits the long-form path with forward slashes. - // Without canonicalization, in_nested_subdir is `true` even at the - // worktree root (bug #3491). realpathSync.native handles 8.3→long - // expansion; path.resolve normalizes separators. Wrap in try so - // a missing path falls back to the original string compare. - let inNestedSubdir = info.inside && worktreeRoot !== null && worktreeRoot !== cwd; - if (inNestedSubdir) { - try { - const canonRoot = fs.realpathSync.native(worktreeRoot); - const canonCwd = fs.realpathSync.native(cwd); - const rel = path.relative(canonRoot, canonCwd); - inNestedSubdir = rel !== '' && !rel.startsWith('..'); - } catch { /* keep raw-string compare result */ } - } - return { - has_git: info.inside, - git_worktree_root: worktreeRoot, - in_nested_subdir: inNestedSubdir, - }; - })(), + ...getInitGitState(cwd), project_path: '.planning/PROJECT.md', commit_docs: config.commit_docs, }; diff --git a/tests/bug-1974-context-exhaustion-record.test.cjs b/tests/bug-1974-context-exhaustion-record.test.cjs index 737a30d17..507bab80d 100644 --- a/tests/bug-1974-context-exhaustion-record.test.cjs +++ b/tests/bug-1974-context-exhaustion-record.test.cjs @@ -18,6 +18,7 @@ const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const { spawnSync } = require('node:child_process'); +const { cleanup } = require('./helpers.cjs'); const HOOK_PATH = path.resolve(__dirname, '..', 'hooks', 'gsd-context-monitor.js'); @@ -99,10 +100,7 @@ describe('#1974 context exhaustion auto-record', () => { }); afterEach(() => { - // Windows: AV/file-indexer/not-yet-exited fire-and-forget subprocess may - // still hold a handle on tmpDir at teardown. Match the helpers.cleanup() - // retry budget (20 × 250ms = 5s) to absorb the deferred-handle window. - fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 250 }); + cleanup(tmpDir); // Clean up bridge files try { const warnPath = path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`); diff --git a/tests/bug-3164-milestone-archive-layout.test.cjs b/tests/bug-3164-milestone-archive-layout.test.cjs index 72563bd72..e91addfb2 100644 --- a/tests/bug-3164-milestone-archive-layout.test.cjs +++ b/tests/bug-3164-milestone-archive-layout.test.cjs @@ -16,7 +16,7 @@ const { describe, test, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); +const { createTempProject, cleanup, runGsdTools, toPosixPath } = require('./helpers.cjs'); function setupMilestoneArchiveProject(tmpDir, options = {}) { const { @@ -151,6 +151,7 @@ describe('#3164 — validate consistency: milestone-archive layout', () => { const out = JSON.parse(result.output); const warnings = out.warnings || []; + const warningsPosix = warnings.map(w => toPosixPath(w)); const phase64Warnings = warnings.filter(w => w.includes('Phase 64 exists on disk but not in ROADMAP.md')); assert.deepStrictEqual( phase64Warnings, @@ -158,12 +159,12 @@ describe('#3164 — validate consistency: milestone-archive layout', () => { `Old archived milestone phase 64 should not be treated as active:\n ${phase64Warnings.join('\n ')}` ); assert.ok( - warnings.some(w => w.includes('Gap in plan numbering in milestones/v1.7-phases/65-current')), + warningsPosix.some(w => w.includes('Gap in plan numbering in milestones/v1.7-phases/65-current')), `Expected plan numbering warning from active archive root, got:\n ${warnings.join('\n ')}` ); assert.ok( - warnings.some(w => w.includes("milestones/v1.7-phases/65-current/65-01-PLAN.md: missing 'wave'")) - || warnings.some(w => w.includes("milestones/v1.7-phases/65-current/65-03-PLAN.md: missing 'wave'")), + warningsPosix.some(w => w.includes("milestones/v1.7-phases/65-current/65-01-PLAN.md: missing 'wave'")) + || warningsPosix.some(w => w.includes("milestones/v1.7-phases/65-current/65-03-PLAN.md: missing 'wave'")), `Expected frontmatter warning from active archive plans, got:\n ${warnings.join('\n ')}` ); }); diff --git a/tests/bug-3588-npm-audit-clean.test.cjs b/tests/bug-3588-npm-audit-clean.test.cjs index 20cd82a99..13b0e3cbc 100644 --- a/tests/bug-3588-npm-audit-clean.test.cjs +++ b/tests/bug-3588-npm-audit-clean.test.cjs @@ -32,23 +32,38 @@ function auditProductionVulns(cwd) { if (!fs.existsSync(path.join(cwd, 'node_modules'))) { return null; // signal "skip" to caller } - const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const isWindows = process.platform === 'win32'; + const npmCandidates = isWindows ? ['npm.cmd', 'npm'] : ['npm']; + const args = ['audit', '--omit=dev', '--json']; let out; - try { - out = execFileSync( - npmCmd, - ['audit', '--omit=dev', '--json'], - { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 } - ); - } catch (e) { - // `npm audit` exits non-zero when advisories are present; the JSON is - // still on stdout in that case. Recover and let the assertion classify. - if (e && typeof e.stdout !== 'undefined') { - out = Buffer.isBuffer(e.stdout) ? e.stdout.toString('utf-8') : String(e.stdout); - } else { - throw e; + let lastErr = null; + for (const npmCmd of npmCandidates) { + try { + out = execFileSync( + npmCmd, + args, + { + cwd, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 60_000, + shell: isWindows, + } + ); + lastErr = null; + break; + } catch (e) { + // `npm audit` exits non-zero when advisories are present; the JSON is + // still on stdout in that case. Recover and let the assertion classify. + if (e && typeof e.stdout !== 'undefined' && e.stdout !== undefined && e.stdout !== null) { + out = Buffer.isBuffer(e.stdout) ? e.stdout.toString('utf-8') : String(e.stdout); + lastErr = null; + break; + } + lastErr = e; } } + if (lastErr) throw lastErr; const parsed = JSON.parse(out); // `null` is reserved for the "node_modules missing → skip" signal above. // Any other unexpected JSON shape is a real failure of the audit harness