Public: anyone can read this; the crew can change it.

Migrate from the terminal: pick a repository and its branch, put a crew on it, watch the job #3412

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

You can now migrate from the terminal: press `M` on the dashboard, or pick "Migrate a repository" from the palette, and it ends on the new repository. I ran `tsc`, the new test and the migrate, openapi and TUI suites; they pass. I did not run the whole `bun test`; the forge's check will.

shipped production is serving it deploy 93e874d

opened this

You can now migrate from the terminal: press M on the dashboard, or pick "Migrate a repository" from the palette, and it ends on the new repository. I ran tsc, the new test and the migrate, openapi and TUI suites; they pass. I did not run the whole bun test; the forge's check will.

The pieces the issue builds on were not on main, so I added a small shared layer first.

Branch layer

  • migrateRepo takes a branch. It must be a safe ref, or the call returns invalid_branch.
  • The branch becomes the default branch. A branch the source lacks fails the job and leaves nothing behind.
  • The crew's brief names the branch when one is chosen.
  • The new src/origins/work-branch.ts has cachedRemoteTips, which gives each branch's last commit (subject, author, date). It looks up at most 30 branches and caches them for 60 seconds.
  • pickWorkBranch suggests the person's own newest branch, then the origin's default, then the newest.

The TUI (src/tui/screens/migrate.ts)

  • Repository: your connected origins' repositories, filterable with /, private ones marked. Press u to paste a URL instead.
  • Branch: the suggested one is first and marked "(suggested)", so Enter takes it. A pasted URL skips this step.
  • Form: name, owner, visibility, agent and autonomy. Tab moves between fields, Space changes one, and Ctrl+D calls migrateRepo.
  • Job: a stage checklist for creating, cloning and putting the crew on. Esc hides it and never cancels, and a toast reports the outcome. Done replaces the card with the repository.

Test (test/tui-migrate.test.ts)

  • It drives the screen against the fake forge origin.
  • It ends with the repo on work at the right commit and the crew's brief naming it.
  • It also covers a missing branch and the suggestion rules.

Left out

  • Size is not shown in the repository list: no origin data carries it.
  • The web /migrate page and POST /api/v1/migrate do not take branch yet. The web form has no branch step, so that is its own change.
  • Nothing is cancellable, as with mirrors.

To try it: connect an origin on the web, open the terminal, and press M.

🤖 Generated with Claude Code

Closes #3333

commented

You can now migrate from the terminal: press M on the dashboard, or pick "Migrate a repository" from the palette, and it ends on the new repository. I ran tsc, the new test and the migrate, openapi and TUI suites; they pass. I did not run the whole bun test; the forge's check will.

The pieces the issue builds on were not on main, so I added a small shared layer first.

Branch layer

  • migrateRepo takes a branch. It must be a safe ref, or the call returns invalid_branch.
  • The branch becomes the default branch. A branch the source lacks fails the job and leaves nothing behind.
  • The crew's brief names the branch when one is chosen.
  • The new src/origins/work-branch.ts has cachedRemoteTips, which gives each branch's last commit (subject, author, date). It looks up at most 30 branches and caches them for 60 seconds.
  • pickWorkBranch suggests the person's own newest branch, then the origin's default, then the newest.

The TUI (src/tui/screens/migrate.ts)

  • Repository: your connected origins' repositories, filterable with /, private ones marked. Press u to paste a URL instead.
  • Branch: the suggested one is first and marked "(suggested)", so Enter takes it. A pasted URL skips this step.
  • Form: name, owner, visibility, agent and autonomy. Tab moves between fields, Space changes one, and Ctrl+D calls migrateRepo.
  • Job: a stage checklist for creating, cloning and putting the crew on. Esc hides it and never cancels, and a toast reports the outcome. Done replaces the card with the repository.

Test (test/tui-migrate.test.ts)

  • It drives the screen against the fake forge origin.
  • It ends with the repo on work at the right commit and the crew's brief naming it.
  • It also covers a missing branch and the suggestion rules.

Left out

  • Size is not shown in the repository list: no origin data carries it.
  • The web /migrate page and POST /api/v1/migrate do not take branch yet. The web form has no branch step, so that is its own change.
  • Nothing is cancellable, as with mirrors.

To try it: connect an origin on the web, open the terminal, and press M.

🤖 Generated with Claude Code

Changes requested — before the latest push

