fix: prevent parallel worktree path resolution from escaping to home directory (#1677)

Fixes #1676
This commit is contained in:
Vojtech Splichal 2026-03-21 15:46:44 +01:00 committed by GitHub
parent e23a27c025
commit 57b92dee43
6 changed files with 42 additions and 16 deletions

View file

@ -170,6 +170,20 @@ export function escapeStaleWorktree(base: string): string {
// base is inside .gsd/worktrees/<something> — extract the project root
const projectRoot = base.slice(0, idx);
// Guard: If the candidate project root's .gsd IS the user-level ~/.gsd,
// the string-slice heuristic matched the wrong /.gsd/ boundary. This happens
// when .gsd is a symlink into ~/.gsd/projects/<hash> and process.cwd()
// resolved through the symlink. Returning ~ would be catastrophic (#1676).
const candidateGsd = join(projectRoot, ".gsd").replaceAll("\\", "/");
const gsdHomePath = gsdHome.replaceAll("\\", "/");
if (candidateGsd === gsdHomePath || candidateGsd.startsWith(gsdHomePath + "/")) {
// Don't chdir to home — return base unchanged.
// resolveProjectRoot() in worktree.ts has the full git-file-based recovery
// and will be called by the caller (startAuto → projectRoot()).
return base;
}
try {
process.chdir(projectRoot);
} catch {

View file

@ -243,6 +243,15 @@ export function ensureGsdSymlink(projectPath: string): string {
const localGsd = join(projectPath, ".gsd");
const inWorktree = isInsideWorktree(projectPath);
// Guard: Never create a symlink at ~/.gsd — that's the user-level GSD home,
// not a project .gsd. This can happen if resolveProjectRoot() or
// escapeStaleWorktree() returned ~ as the project root (#1676).
const localGsdNormalized = localGsd.replaceAll("\\", "/");
const gsdHomePath = gsdHome.replaceAll("\\", "/");
if (localGsdNormalized === gsdHomePath) {
return localGsd;
}
// Ensure external directory exists
mkdirSync(externalPath, { recursive: true });

View file

@ -204,6 +204,9 @@ async function main(): Promise<void> {
"/real/project",
"uses GSD_PROJECT_ROOT when set",
);
delete process.env.GSD_PROJECT_ROOT;
// Without GSD_PROJECT_ROOT, direct layout still works (no ~/.gsd collision)
assertEq(
resolveProjectRoot("/some/repo"),
"/some/repo",

View file

@ -123,16 +123,15 @@ export function detectWorktreeName(basePath: string): string | null {
* operate against the real project root, not a worktree subdirectory.
*/
export function resolveProjectRoot(basePath: string): string {
const normalizedPath = basePath.replaceAll("\\", "/");
const seg = findWorktreeSegment(normalizedPath);
if (!seg) return basePath;
// Layer 1: If the coordinator passed the real project root, use it.
// Only apply this override when basePath actually looks like a worktree path.
if (process.env.GSD_PROJECT_ROOT) {
return process.env.GSD_PROJECT_ROOT;
}
const normalizedPath = basePath.replaceAll("\\", "/");
const seg = findWorktreeSegment(normalizedPath);
if (!seg) return basePath;
// Candidate root via the string-slice heuristic
const sepChar = basePath.includes("\\") ? "\\" : "/";
const gsdMarker = `${sepChar}.gsd${sepChar}`;
@ -173,7 +172,7 @@ function resolveProjectRootFromGitFile(worktreePath: string): string | null {
try {
// Walk up from the worktree path to find the .git file
let dir = worktreePath;
while (true) {
for (let i = 0; i < 10; i++) {
const gitPath = join(dir, ".git");
if (existsSync(gitPath)) {
const content = readFileSync(gitPath, "utf8").trim();

View file

@ -31,7 +31,7 @@ function findWorktreeSegment(normalizedPath) {
function resolveProjectRootFromGitFile(worktreePath) {
try {
let dir = worktreePath;
while (true) {
for (let i = 0; i < 10; i++) {
const gitPath = join(dir, ".git");
if (existsSync(gitPath)) {
const content = readFileSync(gitPath, "utf8").trim();
@ -71,15 +71,15 @@ function normalizePathForCompare(path) {
}
function resolveProjectRoot(basePath) {
const normalizedPath = basePath.replaceAll("\\", "/");
const seg = findWorktreeSegment(normalizedPath);
if (!seg) return basePath;
// Layer 1: If the coordinator passed the real project root, use it.
if (process.env.GSD_PROJECT_ROOT) {
return process.env.GSD_PROJECT_ROOT;
}
const normalizedPath = basePath.replaceAll("\\", "/");
const seg = findWorktreeSegment(normalizedPath);
if (!seg) return basePath;
const sepChar = basePath.includes("\\") ? "\\" : "/";
const gsdMarker = `${sepChar}.gsd${sepChar}`;
const gsdIdx = basePath.indexOf(gsdMarker);

View file

@ -41,7 +41,7 @@ function findWorktreeSegment(normalizedPath) {
function resolveProjectRootFromGitFile(worktreePath) {
try {
let dir = worktreePath;
while (true) {
for (let i = 0; i < 10; i++) {
const gitPath = join(dir, ".git");
if (existsSync(gitPath)) {
const content = readFileSync(gitPath, "utf8").trim();
@ -81,14 +81,15 @@ function normalizePathForCompare(path) {
}
function resolveProjectRoot(basePath) {
const normalizedPath = basePath.replaceAll("\\", "/");
const seg = findWorktreeSegment(normalizedPath);
if (!seg) return basePath;
// Layer 1: If the coordinator passed the real project root, use it.
if (process.env.GSD_PROJECT_ROOT) {
return process.env.GSD_PROJECT_ROOT;
}
const normalizedPath = basePath.replaceAll("\\", "/");
const seg = findWorktreeSegment(normalizedPath);
if (!seg) return basePath;
const sepChar = basePath.includes("\\") ? "\\" : "/";
const gsdMarker = `${sepChar}.gsd${sepChar}`;
const gsdIdx = basePath.indexOf(gsdMarker);