Skip to content

[codex] Harden v1 to v2 migration idempotency#3781

Merged
saddlepaddle merged 1 commit into
mainfrom
fix-migration-idempotency
Apr 27, 2026
Merged

[codex] Harden v1 to v2 migration idempotency#3781
saddlepaddle merged 1 commit into
mainfrom
fix-migration-idempotency

Conversation

@saddlepaddle
Copy link
Copy Markdown
Collaborator

@saddlepaddle saddlepaddle commented Apr 27, 2026

Summary

Hardens the desktop v1-to-v2 migration so reruns recover more partial-success cases without repeatedly surfacing stale skipped work as new migration activity.

  • Falls back from stale or missing v1 worktree paths to branch-based adoption before marking a workspace skipped.
  • Retries previous missing-worktree skip states so a later run can recover if the worktree becomes discoverable.
  • Keeps failed retries of already-missing worktrees out of the “new work” counters, avoiding repeated migration-summary popups on every app launch.
  • Keeps project setup non-relocating during migration; silent relocation can orphan existing v2 worktrees and remains a user-confirmed settings flow.
  • Adds regression coverage for missing worktree rows, stale worktree paths, retrying old skipped rows, non-counted failed retries, and project setup reconciliation.

Root Cause

The migration depended too strongly on v1 worktree path records and treated some skipped states as final. If those records were missing or stale, a rerun could skip a workspace even when host-service could still discover the worktree by branch. The first version of this PR also made project reconciliation relocate local project paths too broadly; that was corrected because relocation has user-visible consequences for existing worktrees.

Validation

  • bun test apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2
  • bunx tsc -p apps/desktop/tsconfig.json --noEmit
  • bunx @biomejs/biome@2.4.2 check apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.ts apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.test.ts
  • git diff --check

Summary by CodeRabbit

  • New Features

    • Project imports now support relocation mode.
  • Bug Fixes

    • Improved v1→v2 workspace migration to fall back to branch-based adoption when worktree metadata is missing or stale.
    • Migration now retries previously skipped workspaces, including those marked as "worktree_not_registered".
    • Retries can recover previously skipped items; failed retries are not double-counted and retain their original skip reason.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 27, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1f66e9ba-18e8-4716-8c1e-7474377f54d8

📥 Commits

Reviewing files that changed from the base of the PR and between 1d6e7b8 and af65edc.

📒 Files selected for processing (2)
  • apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.test.ts
  • apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.test.ts

📝 Walkthrough

Walkthrough

Migration now opportunistically uses v1 worktree paths for adoption, records path-specific NOT_FOUND skips as worktree_not_registered, retries adoption once without worktreePath on NOT_FOUND, and expands retry eligibility to include previously skipped orphan_worktree and worktree_not_registered. Tests simulate path-specific adopt failures and cover retry/fallback scenarios.

Changes

Cohort / File(s) Summary
Migration logic
apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.ts
Derives optional v1WorktreePath/v1BaseBranch from worktreesById and attempts adoption with that path; on adoption NOT_FOUND upserts worktree_not_registered via recordAdoptFailure and retries once without worktreePath; broadens shouldRetryWorkspace to include orphan_worktree and worktree_not_registered.
Tests & test helpers
apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.test.ts
Adds FakeEnv.adoptThrowsForPath to simulate path-specific adoption failures and makes workspaceCreation.adopt throw accordingly; updates project.setup typing to require mode.repoPath and optional allowRelocate; adjusts expected skip reasons to worktree_not_registered; adds tests for branch-based fallback, stale-path retry behavior, and rerun/skip recovery semantics.

Sequence Diagram

