diff --git a/electron/fileViewer/gitDiffService.ts b/electron/fileViewer/gitDiffService.ts index 2a7e380..231de94 100644 --- a/electron/fileViewer/gitDiffService.ts +++ b/electron/fileViewer/gitDiffService.ts @@ -1,4 +1,5 @@ import { execFile } from 'node:child_process' +import { rm } from 'node:fs/promises' import path from 'node:path' import { promisify } from 'node:util' import type { @@ -42,6 +43,19 @@ function isNotWorkingTreeError(error: unknown): boolean { return `${stderr}\n${message}`.includes('is not a working tree') } +function isMissingWorktreeGitFileError(error: unknown): boolean { + const candidate = error as { stderr?: unknown; message?: unknown } + const stderr = typeof candidate.stderr === 'string' ? candidate.stderr : '' + const message = typeof candidate.message === 'string' ? candidate.message : '' + const output = `${stderr}\n${message}` + + return ( + output.includes('validation failed, cannot remove working tree:') && + output.includes('/.git') && + (output.includes('does not exist') || output.includes('is not a file')) + ) +} + export class GitDiffService { constructor(private readonly fileBufferService: FileBufferService) {} @@ -268,11 +282,18 @@ export class GitDiffService { try { await execFileAsync('git', args, { cwd }) } catch (error) { - if (!isNotWorkingTreeError(error)) { - throw error + if (isNotWorkingTreeError(error)) { + await execFileAsync('git', ['worktree', 'prune'], { cwd }) + return + } + + if (force && isMissingWorktreeGitFileError(error)) { + await rm(worktreePath, { recursive: true, force: true }) + await execFileAsync('git', ['worktree', 'prune'], { cwd }) + return } - await execFileAsync('git', ['worktree', 'prune'], { cwd }) + throw error } } diff --git a/scripts/git-worktree-status.test.mjs b/scripts/git-worktree-status.test.mjs index a594bed..2c69f08 100644 --- a/scripts/git-worktree-status.test.mjs +++ b/scripts/git-worktree-status.test.mjs @@ -113,6 +113,34 @@ test('pulling a worktree from origin fast-forwards its current branch', async () } }) +test('force-removing a worktree recovers when its .git file is missing', async () => { + const { GitDiffService } = await importBundled('../electron/fileViewer/gitDiffService.ts') + const root = await mkdtemp(join(tmpdir(), 'terminay-git-worktree-remove-test-')) + const main = join(root, 'project') + const broken = join(root, 'project-broken') + + try { + await mkdir(main) + await git(['init', '-b', 'main'], main) + await git(['config', 'user.email', 'test@example.invalid'], main) + await git(['config', 'user.name', 'Terminay Test'], main) + await writeFile(join(main, 'shared.txt'), 'base\n') + await git(['add', 'shared.txt'], main) + await git(['commit', '-m', 'initial commit'], main) + await git(['worktree', 'add', broken, '-b', 'broken'], main) + await writeFile(join(broken, 'untracked.txt'), 'discard me\n') + await rm(join(broken, '.git'), { force: true }) + + const service = new GitDiffService(fileBufferStub) + await service.removeWorktree(main, broken, true) + + assert.equal((await fileBufferStub.getFileInfo(broken)).exists, false) + assert.equal((await git(['worktree', 'list', '--porcelain'], main)).includes(broken), false) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + const fileBufferStub = { async getFileInfo(rawPath) { const normalizedPath = resolve(rawPath)