fix: detect and remove nested .git dirs in worktree cleanup to prevent data loss (#3044)

Scaffolding tools (create-next-app, cargo init, etc.) create nested .git
directories inside worktrees. Git records these as gitlinks (mode 160000)
without .gitmodules, so worktree cleanup destroys the only copy of the
nested object database — causing permanent silent data loss.

Added findNestedGitDirs() helper that recursively scans worktree for nested
.git directories (skipping node_modules and other non-project dirs). The
removeWorktree() function now calls this before cleanup and removes any
nested .git dirs so files are tracked as regular content.

Closes #2616

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-30 16:49:54 -04:00 committed by GitHub
parent 9384641b25
commit 0b36977804
2 changed files with 198 additions and 1 deletions

View file

@ -0,0 +1,101 @@
/**
* worktree-nested-git-safety.test.ts #2616
*
* When scaffolding tools (create-next-app, cargo init, etc.) run inside a
* worktree, they create nested .git directories. Git treats these as gitlinks
* (mode 160000) without a .gitmodules entry, so the worktree cleanup destroys
* the only copy of those object databases causing permanent data loss.
*
* This test verifies that removeWorktree detects nested .git directories
* (orphaned gitlinks) and absorbs or removes them before cleanup so files
* are tracked as regular content instead of unreachable gitlink pointers.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { createTestContext } from "./test-helpers.ts";
const { assertTrue, report } = createTestContext();
const srcPath = join(import.meta.dirname, "..", "worktree-manager.ts");
const src = readFileSync(srcPath, "utf-8");
console.log("\n=== #2616: Worktree cleanup detects nested .git directories ===");
// ── Test 1: removeWorktree scans for nested .git directories ─────────
const removeWorktreeIdx = src.indexOf("export function removeWorktree");
assertTrue(removeWorktreeIdx > 0, "worktree-manager.ts exports removeWorktree");
const fnBody = src.slice(removeWorktreeIdx, removeWorktreeIdx + 5000);
const detectsNestedGit =
fnBody.includes("nested") && fnBody.includes(".git") ||
fnBody.includes("gitlink") ||
fnBody.includes("160000") ||
fnBody.includes("findNestedGitDirs") ||
fnBody.includes("nestedGitDirs");
assertTrue(
detectsNestedGit,
"removeWorktree detects nested .git directories or gitlinks (#2616)",
);
// ── Test 2: A helper function exists to find nested .git directories ──
const hasNestedGitHelper =
src.includes("findNestedGitDirs") ||
src.includes("detectNestedGitDirs") ||
src.includes("scanNestedGit") ||
src.includes("absorbNestedGit") ||
src.includes("nestedGitDirs");
assertTrue(
hasNestedGitHelper,
"worktree-manager has a helper to find nested .git directories (#2616)",
);
// ── Test 3: Nested .git dirs are absorbed or removed before cleanup ───
const absorbsOrRemoves =
fnBody.includes("absorb") ||
fnBody.includes("rmSync") && fnBody.includes("nested") ||
(fnBody.includes("nestedGitDirs") || fnBody.includes("findNestedGitDirs")) &&
(fnBody.includes("rm") || fnBody.includes("absorb") || fnBody.includes("remove"));
assertTrue(
absorbsOrRemoves,
"removeWorktree absorbs or removes nested .git dirs before cleanup (#2616)",
);
// ── Test 4: A warning is logged when nested .git dirs are found ───────
const warnsAboutNestedGit =
fnBody.includes("nested") && fnBody.includes("logWarning") ||
fnBody.includes("gitlink") && fnBody.includes("logWarning") ||
fnBody.includes("scaffold") && fnBody.includes("logWarning");
assertTrue(
warnsAboutNestedGit,
"removeWorktree warns when nested .git directories are detected (#2616)",
);
// ── Test 5: The findNestedGitDirs helper correctly identifies nested repos ──
// Verify the helper scans subdirectories but skips .gsd/, node_modules/, .git/
const helperBody = src.includes("findNestedGitDirs")
? src.slice(src.indexOf("findNestedGitDirs"))
: "";
const skipsExcludedDirs =
helperBody.includes("node_modules") ||
helperBody.includes(".gsd") ||
helperBody.includes("skip") ||
helperBody.includes("exclude");
assertTrue(
skipsExcludedDirs,
"findNestedGitDirs skips node_modules and other excluded directories (#2616)",
);
report();

View file

@ -15,7 +15,7 @@
* 4. remove() git worktree remove + branch cleanup
*/
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { join, resolve, sep } from "node:path";
import { GSDError, GSD_PARSE_ERROR, GSD_STALE_STATE, GSD_LOCK_HELD, GSD_GIT_ERROR, GSD_MERGE_CONFLICT } from "./errors.js";
@ -277,6 +277,78 @@ export function listWorktrees(basePath: string): WorktreeInfo[] {
return worktrees;
}
// ─── Nested .git Detection (#2616) ──────────────────────────────────────
//
// Scaffolding tools (create-next-app, cargo init, etc.) create nested .git
// directories inside worktrees. Git records these as gitlinks (mode 160000)
// without a .gitmodules entry — so worktree cleanup destroys the only copy
// of their object database, causing permanent silent data loss.
/** Directories to skip when scanning for nested .git dirs. */
const NESTED_GIT_SKIP_DIRS = new Set([
".git", ".gsd", "node_modules", ".next", ".nuxt", "dist", "build",
"__pycache__", ".tox", ".venv", "venv", "target", "vendor",
]);
/**
* Recursively find nested .git directories inside a worktree root.
* Returns paths to directories that contain their own .git (directory, not file).
* Skips node_modules, .gsd, and other non-project directories for performance.
*
* A nested .git *directory* (not a .git file which is a legitimate worktree
* pointer) indicates a scaffolded repo that will become an orphaned gitlink.
*/
export function findNestedGitDirs(rootPath: string): string[] {
const results: string[] = [];
function walk(dir: string, depth: number): void {
// Cap recursion depth to avoid runaway scanning
if (depth > 10) return;
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return; // Permission denied, broken symlink, etc.
}
for (const entry of entries) {
if (NESTED_GIT_SKIP_DIRS.has(entry)) continue;
const fullPath = join(dir, entry);
// Only follow real directories, not symlinks
let stat;
try {
stat = lstatSync(fullPath);
} catch {
continue;
}
if (!stat.isDirectory()) continue;
// Check if this directory contains a .git *directory* (not a .git file).
// A .git file is a worktree pointer and is legitimate.
// A .git directory is a standalone repo created by scaffolding.
const innerGit = join(fullPath, ".git");
try {
const innerStat = lstatSync(innerGit);
if (innerStat.isDirectory()) {
results.push(fullPath);
// Don't recurse into the nested repo — we found what we need
continue;
}
} catch {
// No .git here — continue scanning
}
walk(fullPath, depth + 1);
}
}
walk(rootPath, 0);
return results;
}
/**
* Remove a worktree and optionally delete its branch.
* If the process is currently inside the worktree, chdir out first.
@ -355,6 +427,30 @@ export function removeWorktree(
}
}
// Nested .git safety (#2616): detect nested .git directories created by
// scaffolding tools (create-next-app, cargo init, etc.). These produce
// gitlink entries (mode 160000) without .gitmodules — cleanup would destroy
// the only copy of the nested object database, causing permanent data loss.
// Fix: remove the nested .git dirs so git tracks the files as regular content.
const nestedGitDirs = findNestedGitDirs(resolvedWtPath);
if (nestedGitDirs.length > 0) {
for (const nestedDir of nestedGitDirs) {
const nestedGitPath = join(nestedDir, ".git");
try {
rmSync(nestedGitPath, { recursive: true, force: true });
logWarning("reconcile",
`Removed nested .git directory from scaffolded project to prevent data loss (#2616)`,
{ worktree: name, nestedRepo: nestedDir },
);
} catch {
logWarning("reconcile",
`Failed to remove nested .git directory — files may be lost as orphaned gitlink`,
{ worktree: name, nestedRepo: nestedDir },
);
}
}
}
// Remove worktree: try non-force first when submodules have changes,
// falling back to force only after submodule state has been preserved.
const useForce = hasSubmoduleChanges ? false : force;