sequenceDiagram
    participant Migrator as Migration Process
    participant WorktreeStore as v1 Worktrees (worktreesById)
    participant AdoptionSvc as workspaceCreation.adopt
    participant SkipStore as skippedWorkspaces store

    Migrator->>WorktreeStore: lookup v1 worktree (id -> path, baseBranch)
    alt worktree path found
        Migrator->>AdoptionSvc: adopt(workspaceId, worktreePath, baseBranch)
        alt AdoptionSvc -> Migrator: NOT_FOUND
            Migrator->>SkipStore: upsert skipped as `worktree_not_registered`
            Migrator->>AdoptionSvc: adopt(workspaceId, baseBranch)  -- retry without path
            AdoptionSvc-->>Migrator: success / error
            Migrator->>SkipStore: record result (adopted or still skipped)
        else AdoptionSvc -> Migrator: success
            Migrator->>SkipStore: record adopted mapping
        end
    else no worktree path
        Migrator->>AdoptionSvc: adopt(workspaceId, baseBranch)  -- branch-based adoption
        AdoptionSvc-->>Migrator: success / error
        Migrator->>SkipStore: record result
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I nibbled at a stubborn path and sighed,
Tried the gate, it said "not registered" — replied!
I hopped back, then hopped without the trail,
Found a branch, then told the tale.
Migration carrots saved, I danced and pried.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: hardening v1-to-v2 migration idempotency by improving retry logic and skip handling.
Description check ✅ Passed The description covers the main changes, root cause, validation steps, and additional context, though it lacks explicit Type of Change checkbox selection and Related Issues section.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-migration-idempotency

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@saddlepaddle saddlepaddle marked this pull request as ready for review April 27, 2026 02:06
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 27, 2026

Greptile Summary

This PR hardens v1→v2 migration idempotency by: (1) passing allowRelocate: true on all project setup calls so reruns can move an existing v2 project back to the v1 repo path; (2) expanding shouldRetryWorkspace to also retry orphan_worktree and worktree_not_registered skips; and (3) replacing the hard skip for missing/stale worktree rows with a two-attempt adoption — first by worktree path, then falling back to branch-only if NOT_FOUND. All new paths are covered by four regression tests.

Confidence Score: 5/5

Safe to merge — all changes are well-scoped migration hardening with matching regression tests and no P0/P1 issues found.

The logic is correct across all edge cases: the two-attempt adoption (path → branch) handles every NOT_FOUND permutation properly, the shouldRetryWorkspace expansion is consistent with the new retry semantics, and allowRelocate: true is appropriate for the migration context. Four targeted regression tests verify the new paths. No data-loss, auth, or correctness concerns were found.

No files require special attention.

Important Files Changed

Filename Overview
apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.ts Core migration logic hardened: adds allowRelocate, expands shouldRetryWorkspace to include orphan_worktree/worktree_not_registered, removes hard skip for missing worktree rows, and adds two-attempt adoption with worktree-path-then-branch fallback.
apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.test.ts Test harness extended with adoptThrowsForPath map and allowRelocate tracking; adds four new regression tests covering missing worktree rows, stale paths, retrying old skipped rows, and relocation-enabled setup. Existing permanent-skip test flipped to verify recovery.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Workspace iteration] --> B{existing.status?}
    B -- success + v2Id --> C{hasLocalWorkspace?}
    C -- yes --> D[Emit 'synced', continue]
    C -- no --> E[recoverCompletedWorkspace = true]
    C -- throws --> F[Record error, continue]
    B -- other --> G{shouldRetryWorkspace?}
    G -- false --> H[Emit existing skipped reason, continue]
    G -- true --> I{v2ProjectId resolved?}
    E --> I
    I -- no --> J[Store parent_project_unresolved, skip]
    I -- yes --> K[Resolve v1WorktreePath from worktreesById]
    K --> L[adoptWorkspace v1WorktreePath]
    L -- success --> M[Store success, increment workspacesCreated]
    L -- NOT_FOUND && v1WorktreePath set --> N[Retry adoptWorkspace undefined / branch-only]
    N -- success --> M
    N -- throws --> O[recordAdoptFailure]
    L -- NOT_FOUND && no path --> O
    L -- other error --> O
    O -- NOT_FOUND --> P[Store worktree_not_registered, skip]
    O -- other --> Q[Store error, increment workspacesErrored]
Loading

Reviews (1): Last reviewed commit: "Harden v1 to v2 migration idempotency" | Re-trigger Greptile

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

@saddlepaddle saddlepaddle force-pushed the fix-migration-idempotency branch from bd75760 to 1d6e7b8 Compare April 27, 2026 02:18
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.ts (1)

65-74: ⚠️ Potential issue | 🟠 Major

Pass allowRelocate: true to setupProjectImport call to enable migration relocation.