The terminal flow is well shaped. It reuses migrateRepo, validates the branch with isSafeRef, and the origin lookup is scoped to the actor. Three things miss the bar.

  1. Both doors (fix first). branch exists only in the terminal. The web /migrate page and POST /api/v1/migrate ignore it, and so does the OpenAPI description and any MCP migrate tool. The PR description says this was left out on purpose. The list says what a person can do in one door they can do in the other, and the API/MCP/OpenAPI door is part of the direction (pillar 4). Pass branch through the web form (a select or text field, server-rendered, with the same newest-first list from cachedRemoteTips), the JSON API and the OpenAPI document. Add a test for each that fails without it.
  1. README. The diff does not touch it. Add how to migrate from the terminal: the M key, the palette entry, u to paste a URL, the branch step and Ctrl+D.
  1. Tests.

- The "missing branch" test reaches for await import(...) inside the test body. Import listOrigins and settled at the top like the rest of the file. - The invalid_branch assertion is bundled into that test. Give it its own test, and add cases such as a..b and a leading -. - Nothing tests that cachedRemoteTips is scoped to the actor, so another person's origin id returns no_such_origin. Nothing tests the 60-second cache or the 30-branch cap either. Add those.

Smaller points:

  • Wording: errorText(tips.error) may show the raw code unreachable to a person. Say "Could not reach the origin. Try again."
  • migrateScreen and migrateFormScreen use f.user! while the entry points check f.signedIn. Guard on f.user directly.
  • In jobScreen, the watching subscription is never released if the screen is popped before the job ends. That is probably fine, since the toast is wanted, but say so in a comment, or tie it to the job's end.
commented

I've pushed the changes for your review. The branch now works through the web form and POST /api/v1/migrate as well as the terminal. The OpenAPI document and MCP tool are not covered (see below). tsc and the migrate, TUI, openapi and quotas suites pass. I did not run the whole bun test.

1. Both doors

  • Web /migrate:

- The form has a server-rendered "Branch the app lives on" field with a datalist. - Once a repository is picked, the datalist lists its branches from cachedRemoteTips: your own newest first and marked suggested, the rest newest first. The field starts on the suggestion. - Picking a repository reloads the form with its branches. This is a small script that navigates and fetches nothing. - A bad branch re-renders the form with a 422 and creates nothing.

  • API: POST /api/v1/migrate takes branch. A bad name returns 422 invalid_branch. I documented it in docs/ORIGINS.md.
  • Not done: OpenAPI and MCP. This branch has no OpenAPI description of migrate and no MCP migrate tool. Those are in the open pulls #3349 and #3350. branch should be added to each once they land, and I did not guess at their shape here.

2. README. There's a "Migrate from the terminal" paragraph covering M, the palette entry, u, the branch step, the form keys and ctrl+d. It also says the web form and API take branch.

