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

@@ -19,6 +19,7 @@
*/
import { existsSync, readdirSync, statSync, type Dirent } from 'node:fs';
import { execSync } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { join, relative } from 'node:path';
import { homedir } from 'node:os';
@@ -61,6 +62,40 @@ function pathExists(base: string, relPath: string): boolean {
return existsSync(join(base, relPath));
}
/**
* Bug #3491: detect whether `base` is inside any git worktree, and if so,
* return the absolute worktree root. Mirrors the CJS `gitWorktreeInfoInternal`
* in get-shit-done/bin/lib/core.cjs — keep these two implementations behaviour-
* identical so the SDK and CJS init handlers emit the same has_git semantics.
*
* Returns { inside, worktreeRoot } — both fall back to false/null on any error
* (git unavailable, not a repo, timeout) so callers see the conservative
* default that preserves pre-fix behaviour for non-git environments.
*/
function gitWorktreeInfo(base: string): { inside: boolean; worktreeRoot: string | null } {
try {
const inside = execSync('git rev-parse --is-inside-work-tree', {
cwd: base,
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf-8',
timeout: 5000,
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
}).trim();
if (inside !== 'true') return { inside: false, worktreeRoot: null };
const root = execSync('git rev-parse --show-toplevel', {
cwd: base,
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf-8',
timeout: 5000,
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
}).trim();
return { inside: true, worktreeRoot: root || null };
} catch {
return { inside: false, worktreeRoot: null };
}
}
const NEW_PROJECT_REQUIRED_AGENTS = [
'gsd-project-researcher',
'gsd-research-synthesizer',
@@ -269,7 +304,13 @@ export const initNewProject: QueryHandler = async (_args, projectDir, workstream
needs_codebase_map:
(hasExistingCode || hasPackageFile) && !pathExists(projectDir, '.planning/codebase'),
has_git: pathExists(projectDir, '.git'),
// Bug #3491: detect parent worktree to avoid nested .git init.
has_git: (() => gitWorktreeInfo(projectDir).inside)(),
git_worktree_root: (() => gitWorktreeInfo(projectDir).worktreeRoot)(),
in_nested_subdir: (() => {
const info = gitWorktreeInfo(projectDir);
return info.inside && info.worktreeRoot !== null && info.worktreeRoot !== projectDir;
})(),
brave_search_available: hasBraveSearch,
firecrawl_available: hasFirecrawl,

View File

@@ -78,6 +78,40 @@ function pathExists(base: string, relPath: string): boolean {
return existsSync(join(base, relPath));
}
/**
* Bug #3491: detect whether `base` is inside any git worktree, and if so,
* return the absolute worktree root. Mirrors the CJS `gitWorktreeInfoInternal`
* in get-shit-done/bin/lib/core.cjs — keep these two implementations behaviour-
* identical so the SDK and CJS init handlers emit the same has_git semantics.
*
* Returns { inside, worktreeRoot } — both fall back to false/null on any error
* (git unavailable, not a repo, timeout) so callers see the conservative
* default that preserves pre-fix behaviour for non-git environments.
*/
function gitWorktreeInfo(base: string): { inside: boolean; worktreeRoot: string | null } {
try {
const inside = execSync('git rev-parse --is-inside-work-tree', {
cwd: base,
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf-8',
timeout: 5000,
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
}).trim();
if (inside !== 'true') return { inside: false, worktreeRoot: null };
const root = execSync('git rev-parse --show-toplevel', {
cwd: base,
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf-8',
timeout: 5000,
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
}).trim();
return { inside: true, worktreeRoot: root || null };
} catch {
return { inside: false, worktreeRoot: null };
}
}
/**
* Compute the canonical phase directory name for a known phase entry from the
* roadmap when no directory exists yet. Applies the project_code prefix so
@@ -1197,7 +1231,13 @@ export const initIngestDocs: QueryHandler = async (_args, projectDir) => {
const result: Record<string, unknown> = {
project_exists: pathExists(projectDir, '.planning/PROJECT.md'),
planning_exists: pathExists(projectDir, '.planning'),
has_git: pathExists(projectDir, '.git'),
// Bug #3491: detect parent worktree to avoid nested .git init.
has_git: (() => gitWorktreeInfo(projectDir).inside)(),
git_worktree_root: (() => gitWorktreeInfo(projectDir).worktreeRoot)(),
in_nested_subdir: (() => {
const info = gitWorktreeInfo(projectDir);
return info.inside && info.worktreeRoot !== null && info.worktreeRoot !== projectDir;
})(),
project_path: '.planning/PROJECT.md',
commit_docs: config.commit_docs,
};