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:
parent
9384641b25
commit
0b36977804
2 changed files with 198 additions and 1 deletions
|
|
@ -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();
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue