From e5704e973d3a9d315e24acaa1da3cd7cbb9da93d Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Tue, 28 Apr 2026 16:51:37 +0200 Subject: [PATCH] fix(archive): skip checkpoint when worktree was deleted externally When a workspace's worktree directory was deleted by an external process, archiving failed because CaptureCheckpointSaga ran against a non-git path and threw "fatal: not a git repository". The rollback then restored the task, producing the flash-and-reappear behavior reported in #1798. Now we probe the worktree with isGitRepository before checkpointing; if the path is missing or invalid, we log the reason, set checkpointId to null, and continue with the remaining cleanup steps. A null checkpointId naturally causes unarchive to skip worktree restoration. Generated-By: PostHog Code Task-Id: eeaa1905-3fe5-423c-a853-08d75e78947b --- .../archive/service.integration.test.ts | 14 +++++ .../code/src/main/services/archive/service.ts | 59 ++++++++++++------- 2 files changed, 52 insertions(+), 21 deletions(-) diff --git a/apps/code/src/main/services/archive/service.integration.test.ts b/apps/code/src/main/services/archive/service.integration.test.ts index f5f06d69b..50f7e05b7 100644 --- a/apps/code/src/main/services/archive/service.integration.test.ts +++ b/apps/code/src/main/services/archive/service.integration.test.ts @@ -360,6 +360,20 @@ describe("ArchiveService integration", () => { expect(archived.checkpointId).toBeTruthy(); expect(await pathExists(legacyPath)).toBe(false); })); + + it("archive succeeds when worktree was deleted externally", () => + withTestContext({}, async (ctx) => { + const { worktreePath } = await ctx.setupWorktree("detached"); + + await fs.rm(worktreePath, { recursive: true, force: true }); + expect(await pathExists(worktreePath)).toBe(false); + + const archived = await ctx.service.archiveTask(ctx.archiveInput()); + + expect(archived.checkpointId).toBeNull(); + expect(archived.branchName).toBeNull(); + expect(ctx.archiveRepo.findAll()).toHaveLength(1); + })); }); describe("local/cloud mode", () => { diff --git a/apps/code/src/main/services/archive/service.ts b/apps/code/src/main/services/archive/service.ts index 2a302fe9a..06c98e29a 100644 --- a/apps/code/src/main/services/archive/service.ts +++ b/apps/code/src/main/services/archive/service.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { createGitClient } from "@posthog/git/client"; +import { isGitRepository } from "@posthog/git/queries"; import { CaptureCheckpointSaga, deleteCheckpoint, @@ -173,31 +174,47 @@ export class ArchiveService { if (workspace.mode === "worktree" && worktree) { const worktreePath = worktree.path; - - const actualBranch = await this.getCurrentBranchName(worktreePath); - if (actualBranch && actualBranch !== "HEAD") { - archivedTask.branchName = actualBranch; - } - - await step( - async () => { - if (!archivedTask.checkpointId) { - throw new Error("checkpointId must be set for worktree mode"); - } - await this.captureWorktreeCheckpoint( - folderPath, - worktreePath, - archivedTask.checkpointId, + const worktreeIsValid = await isGitRepository(worktreePath).catch( + (error) => { + log.warn( + `Failed to check worktree at ${worktreePath}; treating as invalid`, + { error }, ); - }, - async () => { - if (archivedTask.checkpointId) { - const git = createGitClient(folderPath); - await deleteCheckpoint(git, archivedTask.checkpointId); - } + return false; }, ); + if (!worktreeIsValid) { + log.warn( + `Worktree at ${worktreePath} is missing or not a git repository; skipping checkpoint capture`, + ); + archivedTask.checkpointId = null; + } else { + const actualBranch = await this.getCurrentBranchName(worktreePath); + if (actualBranch && actualBranch !== "HEAD") { + archivedTask.branchName = actualBranch; + } + + await step( + async () => { + if (!archivedTask.checkpointId) { + throw new Error("checkpointId must be set for worktree mode"); + } + await this.captureWorktreeCheckpoint( + folderPath, + worktreePath, + archivedTask.checkpointId, + ); + }, + async () => { + if (archivedTask.checkpointId) { + const git = createGitClient(folderPath); + await deleteCheckpoint(git, archivedTask.checkpointId); + } + }, + ); + } + await step( async () => { await this.agentService.cancelSessionsByTaskId(taskId);