From 4b45bc9fc10630dc49ef00796f82d467aec84786 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 29 Sep 2026 17:25:21 +0100 Subject: [PATCH] fix: restore archived workspaces without recorded branches (#5694) Create a fresh branch from the recorded base when available, or the repository default, so archived agents remain accessible. Cover the server recovery choices and the browser restore-to-agent flow. --- .../app/e2e/browser/worktree-restore.spec.ts | 91 ++++++++++++++++++- packages/app/src/i18n/resources/ar.ts | 4 +- packages/app/src/i18n/resources/en.ts | 4 +- packages/app/src/i18n/resources/es.ts | 4 +- packages/app/src/i18n/resources/fr.ts | 4 +- packages/app/src/i18n/resources/ja.ts | 4 +- packages/app/src/i18n/resources/ko.ts | 4 +- packages/app/src/i18n/resources/pt-BR.ts | 4 +- packages/app/src/i18n/resources/ru.ts | 4 +- packages/app/src/i18n/resources/zh-CN.ts | 4 +- .../workspace/workspace-route-state-views.tsx | 24 +++-- .../resolve-worktree-creation-intent.ts | 5 +- .../workspace-recovery-service.test.ts | 61 +++++++++++++ .../workspace-recovery-service.ts | 48 ++++++---- packages/server/src/utils/worktree.ts | 18 ++++ 15 files changed, 246 insertions(+), 37 deletions(-) diff --git a/packages/app/e2e/browser/worktree-restore.spec.ts b/packages/app/e2e/browser/worktree-restore.spec.ts index 479a69ffb8..6158906f3f 100644 --- a/packages/app/e2e/browser/worktree-restore.spec.ts +++ b/packages/app/e2e/browser/worktree-restore.spec.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { execFileSync } from "node:child_process"; -import { existsSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import path from "node:path"; import { restoreArchivedWorkspace, @@ -354,6 +355,88 @@ test.describe("Worktree restore", () => { .toBeNull(); }); + test("restores a branchless workspace so its archived agent can be used again", async ({ + page, + }) => { + const project = await openProjectViaDaemon(worktreeClient, tempRepo.path); + createdProjectIds.add(project.projectKey); + const backingWorktree = await createWorktreeViaDaemon(worktreeClient, { + cwd: tempRepo.path, + slug: `branchless-${randomUUID().slice(0, 8)}`, + }); + createdProjectIds.add(backingWorktree.projectKey); + createdWorktreeDirectories.add(backingWorktree.workspaceDirectory); + + execFileSync("git", ["switch", "--detach", "main"], { + cwd: backingWorktree.workspaceDirectory, + stdio: "pipe", + }); + const created = await worktreeClient.createWorkspace({ + source: { kind: "directory", path: backingWorktree.workspaceDirectory }, + title: "Sample workspace", + }); + if (!created.workspace) { + throw new Error(created.error ?? "Could not create the branchless workspace"); + } + const workspaceId = created.workspace.id; + const agent = await createIdleAgent(client, { + cwd: backingWorktree.workspaceDirectory, + workspaceId, + title: "Archived agent", + }); + + await archiveWorkspaceFromDaemon(worktreeClient, backingWorktree.workspaceDirectory, { + scope: "worktree", + }); + await expect + .poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 }) + .toBe(false); + await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).not.toBeNull(); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openSessions(page); + await page.getByTestId(`agent-row-${getServerId()}-${agent.id}`).click(); + await expect(page.getByText("Workspace archived", { exact: true })).toBeVisible(); + await expect( + page.getByText( + "Restore Sample workspace to return to its agents. A new branch will start from the saved base or the repository default.", + { exact: true }, + ), + ).toBeVisible(); + await captureBranchlessRestoreStep(page, "01-restore-workspace.png"); + + await page.getByRole("button", { name: "Restore", exact: true }).click(); + await expect + .poll(() => existsSync(backingWorktree.workspaceDirectory), { timeout: 30_000 }) + .toBe(true); + await expect(page.getByRole("button", { name: "Unarchive", exact: true })).toBeVisible({ + timeout: 30_000, + }); + await captureBranchlessRestoreStep(page, "02-unarchive-agent.png"); + + await page.getByRole("button", { name: "Unarchive", exact: true }).click(); + await expect.poll(() => fetchAgentArchivedAt(client, agent.id)).toBeNull(); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable(); + await captureBranchlessRestoreStep(page, "03-agent-ready.png"); + + const restoredHead = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: backingWorktree.workspaceDirectory, + encoding: "utf8", + }).trim(); + const defaultHead = execFileSync("git", ["rev-parse", "main"], { + cwd: tempRepo.path, + encoding: "utf8", + }).trim(); + expect(restoredHead).toBe(defaultHead); + expect( + execFileSync("git", ["branch", "--show-current"], { + cwd: backingWorktree.workspaceDirectory, + encoding: "utf8", + }).trim(), + ).toMatch(/^restored\//); + }); + test("restore failure stays visible and permits a successful retry", async ({ page }) => { const { agent, worktree } = await openArchivedWorkspaceFromHistory(page, "restore-retry"); const displacedProjectPath = `${tempRepo.path}-temporarily-unavailable`; @@ -422,6 +505,12 @@ test.describe("Worktree restore", () => { }); }); +async function captureBranchlessRestoreStep(page: Page, filename: string): Promise { + const screenshotDirectory = path.join(tmpdir(), "paseo-branchless-restore-evidence"); + mkdirSync(screenshotDirectory, { recursive: true }); + await page.screenshot({ path: path.join(screenshotDirectory, filename), fullPage: true }); +} + function commitRestorableChange(cwd: string): void { writeFileSync(path.join(cwd, "restored-change.txt"), "preserved after restore\n"); execFileSync("git", ["add", "restored-change.txt"], { cwd, stdio: "pipe" }); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index a418df723f..c2a1475776 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -439,7 +439,9 @@ export const ar: TranslationResources = { recovery: { archivedTitle: "مساحة العمل مؤرشفة", restoreDescription: - "تمت أرشفة {{workspaceName}} وإزالة شجرة العمل الخاصة بها. استعد الفرع {{branch}} لفتحها مجددًا.", + "استعد {{workspaceName}} للعودة إلى وكلائها. ستستخدم شجرة العمل الفرع {{branch}}.", + restoreWithoutBranchDescription: + "استعد {{workspaceName}} للعودة إلى وكلائها. سيبدأ فرع جديد من الفرع الأساسي المحفوظ أو الفرع الافتراضي للمستودع.", unarchiveDescription: "{{workspaceName}} مؤرشفة. ألغِ أرشفتها لفتحها مجددًا.", restoreAction: "استعادة", unarchiveAction: "إلغاء الأرشفة", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 09774ae914..28600d410c 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -436,7 +436,9 @@ export const en = { recovery: { archivedTitle: "Workspace archived", restoreDescription: - "{{workspaceName}} was archived and its worktree was removed. Restore branch {{branch}} to open it again.", + "Restore {{workspaceName}} to return to its agents. Its worktree will use branch {{branch}}.", + restoreWithoutBranchDescription: + "Restore {{workspaceName}} to return to its agents. A new branch will start from the saved base or the repository default.", unarchiveDescription: "{{workspaceName}} is archived. Unarchive it to open it again.", restoreAction: "Restore", unarchiveAction: "Unarchive", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 3227988de9..7b9cf848df 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -444,7 +444,9 @@ export const es: TranslationResources = { recovery: { archivedTitle: "Espacio de trabajo archivado", restoreDescription: - "{{workspaceName}} se archivó y se eliminó su worktree. Restaura la rama {{branch}} para volver a abrirlo.", + "Restaura {{workspaceName}} para volver a sus agentes. Su worktree usará la rama {{branch}}.", + restoreWithoutBranchDescription: + "Restaura {{workspaceName}} para volver a sus agentes. Se creará una rama nueva desde la base guardada o la rama predeterminada del repositorio.", unarchiveDescription: "{{workspaceName}} está archivado. Desarchívalo para volver a abrirlo.", restoreAction: "Restaurar", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index ca3343000f..15b8cc9944 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -445,7 +445,9 @@ export const fr: TranslationResources = { recovery: { archivedTitle: "Espace de travail archivé", restoreDescription: - "{{workspaceName}} a été archivé et son worktree supprimé. Restaurez la branche {{branch}} pour le rouvrir.", + "Restaurez {{workspaceName}} pour retrouver ses agents. Son worktree utilisera la branche {{branch}}.", + restoreWithoutBranchDescription: + "Restaurez {{workspaceName}} pour retrouver ses agents. Une nouvelle branche partira de la base enregistrée ou de la branche par défaut du dépôt.", unarchiveDescription: "{{workspaceName}} est archivé. Désarchivez-le pour le rouvrir.", restoreAction: "Restaurer", unarchiveAction: "Désarchiver", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 31c4c8fb61..6ccbb538b5 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -444,7 +444,9 @@ export const ja: TranslationResources = { recovery: { archivedTitle: "ワークスペースはアーカイブ済みです", restoreDescription: - "{{workspaceName}} はアーカイブされ、worktree が削除されました。ブランチ {{branch}} を復元して再度開きます。", + "{{workspaceName}} を復元してエージェントに戻ります。worktree ではブランチ {{branch}} を使用します。", + restoreWithoutBranchDescription: + "{{workspaceName}} を復元してエージェントに戻ります。保存されたベース、またはリポジトリのデフォルトブランチから新しいブランチを作成します。", unarchiveDescription: "{{workspaceName}} はアーカイブされています。再度開くにはアーカイブを解除してください。", restoreAction: "復元", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index cf5ca5ea41..76dfb606ad 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -440,7 +440,9 @@ export const ko: TranslationResources = { recovery: { archivedTitle: "워크스페이스가 보관되었습니다", restoreDescription: - "{{workspaceName}}가 보관되고 워크트리가 제거되었습니다. 다시 열려면 {{branch}} 브랜치를 복원하세요.", + "{{workspaceName}}를 복원하여 에이전트로 돌아갑니다. 워크트리는 {{branch}} 브랜치를 사용합니다.", + restoreWithoutBranchDescription: + "{{workspaceName}}를 복원하여 에이전트로 돌아갑니다. 저장된 기반 브랜치 또는 저장소 기본 브랜치에서 새 브랜치를 만듭니다.", unarchiveDescription: "{{workspaceName}}가 보관되었습니다. 다시 열려면 보관을 취소하세요.", restoreAction: "복원", unarchiveAction: "보관 취소", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 5d4e10c14e..0c747106c6 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -443,7 +443,9 @@ export const ptBR: TranslationResources = { recovery: { archivedTitle: "Workspace arquivado", restoreDescription: - "{{workspaceName}} foi arquivado e sua worktree foi removida. Restaure a branch {{branch}} para abri-lo novamente.", + "Restaure {{workspaceName}} para voltar aos seus agentes. A worktree usará a branch {{branch}}.", + restoreWithoutBranchDescription: + "Restaure {{workspaceName}} para voltar aos seus agentes. Uma nova branch partirá da base salva ou da branch padrão do repositório.", unarchiveDescription: "{{workspaceName}} está arquivado. Desarquive-o para abri-lo novamente.", restoreAction: "Restaurar", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 271eafaa28..7e3fe4f436 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -443,7 +443,9 @@ export const ru: TranslationResources = { recovery: { archivedTitle: "Рабочая область в архиве", restoreDescription: - "Рабочее пространство «{{workspaceName}}» было архивировано, а его worktree удалён. Восстановите ветку {{branch}}, чтобы снова открыть рабочее пространство.", + "Восстановите «{{workspaceName}}», чтобы вернуться к его агентам. Worktree будет использовать ветку {{branch}}.", + restoreWithoutBranchDescription: + "Восстановите «{{workspaceName}}», чтобы вернуться к его агентам. Новая ветка будет создана от сохранённой базовой или ветки репозитория по умолчанию.", unarchiveDescription: "Рабочее пространство «{{workspaceName}}» находится в архиве. Разархивируйте его, чтобы снова открыть.", restoreAction: "Восстановить", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index cdeb534e58..c54dbae802 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -439,7 +439,9 @@ export const zhCN: TranslationResources = { recovery: { archivedTitle: "Workspace 已归档", restoreDescription: - "{{workspaceName}} 已归档,其 worktree 已移除。恢复分支 {{branch}} 以重新打开。", + "恢复 {{workspaceName}} 以返回其中的代理。worktree 将使用分支 {{branch}}。", + restoreWithoutBranchDescription: + "恢复 {{workspaceName}} 以返回其中的代理。新分支将从保存的基准分支或仓库默认分支创建。", unarchiveDescription: "{{workspaceName}} 已归档。取消归档以重新打开。", restoreAction: "恢复", unarchiveAction: "取消归档", diff --git a/packages/app/src/screens/workspace/workspace-route-state-views.tsx b/packages/app/src/screens/workspace/workspace-route-state-views.tsx index 364816114f..f5f8c5e096 100644 --- a/packages/app/src/screens/workspace/workspace-route-state-views.tsx +++ b/packages/app/src/screens/workspace/workspace-route-state-views.tsx @@ -126,15 +126,21 @@ function ArchivedWorkspaceRecovery({ if (recovery.phase === "failed") { actionLabel = t("common.actions.retry"); } - const description = - recovery.recovery.action === "restore" - ? t("workspace.route.recovery.restoreDescription", { - workspaceName: recovery.recovery.workspaceName, - branch: recovery.recovery.branch, - }) - : t("workspace.route.recovery.unarchiveDescription", { - workspaceName: recovery.recovery.workspaceName, - }); + let description: string; + if (recovery.recovery.action === "restore" && recovery.recovery.branch) { + description = t("workspace.route.recovery.restoreDescription", { + workspaceName: recovery.recovery.workspaceName, + branch: recovery.recovery.branch, + }); + } else if (recovery.recovery.action === "restore") { + description = t("workspace.route.recovery.restoreWithoutBranchDescription", { + workspaceName: recovery.recovery.workspaceName, + }); + } else { + description = t("workspace.route.recovery.unarchiveDescription", { + workspaceName: recovery.recovery.workspaceName, + }); + } return ( diff --git a/packages/server/src/server/resolve-worktree-creation-intent.ts b/packages/server/src/server/resolve-worktree-creation-intent.ts index b966a6b8aa..73403f5908 100644 --- a/packages/server/src/server/resolve-worktree-creation-intent.ts +++ b/packages/server/src/server/resolve-worktree-creation-intent.ts @@ -1,7 +1,10 @@ import type { ForgeService, PullRequestCheckoutTarget } from "../services/forge-service.js"; import type { WorktreeSource } from "../utils/worktree.js"; -export type WorktreeCreationIntent = Exclude; +export type WorktreeCreationIntent = Exclude< + WorktreeSource, + { kind: "restore" | "restore-from-base" } +>; export interface ResolveWorktreeCreationIntentInput { worktreeSlug?: string; diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts index 82ffafa4d7..964f305eb8 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts @@ -357,6 +357,67 @@ test("preserves ordinary checkout behavior when no separate base was recorded", ).toMatchObject({ currentBranch: "feature", baseRef: "feature" }); }); +test("restores a branchless workspace from its recorded base so work can continue", async () => { + const fixture = await createBaseRecoveryFixture("refs/heads/release"); + execFileSync("git", ["switch", "-c", "release"], { cwd: fixture.repoDir, stdio: "pipe" }); + writeFileSync(join(fixture.repoDir, "release.txt"), "release\n"); + execFileSync("git", ["add", "release.txt"], { cwd: fixture.repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "release"], { cwd: fixture.repoDir, stdio: "pipe" }); + execFileSync("git", ["switch", "main"], { cwd: fixture.repoDir, stdio: "pipe" }); + await fixture.registry.update(fixture.workspace.workspaceId, (workspace) => ({ + ...workspace, + branch: null, + })); + await fixture.archiveAndRemove(); + + await expect(fixture.service.inspect(fixture.workspace.workspaceId)).resolves.toMatchObject({ + kind: "recoverable", + action: "restore", + branch: null, + }); + await expect(fixture.service.restore(fixture.workspace.workspaceId)).resolves.toEqual({ + workspaceId: fixture.workspace.workspaceId, + action: "restore", + }); + + const checkout = await getCheckoutStatus(fixture.workspace.cwd, { paseoHome: fixture.paseoHome }); + expect(checkout.currentBranch).toBeTruthy(); + expect(checkout.currentBranch).not.toBe("feature"); + expect( + execFileSync("git", ["rev-parse", "HEAD"], { + cwd: fixture.workspace.cwd, + encoding: "utf8", + }).trim(), + ).toBe( + execFileSync("git", ["rev-parse", "release"], { + cwd: fixture.repoDir, + encoding: "utf8", + }).trim(), + ); +}); + +test("restores a branchless workspace from the repository default when its base is missing", async () => { + const fixture = await createBaseRecoveryFixture("refs/heads/deleted-base"); + await fixture.registry.update(fixture.workspace.workspaceId, (workspace) => ({ + ...workspace, + branch: null, + })); + await fixture.archiveAndRemove(); + + await expect(fixture.service.restore(fixture.workspace.workspaceId)).resolves.toEqual({ + workspaceId: fixture.workspace.workspaceId, + action: "restore", + }); + expect( + execFileSync("git", ["rev-parse", "HEAD"], { + cwd: fixture.workspace.cwd, + encoding: "utf8", + }).trim(), + ).toBe( + execFileSync("git", ["rev-parse", "main"], { cwd: fixture.repoDir, encoding: "utf8" }).trim(), + ); +}); + test("restores the branch HEAD with a base name when the exact base is missing", async () => { const fixture = await createBaseRecoveryFixture("refs/remotes/upstream/main"); const headBeforeArchive = execFileSync("git", ["rev-parse", "HEAD"], { diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts index f40e0e8afc..8b0e9a853a 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts @@ -1,5 +1,7 @@ +import { randomUUID } from "node:crypto"; import { basename } from "node:path"; +import { resolveRepositoryDefaultBranch } from "../../../utils/checkout-git.js"; import { createRealpathAwarePathMatcher } from "../../../utils/path.js"; import { runGitCommand } from "../../../utils/run-git-command.js"; import { @@ -7,6 +9,7 @@ import { isPaseoOwnedWorktreeCwd, mapWorkspaceCwdToWorktree, rollbackCreatedPaseoWorktree, + type WorktreeSource, } from "../../../utils/worktree.js"; import { WorktreeRequestError, toWorktreeRequestError } from "../../worktree-errors.js"; import { @@ -109,15 +112,6 @@ export function createWorkspaceRecoveryService(deps: { message: "The archived workspace directory no longer exists and cannot be recreated.", }; } - if (!workspace.branch) { - return { - kind: "unavailable", - workspaceId, - reason: "worktree_branch_missing", - message: "The archived worktree has no branch recorded, so it cannot be restored.", - }; - } - // COMPAT(worktreeRestoreMissingMainRepoRoot): records created before v0.1.110 // lack placement ownership; remove the project-root fallback after 2027-01-17. const sourceRepoRoot = workspace.mainRepoRoot ?? project.rootPath; @@ -157,13 +151,13 @@ export function createWorkspaceRecoveryService(deps: { workspace: PersistedWorkspaceRecord, sourceRepoRoot: string, ): Promise { - const branch = workspace.branch; - if (!branch) { - throw new WorktreeRequestError({ - code: "unknown", - message: `Workspace ${workspace.workspaceId} has no branch to restore`, - }); - } + const source: WorktreeSource = workspace.branch + ? { kind: "restore", branchName: workspace.branch, baseRef: workspace.baseBranch } + : { + kind: "restore-from-base", + branchName: `restored/${randomUUID().slice(0, 8)}`, + baseRef: await resolveRecoveryBase(sourceRepoRoot, workspace.baseBranch), + }; try { await runGitCommand(["worktree", "prune"], { cwd: sourceRepoRoot, timeout: 30_000 }); @@ -189,7 +183,7 @@ export function createWorkspaceRecoveryService(deps: { const result = await createWorktree({ cwd: sourceRepoRoot, worktreeSlug: basename(previousWorktreePath), - source: { kind: "restore", branchName: branch, baseRef: workspace.baseBranch }, + source, runSetup: false, paseoHome: deps.paseoHome, worktreesRoot: deps.worktreesRoot, @@ -234,6 +228,26 @@ export function createWorkspaceRecoveryService(deps: { return { inspect, restore }; } +async function resolveRecoveryBase(repoRoot: string, recordedBase: string | null): Promise { + if (recordedBase) { + try { + await runGitCommand(["rev-parse", "--verify", `${recordedBase}^{commit}`], { cwd: repoRoot }); + return recordedBase; + } catch { + // The saved base was removed; use the repository default for continued work. + } + } + const defaultBranch = await resolveRepositoryDefaultBranch(repoRoot); + if (!defaultBranch) { + throw new WorktreeRequestError({ + code: "unknown", + message: + "The saved base is unavailable and the repository has no default branch to restore from.", + }); + } + return defaultBranch; +} + function createRecoveryPlan( input: | { action: "unarchive"; workspace: PersistedWorkspaceRecord } diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index ad5ba0f8b3..0fd83e8dc7 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -186,6 +186,7 @@ export type WorktreeSource = | { kind: "branch-off"; baseBranch: string; branchName: string } | { kind: "checkout-branch"; branchName: string } | { kind: "restore"; branchName: string; baseRef: string | null } + | { kind: "restore-from-base"; baseRef: string; branchName: string } | { kind: "checkout-change-request"; forge: string; @@ -1341,6 +1342,21 @@ async function resolveRestoredWorktreeSourcePlan( }; } +async function resolveRestoredWorktreeFromBasePlan( + cwd: string, + source: Extract, +): Promise { + await validateGitBranchName(cwd, source.branchName); + const baseRef = await resolveBaseBranchForWorktree(cwd, source.baseRef); + const branchName = await resolveUniqueLocalBranchName(cwd, source.branchName); + return { + branchName, + metadataBaseRefName: normalizeRequiredBaseBranch(source.baseRef), + metadataBaseRef: baseRef, + addArguments: ["-b", branchName, "--no-track", baseRef], + }; +} + async function resolveBranchOffWorktreeSourcePlan( cwd: string, source: Extract, @@ -1377,6 +1393,8 @@ async function resolveWorktreeSourcePlan({ return resolveBranchOffWorktreeSourcePlan(cwd, source, desiredSlug); case "restore": return resolveRestoredWorktreeSourcePlan(cwd, source); + case "restore-from-base": + return resolveRestoredWorktreeFromBasePlan(cwd, source); case "checkout-branch": { await validateGitBranchName(cwd, source.branchName); await ensureLocalBranch(cwd, source.branchName);