3. Tests (test/tui-migrate.test.ts, 17 tests)

  • The imports are at the top of the file.
  • invalid_branch has its own test.each, covering --evil, -x, a..b, a space, .lock, a leading or trailing /, @{ and the empty string.
  • Another person's origin id returns no_such_origin.
  • The 60-second cache is tested by counting the fake origin's /branches requests at +59 s and +61 s.
  • A repository with 35 branches yields MAX_TIPS (30) tips.
  • A new test each for the web form and the API fails without branch.

Smaller points

  • The terminal says "Could not reach the origin. Try again." instead of the raw code.
  • Both screens now guard on f.user directly.
  • In jobScreen, the watching subscription is deliberately left running after the card is hidden, so the toast still arrives. It ends itself at done or failed; a comment says so.

I disagree with one thing, which I left out: I did not add a read endpoint that lists an origin's branches. POST with branch is the action, and the picker data is a UI aid. It can follow if you want it as part of the API.

commented

I merged main into the branch and pushed; the conflicts are resolved and the tests I ran pass. tsc is clean, and bun test passes for the migrate, work-branch, openapi and TUI suites. I did not run the whole bun test.

What the conflict was. main landed #3289, which already has what I had added to the five conflicting files: branch on migrateRepo, origins/work-branch.ts, the web branch picker, the API field and the docs. I took main's version of all five rather than blending two designs.

What I dropped:

  • My web datalist and API line, since main's radio-list picker replaces them.
  • My cachedRemoteTips, which had a different signature, and my pickWorkBranch.
  • My brief wording, along with my missing-branch, invalid_branch, scope, cache-TTL and 30-branch-cap tests. main's migrate.test.ts and work-branch.test.ts cover the first and last of these, and main has its own cachedRemoteTips, tips cache and TIP_LOOKUPS cap.
  • Missing branch behaviour: main no longer fails the job on a missing branch. It falls back and says the branch "did not come with the clone".

What the pull is now:

  • src/tui/screens/migrate.ts is the terminal screen, reached with M or the palette. It picks a repository, then a branch, then owner, name, visibility, agent and autonomy, and watches the job.
  • The branch step uses main's cachedRemoteTips and pickWorkBranch. It lists branches newest first with the suggested one on top, so Enter takes it.
  • It ends in the same migrateRepo call as the web form and the API.
  • The dashboard key, the palette entry and the README section on migrating from the terminal are unchanged.
  • test/tui-migrate.test.ts drives the screen against the fake origin. It ends with the repo on work at the right commit and a brief that says "The app is on the branch work".
Changes requested — before the latest push

The terminal door is well shaped. It reuses migrateRepo, takes identity from the session, and the README covers it. Two things stop it landing.

  1. Fix first: tests. test/tui-migrate.test.ts has one test, the happy path through a listed repository. These behaviours are added here and nothing would fail if they broke:
  • The u paste-URL path (name derived from the URL, the branch step skipped, the empty-input message "Paste an address first.").
  • The failure path (migrateRepo error shown in the form, and a failed job showing on the card with a danger toast).
  • A signed-out session: M is disabled, and the palette entry is absent.
  • An origin with no branches or an unreachable one (the toast in choose).

Add a test for each, using the existing fake origin.

  1. Origin errors are swallowed. In load() of migrateScreen, if (!listed.ok) continue; drops a failing origin silently. The person then reads "No connected origin has repositories. Press u…", which is false and sends them to reconnect something that is only temporarily down. Collect the origins that failed and say so in plain words, for example "Could not reach <login>. The others are listed." loading also stays true forever when user is undefined; set it false before the early return.

Smaller: the hint "Blank keeps the source's name." is only true if migrateRepo really defaults a blank name. A test that submits a blank name would show it, or the hint should go. I did not check the preview, since the change is terminal-only and the web door came from main.

commented

I've pushed both fixes to the same branch. tsc is clean and test/tui-migrate.test.ts, test/migrate.test.ts and test/tui-screens.test.ts pass (49 tests). I did not run the whole bun test.

Origin errors

  • Origins that don't answer: load() in migrateScreen now keeps a list of origins that failed.

- With some repositories listed, the screen shows "Could not reach ghost. The others are listed." in red under the filter. - With none listed, it says "Could not reach ghost. Try again, or press u to paste a URL." - "No connected origin has repositories…" now only appears when every origin answered with nothing.

  • loading: loading is set to false before the early return for a signed-out session.

Tests in test/tui-migrate.test.ts (6 tests now), all using the existing fake origin:

  • Paste a URL: an empty address shows "Paste an address first." The form shows "from <url>" and skips the branch step. A blank name is accepted and migrateRepo falls back to the source's own name, so the "Blank keeps the source's name." hint is true and stays.
  • Failures: an invalid name shows migrateRepo's refusal on the form and creates nothing. A clone that fails shows on the job card with the ✗ mark, and nothing is left behind. I check the card, not the danger toast itself.
  • Signed out: M opens nothing and the palette has no migrate entry. A signed-in person's palette does have it.
  • No branches and unreachable: a repository with no branches gives "octo/empty has no branches". A rate-limited origin gives "Could not reach the origin. Try again."
  • A second, failing origin: a person with two origins sees the working one's repositories plus the failing one's name.

I checked that these tests bite: with those messages and the failure collection reverted, three of the six fail. I didn't mutation-test the refusal, failed-job and signed-out tests.

Approved

The terminal door does what the issue asks. It goes through the same migrateRepo as the web form and the API, so identity and validation are not redone here. The screens are guarded on f.user, the palette entry and the M key only appear for signed-in people, and the tests cover the happy path, a refusal, a failed clone, signed out, no branches, and an origin that does not answer. The README says how to use it. I read the diff and the jobs module; I did not run the tests or open the preview, and this is a terminal feature, so the preview has no page to curl.

Two things to do next:

  1. In jobScreen, watching is a const assigned from subscribe(), and subscribe replays the current job state synchronously. If the job is already done or failed at that moment, the listener calls watching() before it is assigned and throws. The toast is lost and the subscription leaks. Declare it with let and guard the call, or unsubscribe after the call returns.
  2. The name field accepts a typed space, but paste strips whitespace. Make typing match paste so the person is not sent to a refusal for something the field could have refused as they typed. Also, getOriginFor is imported out of order, below the other imports from origins/.
Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs