diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index 6c4fb52b..8e67a445 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -3663,6 +3663,7 @@ export async function removeWorktree(directory, input = {}) { if (targetCanonical === primaryCanonical) { throw new Error('Cannot remove the primary workspace'); } + const worktreeRootCanonical = await canonicalPath(context.worktreeRoot); const entries = await listWorktreeEntries(context.primaryWorktree); const matchedEntry = await (async () => { @@ -3679,6 +3680,10 @@ export async function removeWorktree(directory, input = {}) { })(); if (!matchedEntry?.worktree) { + if (targetCanonical === worktreeRootCanonical || !isInsideOrSameDirectory(worktreeRootCanonical, targetCanonical)) { + throw new Error('Cannot remove unmanaged worktree directory'); + } + const targetExists = await checkPathExists(targetDirectory); if (targetExists) { await fsp.rm(targetDirectory, { recursive: true, force: true }); diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index 96814df7..977a093b 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -9,6 +9,7 @@ import { checkoutCommit, cherryPick, getStatus, + removeWorktree, resetToCommit, resolveBaseRefForLog, revertCommit, @@ -139,6 +140,46 @@ describe('getStatus', () => { }); }); +// --------------------------------------------------------------------------- +// removeWorktree +// --------------------------------------------------------------------------- + +describe('removeWorktree', () => { + it('refuses orphan cleanup outside the managed worktree root', async () => { + if (!canRunGit()) return; + + const previousXdgDataHome = process.env.XDG_DATA_HOME; + const dataHome = createTempDir(); + process.env.XDG_DATA_HOME = dataHome; + + try { + const repo = createTempDir(); + const sentinel = createTempDir(); + const canary = path.join(sentinel, 'canary.txt'); + + runGit(repo, ['init', '-b', 'main']); + runGit(repo, ['config', 'user.email', 'test@example.com']); + runGit(repo, ['config', 'user.name', 'Test User']); + fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n'); + runGit(repo, ['add', 'README.md']); + runGit(repo, ['commit', '-m', 'Initial commit']); + fs.writeFileSync(canary, 'sentinel'); + + await expect(removeWorktree(repo, { + directory: sentinel, + deleteLocalBranch: false, + })).rejects.toThrow('Cannot remove unmanaged worktree directory'); + expect(fs.existsSync(canary)).toBe(true); + } finally { + if (previousXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME; + } else { + process.env.XDG_DATA_HOME = previousXdgDataHome; + } + } + }); +}); + // --------------------------------------------------------------------------- // checkoutCommit // ---------------------------------------------------------------------------