Skip to content

Commit 4645b14

Browse files
authored
fix: prevent symbolic linking to self (#900)
1 parent 8876c8f commit 4645b14

4 files changed

Lines changed: 84 additions & 39 deletions

File tree

.claude

Lines changed: 0 additions & 1 deletion
This file was deleted.

packages/git/src/sagas/worktree.ts

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
branchExists,
77
getDefaultBranch,
88
} from "../queries.js";
9+
import { safeSymlink } from "../utils.js";
910

1011
export interface CreateWorktreeInput extends GitSagaInput {
1112
worktreePath: string;
@@ -63,23 +64,29 @@ export class CreateWorktreeSaga extends GitSaga<
6364
execute: async () => {
6465
const sourceClaudeDir = path.join(baseDir, ".claude");
6566
const targetClaudeDir = path.join(worktreePath, ".claude");
66-
try {
67-
await fs.access(sourceClaudeDir);
68-
await fs.symlink(sourceClaudeDir, targetClaudeDir, "dir");
67+
const linkedDir = await safeSymlink(
68+
sourceClaudeDir,
69+
targetClaudeDir,
70+
"dir",
71+
);
72+
if (linkedDir) {
6973
await addToLocalExclude(worktreePath, ".claude", {
7074
abortSignal: signal,
7175
});
72-
} catch {}
76+
}
7377

7478
const sourceClaudeLocalMd = path.join(baseDir, "CLAUDE.local.md");
7579
const targetClaudeLocalMd = path.join(worktreePath, "CLAUDE.local.md");
76-
try {
77-
await fs.access(sourceClaudeLocalMd);
78-
await fs.symlink(sourceClaudeLocalMd, targetClaudeLocalMd, "file");
80+
const linkedFile = await safeSymlink(
81+
sourceClaudeLocalMd,
82+
targetClaudeLocalMd,
83+
"file",
84+
);
85+
if (linkedFile) {
7986
await addToLocalExclude(worktreePath, "CLAUDE.local.md", {
8087
abortSignal: signal,
8188
});
82-
} catch {}
89+
}
8390
},
8491
rollback: async () => {
8592
const targetClaudeDir = path.join(worktreePath, ".claude");
@@ -140,23 +147,29 @@ export class CreateWorktreeForBranchSaga extends GitSaga<
140147
execute: async () => {
141148
const sourceClaudeDir = path.join(baseDir, ".claude");
142149
const targetClaudeDir = path.join(worktreePath, ".claude");
143-
try {
144-
await fs.access(sourceClaudeDir);
145-
await fs.symlink(sourceClaudeDir, targetClaudeDir, "dir");
150+
const linkedDir = await safeSymlink(
151+
sourceClaudeDir,
152+
targetClaudeDir,
153+
"dir",
154+
);
155+
if (linkedDir) {
146156
await addToLocalExclude(worktreePath, ".claude", {
147157
abortSignal: signal,
148158
});
149-
} catch {}
159+
}
150160

151161
const sourceClaudeLocalMd = path.join(baseDir, "CLAUDE.local.md");
152162
const targetClaudeLocalMd = path.join(worktreePath, "CLAUDE.local.md");
153-
try {
154-
await fs.access(sourceClaudeLocalMd);
155-
await fs.symlink(sourceClaudeLocalMd, targetClaudeLocalMd, "file");
163+
const linkedFile = await safeSymlink(
164+
sourceClaudeLocalMd,
165+
targetClaudeLocalMd,
166+
"file",
167+
);
168+
if (linkedFile) {
156169
await addToLocalExclude(worktreePath, "CLAUDE.local.md", {
157170
abortSignal: signal,
158171
});
159-
} catch {}
172+
}
160173
},
161174
rollback: async () => {
162175
const targetClaudeDir = path.join(worktreePath, ".claude");

packages/git/src/utils.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,46 @@
1+
import * as fs from "node:fs/promises";
2+
import * as path from "node:path";
3+
14
export interface GitHubRepo {
25
organization: string;
36
repository: string;
47
}
58

9+
export async function safeSymlink(
10+
source: string,
11+
target: string,
12+
type: "file" | "dir",
13+
): Promise<boolean> {
14+
if (path.resolve(source) === path.resolve(target)) {
15+
return false;
16+
}
17+
18+
const sourceDir = path.dirname(path.resolve(source));
19+
const targetDir = path.dirname(path.resolve(target));
20+
if (
21+
sourceDir === targetDir &&
22+
path.basename(source) === path.basename(target)
23+
) {
24+
return false;
25+
}
26+
27+
try {
28+
await fs.access(source);
29+
} catch {
30+
return false;
31+
}
32+
33+
try {
34+
await fs.symlink(source, target, type);
35+
return true;
36+
} catch (error) {
37+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
38+
return false;
39+
}
40+
throw error;
41+
}
42+
}
43+
644
export function parseGitHubUrl(url: string): GitHubRepo | null {
745
// Trim whitespace/newlines that git commands may include
846
const trimmedUrl = url.trim();

packages/git/src/worktree.ts

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getDefaultBranch,
99
listWorktrees as listWorktreesRaw,
1010
} from "./queries.js";
11+
import { safeSymlink } from "./utils.js";
1112

1213
export interface WorktreeInfo {
1314
worktreePath: string;
@@ -370,32 +371,26 @@ export class WorktreeManager {
370371
const sourceClaudeDir = path.join(this.mainRepoPath, ".claude");
371372
const targetClaudeDir = path.join(worktreePath, ".claude");
372373

373-
try {
374-
await fs.access(sourceClaudeDir);
375-
try {
376-
await fs.symlink(sourceClaudeDir, targetClaudeDir, "dir");
377-
await addToLocalExclude(worktreePath, ".claude");
378-
} catch (error) {
379-
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
380-
throw error;
381-
}
382-
}
383-
} catch {}
374+
const linkedDir = await safeSymlink(
375+
sourceClaudeDir,
376+
targetClaudeDir,
377+
"dir",
378+
);
379+
if (linkedDir) {
380+
await addToLocalExclude(worktreePath, ".claude");
381+
}
384382

385383
const sourceClaudeLocalMd = path.join(this.mainRepoPath, "CLAUDE.local.md");
386384
const targetClaudeLocalMd = path.join(worktreePath, "CLAUDE.local.md");
387385

388-
try {
389-
await fs.access(sourceClaudeLocalMd);
390-
try {
391-
await fs.symlink(sourceClaudeLocalMd, targetClaudeLocalMd, "file");
392-
await addToLocalExclude(worktreePath, "CLAUDE.local.md");
393-
} catch (error) {
394-
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
395-
throw error;
396-
}
397-
}
398-
} catch {}
386+
const linkedFile = await safeSymlink(
387+
sourceClaudeLocalMd,
388+
targetClaudeLocalMd,
389+
"file",
390+
);
391+
if (linkedFile) {
392+
await addToLocalExclude(worktreePath, "CLAUDE.local.md");
393+
}
399394
}
400395

401396
async cleanupOrphanedWorktrees(associatedWorktreePaths: string[]): Promise<{

0 commit comments

Comments
 (0)