fix(workflow): detect nested git worktree in new-project bootstrap (#3491)

The `has_git` boolean returned by `init new-project` and `init ingest-docs`
was derived from a shallow `pathExists(cwd, '.git')` check, so a subdirectory
of an existing repo reported `has_git: false`. The workflow then ran
`git init`, creating a nested `.git` inside the outer worktree and silently
diverting subsequent `gsd-sdk commit` calls into the nested repo.

Replace the shallow check with `git rev-parse --is-inside-work-tree`
semantics in both CJS (`get-shit-done/bin/lib/init.cjs`) and TS
(`sdk/src/query/init.ts`, `sdk/src/query/init-complex.ts`) handlers via a new
shared `gitWorktreeInfoInternal` helper, and expose `git_worktree_root` +
`in_nested_subdir` so the workflows can refuse `git init` inside an existing
worktree and warn that planning files will track to the outer repo.

Regression test: `tests/bug-3491-nested-git-worktree.test.cjs`.

Fixes #3491

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-14 09:11:59 -04:00
parent 49e7c48007
commit fb6633ceda
9 changed files with 347 additions and 13 deletions

View File

@@ -1535,6 +1535,48 @@ function pathExistsInternal(cwd, targetPath) {
}
}
/**
* Detect whether `cwd` sits inside a git worktree, and if so, return the
* absolute path of the worktree root.
*
* Bug #3491: the previous shallow `pathExistsInternal(cwd, '.git')` check
* only saw a `.git` entry directly in cwd, so subdirectories of an existing
* repo reported `has_git: false` and the new-project workflow then ran
* `git init` — creating a nested `.git` inside the outer repo's worktree.
*
* Mirrors `git rev-parse --is-inside-work-tree` semantics. Uses the existing
* `execGit` seam so behaviour is consistent with the rest of the toolchain
* (non-interactive env, 10s timeout, mockable in tests).
*
* Returns: { inside: boolean, worktreeRoot: string | null }
* - inside=true → cwd is somewhere inside a git worktree
* - inside=false → cwd is not inside any git worktree (or git is unavailable)
*
* Failure modes (git not installed, command times out, non-zero exit) all
* collapse to `{ inside: false, worktreeRoot: null }` — the conservative
* default that preserves pre-fix behaviour for environments without git.
*/
function gitWorktreeInfoInternal(cwd) {
try {
const insideResult = execGit(['rev-parse', '--is-inside-work-tree'], { cwd, timeout: 5000 });
if (insideResult.exitCode !== 0) {
return { inside: false, worktreeRoot: null };
}
const insideStdout = String(insideResult.stdout || '').trim();
if (insideStdout !== 'true') {
return { inside: false, worktreeRoot: null };
}
const rootResult = execGit(['rev-parse', '--show-toplevel'], { cwd, timeout: 5000 });
if (rootResult.exitCode !== 0) {
return { inside: true, worktreeRoot: null };
}
const root = String(rootResult.stdout || '').trim();
return { inside: true, worktreeRoot: root || null };
} catch {
return { inside: false, worktreeRoot: null };
}
}
function generateSlugInternal(text) {
if (!text) return null;
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 60);
@@ -1817,6 +1859,7 @@ module.exports = {
resolveTierEntry,
_resetRuntimeWarningCacheForTests,
pathExistsInternal,
gitWorktreeInfoInternal,
generateSlugInternal,
getMilestoneInfo,
getMilestonePhaseFilter,

View File

@@ -5,7 +5,7 @@
const fs = require('fs');
const path = require('path');
const { execGit, platformWriteSync, platformReadSync } = require('./shell-command-projection.cjs');
const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, normalizePhaseName, toPosixPath, output, error, checkAgentsInstalled, phaseTokenMatches } = require('./core.cjs');
const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, gitWorktreeInfoInternal, generateSlugInternal, getMilestoneInfo, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, normalizePhaseName, toPosixPath, output, error, checkAgentsInstalled, phaseTokenMatches } = require('./core.cjs');
const { planningPaths, planningDir, planningRoot } = require('./planning-workspace.cjs');
const { maskIfSecret } = require('./secrets.cjs');
const scanPhasePlans = require('./plan-scan.cjs');
@@ -458,8 +458,17 @@ function cmdInitNewProject(cwd, raw) {
is_brownfield: hasCode || hasPackageFile,
needs_codebase_map: (hasCode || hasPackageFile) && !pathExistsInternal(cwd, '.planning/codebase'),
// Git state
has_git: pathExistsInternal(cwd, '.git'),
// Git state (Bug #3491: detect parent worktree to avoid nested .git init)
...(() => {
const info = gitWorktreeInfoInternal(cwd);
const worktreeRoot = info.worktreeRoot;
const inNestedSubdir = info.inside && worktreeRoot !== null && worktreeRoot !== cwd;
return {
has_git: info.inside,
git_worktree_root: worktreeRoot,
in_nested_subdir: inNestedSubdir,
};
})(),
// Enhanced search
brave_search_available: hasBraveSearch,
@@ -593,7 +602,17 @@ function cmdInitIngestDocs(cwd, raw) {
const result = {
project_exists: pathExistsInternal(cwd, '.planning/PROJECT.md'),
planning_exists: fs.existsSync(planningRoot(cwd)),
has_git: fs.existsSync(path.join(cwd, '.git')),
...(() => {
// Bug #3491 — see cmdInitNewProject above. Same shallow-check bug.
const info = gitWorktreeInfoInternal(cwd);
const worktreeRoot = info.worktreeRoot;
const inNestedSubdir = info.inside && worktreeRoot !== null && worktreeRoot !== cwd;
return {
has_git: info.inside,
git_worktree_root: worktreeRoot,
in_nested_subdir: inNestedSubdir,
};
})(),
project_path: '.planning/PROJECT.md',
commit_docs: config.commit_docs,
};