fix: skip external state migration inside git worktrees (#2970) (#3227)

Add isInsideWorktree() guard at the top of migrateToExternalState() so
migration never runs when basePath is a git worktree. Worktrees share
the same repoIdentity hash as the main repo, so migration would create
a junction to the wrong target and orphan .gsd.migrating.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-30 16:33:16 -04:00 committed by GitHub
parent fb2ef25250
commit 46dff43e21
2 changed files with 114 additions and 1 deletions

View file

@ -9,7 +9,7 @@
import { execFileSync } from "node:child_process";
import { existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, renameSync, cpSync, rmSync, symlinkSync } from "node:fs";
import { join } from "node:path";
import { externalGsdRoot } from "./repo-identity.js";
import { externalGsdRoot, isInsideWorktree } from "./repo-identity.js";
import { getErrorMessage } from "./error-utils.js";
import { hasGitTrackedGsdFiles } from "./gitignore.js";
import { GIT_NO_PROMPT_ENV } from "./git-constants.js";
@ -34,6 +34,14 @@ export interface MigrationResult {
* 3. On failure: rename `.gsd.migrating` back to `.gsd` (rollback)
*/
export function migrateToExternalState(basePath: string): MigrationResult {
// Worktrees get their .gsd via syncGsdStateToWorktree(), not migration.
// Migration inside a worktree would compute the same external hash as the
// main repo (externalGsdRoot hashes remoteUrl + gitRoot), creating a broken
// junction and orphaning .gsd.migrating (#2970).
if (isInsideWorktree(basePath)) {
return { migrated: false };
}
const localGsd = join(basePath, ".gsd");
// Skip if doesn't exist

View file

@ -0,0 +1,105 @@
import { describe, test, before, after } from "node:test";
import assert from "node:assert/strict";
import {
mkdtempSync,
rmSync,
writeFileSync,
existsSync,
mkdirSync,
realpathSync,
} from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import { migrateToExternalState } from "../migrate-external.ts";
function run(command: string, cwd: string): string {
return execSync(command, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf-8",
}).trim();
}
describe("migrate-external worktree guard (#2970)", () => {
let base: string;
let stateDir: string;
let worktreePath: string;
before(() => {
base = realpathSync(mkdtempSync(join(tmpdir(), "gsd-migrate-wt-")));
stateDir = realpathSync(mkdtempSync(join(tmpdir(), "gsd-state-")));
process.env.GSD_STATE_DIR = stateDir;
// Create a git repo with a remote
run("git init -b main", base);
run('git config user.name "Test"', base);
run('git config user.email "test@example.com"', base);
run('git remote add origin git@github.com:example/repo.git', base);
writeFileSync(join(base, "README.md"), "# Test\n", "utf-8");
run("git add README.md", base);
run('git commit -m "init"', base);
// Create a worktree
worktreePath = join(base, ".gsd", "worktrees", "M001");
run(`git worktree add -b milestone/M001 ${worktreePath}`, base);
// Populate worktree with a .gsd directory (simulating syncGsdStateToWorktree)
const worktreeGsd = join(worktreePath, ".gsd");
mkdirSync(worktreeGsd, { recursive: true });
writeFileSync(join(worktreeGsd, "PREFERENCES.md"), "# prefs\n", "utf-8");
});
after(() => {
delete process.env.GSD_STATE_DIR;
// Remove worktree before cleaning up
try { run(`git worktree remove --force ${worktreePath}`, base); } catch { /* ok */ }
rmSync(base, { recursive: true, force: true });
rmSync(stateDir, { recursive: true, force: true });
});
test("migrateToExternalState skips when basePath is a git worktree", () => {
// The worktree has a real .gsd directory — migration would normally run.
// But since this is a worktree, it should be skipped.
const result = migrateToExternalState(worktreePath);
assert.equal(result.migrated, false, "should not migrate inside a worktree");
assert.equal(result.error, undefined, "should not report an error");
// .gsd should still exist as a real directory (not renamed/removed)
assert.ok(
existsSync(join(worktreePath, ".gsd")),
".gsd directory should still exist after skipped migration"
);
// .gsd.migrating should NOT exist
assert.ok(
!existsSync(join(worktreePath, ".gsd.migrating")),
".gsd.migrating should not be created in a worktree"
);
});
test("migrateToExternalState still works on main repo", () => {
// Create a fresh temp repo to test main repo migration path
const mainBase = realpathSync(mkdtempSync(join(tmpdir(), "gsd-migrate-main-")));
try {
run("git init -b main", mainBase);
run('git config user.name "Test"', mainBase);
run('git config user.email "test@example.com"', mainBase);
run('git remote add origin git@github.com:example/main-repo.git', mainBase);
writeFileSync(join(mainBase, "README.md"), "# Test\n", "utf-8");
run("git add README.md", mainBase);
run('git commit -m "init"', mainBase);
// Create a .gsd directory with content
mkdirSync(join(mainBase, ".gsd"), { recursive: true });
writeFileSync(join(mainBase, ".gsd", "PREFERENCES.md"), "# prefs\n", "utf-8");
const result = migrateToExternalState(mainBase);
assert.equal(result.migrated, true, "should migrate on main repo");
} finally {
rmSync(mainBase, { recursive: true, force: true });
}
});
});