diff --git a/src/resources/extensions/gsd/tests/worktree-nested-git-safety.test.ts b/src/resources/extensions/gsd/tests/worktree-nested-git-safety.test.ts new file mode 100644 index 000000000..27ec1383a --- /dev/null +++ b/src/resources/extensions/gsd/tests/worktree-nested-git-safety.test.ts @@ -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(); diff --git a/src/resources/extensions/gsd/worktree-manager.ts b/src/resources/extensions/gsd/worktree-manager.ts index 16a943a33..b929e02e7 100644 --- a/src/resources/extensions/gsd/worktree-manager.ts +++ b/src/resources/extensions/gsd/worktree-manager.ts @@ -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;