The PR description explicitly states the feature is "allowing migration project reconciliation to relocate an existing local v2 project back to the v1 repo path (allowRelocate: true)", yet setupProjectImport at lines 65–74 does not pass this flag. The schema supports it with allowRelocate: z.boolean().default(false), and the test mock was widened to accept it (line 183), signaling intent.

The allowRelocate parameter is used elsewhere in the codebase:

  • ProjectLocationSection.tsx:72 passes it for user-initiated relocation
  • useFolderFirstImport.ts:86 does not (first-time import has no existing project to relocate)

For migration reconciliation to work as described, update setupProjectImport to accept and pass allowRelocate: true:

Suggested fix
 async function setupProjectImport(
 	hostService: HostServiceClient,
 	projectId: string,
 	repoPath: string,
+	allowRelocate: boolean = true,
 ): Promise<void> {
 	await hostService.project.setup.mutate({
 		projectId,
-		mode: { kind: "import", repoPath },
+		mode: { kind: "import", repoPath, allowRelocate },
 	});
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.ts`
around lines 65 - 74, The setupProjectImport function currently calls
hostService.project.setup.mutate without the allowRelocate flag; update the
function signature (setupProjectImport) to accept an allowRelocate boolean (or
default true when used for migration) and pass it into
hostService.project.setup.mutate as part of the mode payload (mode: { kind:
"import", repoPath, allowRelocate: true }) so the mutation receives
allowRelocate: true for migration reconciliation; ensure any callers (where
migration is intended) pass true or rely on the default you add.
🧹 Nitpick comments (3)
apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.test.ts (2)

183-184: Type widening to optional repoPath is broader than needed.

The mock now accepts mode: { repoPath?: string; allowRelocate?: boolean }. None of the production call sites in migrate.ts actually omit repoPath, so making it optional in the stub loosens the type contract beyond what the code under test exercises. If the goal was just to permit allowRelocate, you can keep repoPath: string required. Marginal — only relevant if a future caller drops repoPath and the test would no longer flag it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.test.ts`
around lines 183 - 184, The test stub relaxes the type for the `mode` parameter
to `{ repoPath?: string; allowRelocate?: boolean }`; change it back so
`repoPath` is required (i.e. `{ repoPath: string; allowRelocate?: boolean }`) to
match production call sites in `migrate.ts` and keep `allowRelocate` optional,
by updating the mock/signature in `migrate.test.ts` where the function parameter
is declared so the test fails if a caller omits `repoPath`.

921-977: Idempotent re-skip coverage is the missing piece — nice addition.

This locks in the wasAlreadyMissingWorktreeSkip branch behavior: a previously-skipped worktree_not_registered workspace whose retry also fails with NOT_FOUND is reported in summary.workspaces but not counted in workspacesSkipped. Without this test, a future refactor could regress and start double-counting reruns as new skips.

Two small things that would make the assertion even tighter (optional):

  • Assert env.adoptCalls length to confirm the retry loop ran exactly once (no spurious branch-only re-call on top of an already-pathless first attempt — there's no path here since v1Worktrees: [], so it should be 1).
  • Assert summary.workspaces length is 1, not just "contains" the expected entry, to guard against duplicate pushes.
Proposed tightening
 		expect(summary.workspacesCreated).toBe(0);
 		expect(summary.workspacesSkipped).toBe(0);
+		expect(env.adoptCalls).toHaveLength(1);
+		expect(summary.workspaces).toHaveLength(1);
 		expect(summary.workspaces).toContainEqual({
 			name: "workspace-w-orphan",
 			branch: "branch-w-orphan",
 			status: "skipped",
 			reason: "worktree no longer exists",
 		});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.test.ts`
around lines 921 - 977, The test "failed retry of previous missing-worktree skip
does not count as new work" is missing two tighter assertions: after calling
migrateV1DataToV2, assert env.adoptCalls.length === 1 to ensure the retry loop
invoked adopt exactly once, and assert summary.workspaces.length === 1 (instead
of only containsEqual) to ensure no duplicate entries were pushed; update the
test to include these two assertions referencing migrateV1DataToV2,
env.adoptCalls, and summary.workspaces.
apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.ts (1)

413-501: Adoption refactor with NOT_FOUND retry is well-structured.

A few things worth noting (non-blocking):

  1. Outer try wraps both the path-based attempt and the branch-based retry; recordAdoptFailure is only invoked once with the terminal error — correct, no double-recording.
  2. Retry guard trpcCode(err) !== "NOT_FOUND" || !v1WorktreePath correctly avoids a redundant re-call when the first attempt already had no path.
  3. existingWorkspaceId is preserved across the retry via the closure, so the host-service can still relink an orphaned cloud workspace on the branch-only attempt.
  4. In recordAdoptFailure, when wasAlreadyMissingWorktreeSkip(existing) is true, the workspace is pushed to summary.workspaces but workspacesSkipped is intentionally not incremented — this preserves "did nothing new" semantics on idempotent reruns and is covered by the new test at lines 921-977.

One minor optional thought: recordAdoptFailure re-writes the same worktree_not_registered row even when existing was already in that exact state. It's harmless (and arguably simpler than skipping the write), but if upsertState is non-trivial you could short-circuit when existing.status === "skipped" && existing.reason === "worktree_not_registered". Defer if you prefer the uniform write.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.ts`
around lines 413 - 501, The current recordAdoptFailure always upserts a
worktree_not_registered row even if existing already records that state;
short-circuit this by checking inside recordAdoptFailure whether
existing?.status === "skipped" && existing.reason === "worktree_not_registered"
(or reuse wasAlreadyMissingWorktreeSkip(existing)) and if so skip the
electronTrpc.migration.upsertState.mutate call and any summary mutation,
returning early; update the recordAdoptFailure function to perform this guard
before calling upsertState.mutate so you avoid redundant writes while preserving
current behavior when the condition is false.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.ts`:
- Around line 65-74: The setupProjectImport function currently calls
hostService.project.setup.mutate without the allowRelocate flag; update the
function signature (setupProjectImport) to accept an allowRelocate boolean (or
default true when used for migration) and pass it into
hostService.project.setup.mutate as part of the mode payload (mode: { kind:
"import", repoPath, allowRelocate: true }) so the mutation receives
allowRelocate: true for migration reconciliation; ensure any callers (where
migration is intended) pass true or rely on the default you add.

---

Nitpick comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.test.ts`:
- Around line 183-184: The test stub relaxes the type for the `mode` parameter
to `{ repoPath?: string; allowRelocate?: boolean }`; change it back so
`repoPath` is required (i.e. `{ repoPath: string; allowRelocate?: boolean }`) to
match production call sites in `migrate.ts` and keep `allowRelocate` optional,
by updating the mock/signature in `migrate.test.ts` where the function parameter
is declared so the test fails if a caller omits `repoPath`.
- Around line 921-977: The test "failed retry of previous missing-worktree skip
does not count as new work" is missing two tighter assertions: after calling
migrateV1DataToV2, assert env.adoptCalls.length === 1 to ensure the retry loop
invoked adopt exactly once, and assert summary.workspaces.length === 1 (instead
of only containsEqual) to ensure no duplicate entries were pushed; update the
test to include these two assertions referencing migrateV1DataToV2,
env.adoptCalls, and summary.workspaces.

In
`@apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.ts`:
- Around line 413-501: The current recordAdoptFailure always upserts a
worktree_not_registered row even if existing already records that state;
short-circuit this by checking inside recordAdoptFailure whether
existing?.status === "skipped" && existing.reason === "worktree_not_registered"
(or reuse wasAlreadyMissingWorktreeSkip(existing)) and if so skip the
electronTrpc.migration.upsertState.mutate call and any summary mutation,
returning early; update the recordAdoptFailure function to perform this guard
before calling upsertState.mutate so you avoid redundant writes while preserving
current behavior when the condition is false.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8bfed2fc-b495-4e03-928c-ce5b5adfd46a

📥 Commits

Reviewing files that changed from the base of the PR and between bd75760 and 1d6e7b8.

📒 Files selected for processing (2)
  • apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.test.ts
  • apps/desktop/src/renderer/routes/_authenticated/hooks/useMigrateV1DataToV2/migrate.ts

@saddlepaddle saddlepaddle force-pushed the fix-migration-idempotency branch from 1d6e7b8 to af65edc Compare April 27, 2026 02:27
@saddlepaddle saddlepaddle merged commit 88e2500 into main Apr 27, 2026
6 checks passed
@github-actions
Copy link
Copy Markdown
Contributor

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ⚠️ Neon database branch

Thank you for your contribution! 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant