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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -56,7 +56,7 @@ INIT=$(node "$HOME/.claude/get-shit-done/bin/gsd-tools.cjs" init ingest-docs)
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
```
|
||||
|
||||
Parse `project_exists`, `planning_exists`, `has_git`, `project_path` from INIT.
|
||||
Parse `project_exists`, `planning_exists`, `has_git`, `git_worktree_root`, `in_nested_subdir`, `project_path` from INIT.
|
||||
|
||||
**Auto-detect MODE** if not set:
|
||||
- `planning_exists: true` → `MODE=merge`
|
||||
@@ -64,7 +64,12 @@ Parse `project_exists`, `planning_exists`, `has_git`, `project_path` from INIT.
|
||||
|
||||
If user passed `--mode new` but `.planning/` already exists: display warning and require explicit confirm via `AskUserQuestion` (approve-revise-abort from `references/gate-prompts.md`) before overwriting.
|
||||
|
||||
If `has_git: false` and `MODE=new`: initialize git:
|
||||
Git initialisation (Bug #3491 — never create a nested `.git` inside an existing worktree):
|
||||
|
||||
- If `has_git: true` and `in_nested_subdir: true`: do NOT run `git init`. Surface a warning that planning files will be tracked by the outer repo at `git_worktree_root`.
|
||||
- If `has_git: true` and `in_nested_subdir: false`: already at a worktree root, skip `git init`.
|
||||
- If `has_git: false` and `MODE=new`: initialize git:
|
||||
|
||||
```bash
|
||||
git init
|
||||
```
|
||||
|
||||
@@ -64,7 +64,7 @@ AGENT_SKILLS_SYNTHESIZER=$(gsd-sdk query agent-skills gsd-research-synthesizer)
|
||||
AGENT_SKILLS_ROADMAPPER=$(gsd-sdk query agent-skills gsd-roadmapper)
|
||||
```
|
||||
|
||||
Parse JSON for: `researcher_model`, `synthesizer_model`, `roadmapper_model`, `commit_docs`, `project_exists`, `has_codebase_map`, `planning_exists`, `has_existing_code`, `has_package_file`, `is_brownfield`, `needs_codebase_map`, `has_git`, `project_path`, `agents_installed`, `missing_agents`, `agent_runtime`, `agents_dir`, `required_agents`, `required_agents_installed`, `missing_required_agents`, `agent_skill_payloads_available`, `agent_skill_payload_agents`.
|
||||
Parse JSON for: `researcher_model`, `synthesizer_model`, `roadmapper_model`, `commit_docs`, `project_exists`, `has_codebase_map`, `planning_exists`, `has_existing_code`, `has_package_file`, `is_brownfield`, `needs_codebase_map`, `has_git`, `git_worktree_root`, `in_nested_subdir`, `project_path`, `agents_installed`, `missing_agents`, `agent_runtime`, `agents_dir`, `required_agents`, `required_agents_installed`, `missing_required_agents`, `agent_skill_payloads_available`, `agent_skill_payload_agents`.
|
||||
|
||||
**If `agents_installed` is false:** Display a warning before proceeding:
|
||||
```text
|
||||
@@ -117,11 +117,11 @@ All subsequent references to the project instruction file use `$INSTRUCTION_FILE
|
||||
|
||||
**If `project_exists` is true:** Error — project already initialized. Use `/gsd:progress`.
|
||||
|
||||
**If `has_git` is false:** Initialize git:
|
||||
**Git init (#3491 — never nest `.git` inside an existing worktree):**
|
||||
|
||||
```bash
|
||||
git init
|
||||
```
|
||||
- If `has_git` true and `in_nested_subdir` true: skip `git init`; warn `⚠ Initializing inside existing worktree (${git_worktree_root}); planning files will track to outer repo.`
|
||||
- If `has_git` true and `in_nested_subdir` false: skip `git init` (already at worktree root).
|
||||
- If `has_git` false: `git init`.
|
||||
|
||||
## 2. Brownfield Offer
|
||||
|
||||
|
||||
Reference in New Issue
Block a user