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
Check · Review · Land · Live done — Landed and live in production.
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
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
migrateRepotakes abranch. It must be a safe ref, or the call returnsinvalid_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.tshascachedRemoteTips, which gives each branch's last commit (subject, author, date). It looks up at most 30 branches and caches them for 60 seconds. pickWorkBranchsuggests 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. Pressuto 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
workat 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
/migratepage andPOST /api/v1/migratedo not takebranchyet. 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
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.
- Both doors (fix first).
branchexists only in the terminal. The web/migratepage andPOST /api/v1/migrateignore 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). Passbranchthrough the web form (a select or text field, server-rendered, with the same newest-first list fromcachedRemoteTips), the JSON API and the OpenAPI document. Add a test for each that fails without it.
- README. The diff does not touch it. Add how to migrate from the terminal: the
Mkey, the palette entry,uto paste a URL, the branch step and Ctrl+D.
- 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 codeunreachableto a person. Say "Could not reach the origin. Try again." migrateScreenandmigrateFormScreenusef.user!while the entry points checkf.signedIn. Guard onf.userdirectly.- In
jobScreen, thewatchingsubscription 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.
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/migratetakesbranch. A bad name returns 422invalid_branch. I documented it indocs/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.
branchshould 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_branchhas its owntest.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
/branchesrequests 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.userdirectly. - In
jobScreen, thewatchingsubscription is deliberately left running after the card is hidden, so the toast still arrives. It ends itself atdoneorfailed; 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.
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 mypickWorkBranch. - My brief wording, along with my missing-branch,
invalid_branch, scope, cache-TTL and 30-branch-cap tests.main'smigrate.test.tsandwork-branch.test.tscover the first and last of these, andmainhas its owncachedRemoteTips, tips cache andTIP_LOOKUPScap. - Missing branch behaviour:
mainno 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.tsis the terminal screen, reached withMor 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'scachedRemoteTipsandpickWorkBranch. It lists branches newest first with the suggested one on top, so Enter takes it. - It ends in the same
migrateRepocall 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.tsdrives the screen against the fake origin. It ends with the repo onworkat the right commit and a brief that says "The app is on the branchwork".
The terminal door is well shaped. It reuses migrateRepo, takes identity from the session, and the README covers it. Two things stop it landing.
- 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
upaste-URL path (name derived from the URL, the branch step skipped, the empty-input message "Paste an address first."). - The failure path (
migrateRepoerror shown in the form, and a failed job showing on the card with a danger toast). - A signed-out session:
Mis 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.
- Origin errors are swallowed. In
load()ofmigrateScreen,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."loadingalso stays true forever whenuseris 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.
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()inmigrateScreennow 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:loadingis 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
migrateRepofalls 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:
Mopens 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.
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:
- In jobScreen,
watchingis 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 callswatching()before it is assigned and throws. The toast is lost and the subscription leaks. Declare it withletand guard the call, or unsubscribe after the call returns. - 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,
getOriginForis imported out of order, below the other imports from origins/.
You can now migrate from the terminal: press
Mon the dashboard, or pick "Migrate a repository" from the palette, and it ends on the new repository. I rantsc, the new test and the migrate, openapi and TUI suites; they pass. I did not run the wholebun 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
migrateRepotakes abranch. It must be a safe ref, or the call returnsinvalid_branch.src/origins/work-branch.tshascachedRemoteTips, which gives each branch's last commit (subject, author, date). It looks up at most 30 branches and caches them for 60 seconds.pickWorkBranchsuggests the person's own newest branch, then the origin's default, then the newest.The TUI (
src/tui/screens/migrate.ts)/, private ones marked. Pressuto paste a URL instead.migrateRepo.Test (
test/tui-migrate.test.ts)workat the right commit and the crew's brief naming it.Left out
/migratepage andPOST /api/v1/migratedo not takebranchyet. The web form has no branch step, so that is its own change.To try it: connect an origin on the web, open the terminal, and press
M.🤖 Generated with Claude Code
Closes #3333