Skip to content

feat(forge): let the repository panel switch git remotes - #753

Open
dawNotPoi wants to merge 6 commits into
xintaofei:mainfrom
dawNotPoi:feat/743-forge-remote-selector
Open

dawNotPoi wants to merge 6 commits into
xintaofei:mainfrom
dawNotPoi:feat/743-forge-remote-selector

Conversation

@dawNotPoi

@dawNotPoi dawNotPoi commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

The repository panel previously always read origin, so a forked checkout (gh repo fork leaves origin = your fork and upstream = the parent) could only show the fork's own issues and pull requests.

This PR adds a remote picker and completes the fork workflow end to end.

What changed

Remote selection

  • The repository panel can switch between every git remote in the folder.
  • No saved choice means the historical default, origin.
  • The selected remote is resolved centrally by folder_forge_remote_core, so forge operations follow the repository shown in the panel.
  • ForgeRemote.remote_name echoes the resolved remote so the UI can show what is in force.
  • Remote selections are deliberately separate from ForgePanelSettings.

Each folder persists its selection independently:

forge_panel_remote:<folder_id>

The frontend still receives an aggregated ForgeRemoteStore, but that store is assembled from independent rows at read time rather than persisted as one shared JSON blob. This avoids both the settings side effects and cross-folder read-modify-write races noted in the previous review.

Safe writes from stale clients

Comment, close/reopen, new-issue and merge writes carry the repository coordinates the client is showing.

The backend compares those coordinates with the folder's currently selected remote before resolving the write. If another window changed the selection, the write is refused rather than silently redirected to another repository, and the UI re-resolves the panel.

Older clients that send no expected coordinates retain the previous behavior.

Cross-fork task delivery

For the fork workflow (origin = contributor fork, selected upstream = parent):

  • the source issue/PR belongs to the selected parent;
  • the folder's origin is recorded as the writable fork at trigger time;
  • issue-task work is pushed to that fork;
  • GitHub/Gitea open a cross-repository PR using a qualified owner:branch head;
  • GitLab records the source/target project IDs required for a cross-project MR;
  • retry/recovery searches the target PR/MR collection while matching the fork head, so an interrupted delivery adopts the existing change instead of creating a duplicate;
  • PR tasks from third-party forks push back to their original head when the forge permits maintainer edits.

Remote-switch state

Changing the selected remote invalidates repository-scoped frontend state:

  • page resets to 1;
  • label filters reset;
  • list/count scopes include the repository;
  • stale in-flight responses cannot repaint rows from the previous repository;
  • identity and merge-option lookups are scoped by folder + repository;
  • write paths carry the expected repository coordinates.

Closes #743.

Validation

Added regression coverage for the issues identified in the previous review, including:

  • selected remote resolution;
  • independent per-folder remote persistence;
  • concurrent saves to different folders without lost updates;
  • settings changes not affecting the remote selection;
  • stale writes being refused instead of redirected;
  • page/label/list state reset when switching repositories;
  • cross-fork issue-task delivery;
  • retry adoption instead of duplicate PR creation;
  • GitLab cross-project delivery and retry;
  • third-party fork push-back and permission failures.

Latest CI on 98372ce5 passes the complete matrix:

  • Frontend lint + Vitest + static export build
  • Rust desktop: Ubuntu, macOS, Windows
  • Rust server: Ubuntu, macOS, Windows
  • Cargo tests and Clippy

All checks

@xintaofei

Copy link
Copy Markdown
Owner

Thanks for picking this up — and for the thorough PR description, it made reviewing much easier.

The entry point you chose is right: folder_forge_remote_core really is the single repository-resolution site, so changing it there is what makes all twelve operations follow along. Things I'd keep as-is: resolving a missing selected remote to None rather than silently falling back to origin (better than what I'd sketched myself), all ten locales actually translated, echoing remote_name so the picker can show what's in force, and wiring remoteVersion into the reset effect so a switch runs the full folder-switch teardown.

I built your branch and ran it against a codeg-server with a fork-shaped repo (origin = fork, upstream = parent). The switch works. Below is everything I found, including the things I checked and found fine, so one more pass should cover it.

I want to start with a scope question, because it affects whether the rest is worth polishing yet.

1. Cross-fork delivery — I don't think the panel switch can land on its own

Once the panel points at upstream, Start task is the reason the panel exists in a coding workbench, and for exactly the users #743 names — an outside contributor who ran gh repo fork — it fails:

  • An issue task branches from the project folder's local HEAD (the fork's branch) and then pushes its work branch to meta.owner_repo, i.e. upstream — which that contributor can't write to. It fails at delivery, after the agent has done the work.
  • A PR task reviewing someone else's upstream PR pushes back to that PR's head_repo — their fork — and fails the same way.

So on its own, this ships a panel with a button that is broken for its target audience, which reads as misleading rather than incomplete. I'd rather we cover delivery before this goes out.

The good news is that most of the plumbing is already there, and your remote picker is what supplies the missing piece: once the folder has a selected remote, origin is the fork and the selection is the source, so codeg finally knows both halves at trigger time. What's missing:

  1. Record the fork alongside the source in ForgeSourceMeta at trigger time (today only head_repo carries a "not the source repo" notion).
  2. Push the work branch to the fork rather than the source. push_branch(ctx, wt, repo, …) already takes the target as a parameter, and pull_push_repo already returns head_repo for fork PRs — including error messages that distinguish own-repo from fork. For an issue task it's the argument at the push_branch call that needs to change, not the mechanism.
  3. Open the PR cross-repo. GitHub's and Gitea's create-PR calls both post head: req.head verbatim, and both accept a qualified fork-owner:branch, so NewPullRequest.head can already carry it — no shape change. GitLab is the genuinely expensive one: create_merge_request posts source_branch against the target project, and a cross-project MR needs source_project_id, which the current struct can't express.
  4. Optional but better: branch from the source's base instead of the fork's local HEAD. fetch_ref already does exactly this for PR tasks via pr_checkout_point, so it's reuse rather than new machinery.

Given (3), one reasonable split is GitHub/Gitea first, with GitLab's cross-project MR either following in the same PR or explicitly gated (refuse the trigger with a clear message rather than failing at push time). Happy to discuss the split — and if you'd rather keep this PR to the panel and do delivery in a second one, that works too, as long as they land together.

2. Blocking — a write carries no repository, so it follows whatever is stored

The same comment request, sent twice, with only the stored setting changed in between:

POST forge_create_comment {"folderId":1,"draft":{"kind":"issue","number":12,"body":"hi"}}
stored = origin → routed to github.com
stored = elsewhere → the identical request routed to gitlab.com

Nothing in the request names a repository, and there's no broadcast when the selection changes. So any client holding a stale view — a second window, or a browser tab against the same server — posts, closes, files or merges into whatever repository the selection now names while its own header still shows the other one. Affected: forge_create_comment_core, forge_set_item_state_core, forge_create_issue_core, forge_merge_change_core.

The fix already exists in this file: work_task_create_from_forge_core carries the coordinates it believes in and checks them against what the server derives (commands/forge.rs:828-850), so in the same stale situation it returns a clean folder_mismatch instead of mis-writing — I confirmed that on your branch too.

Suggested fix: give the four write commands an #[serde(default)] expected server_host / owner_repo pair and have resolve_folder_repo compare it with what it derived, erroring with something the panel can recognise and recover from by re-resolving. Absent field = today's behaviour, so older clients are unaffected. Passing the same context on reads is optional, but it lets a stale window self-heal instead of quietly showing another repo's list.

3. Storing the choice in ForgePanelSettings has two side effects

3a. "Use global defaults" destroys the selection. The settings dialog saves that option as settings: null, and ForgeSettingsStore::apply handles (Some(id), None) by removing the whole folder row — remote included:

after picking upstream → upstream xintaofei/codeg
after ticking "Use global defaults" → origin gdm257/codeg

The description says the stored value is carried through a save so it can't be cleared — true for the custom save path you tested, but not for the drop path.

3b. Picking a remote silently detaches the folder from the global row. handlePickRemote writes a folder-scoped row, and an override wins wholesale — which effectiveForgeSettings documents explicitly ("one save detaches a folder from the global row entirely"):

folder follows global? → yes (no own row)
after picking a remote → no — folder now has its own row
global later changed to "CHANGED: reply in Chinese." → folder still sees "Reply in English."

Those standing instructions ride into every task the panel mints (via settings::load_effective), so an edited global instruction quietly stops applying to that folder — from an action that had nothing to do with it. The settings dialog will also start showing that folder as "custom", which the user never asked for.

Suggested fix for both: move remote out of ForgePanelSettings into its own per-folder key. One change kills 3a and 3b together, and it sidesteps read-modify-write races on the shared blob (the pool is max_connections(5)).

4. Frontend follow-ups on the switch

  • page isn't reset. pickFolder goes through resetTo; handlePickRemote doesn't. Switch while on page 5 and the new repository opens on page 5.
  • The label filter survives. labelledFolder (forge-page.tsx:1054-1061) keys its reset on effectiveFolderId, which a remote switch doesn't change — and the comment right above it is exactly about why that's wrong ("a different repository has a different label vocabulary"). Switch with bug selected and upstream comes back filtered by a label it may not have, which reads as "upstream has no issues".
  • Scope keys omit the remote. listScope / countsScope (forge-page.tsx:774, 777) are folder + filters only, and the teardown doesn't bump reqRef, so a list response already in flight at switch time can land under the unchanged scope and briefly paint the old repository's rows.
  • Detail sheet, minor. useForgeIdentity and useMergeOptions key on folderId alone, so after a cross-host switch they show the previous repository's account and merge methods until the effect re-runs on reopen. (useChangeDetail is fine — it self-corrects through the null transition when the panel closes. No change needed there.)

Things I checked that are not problems

So you don't go chasing them:

  • Option-shaped remote names are safe. git remote get-url --all / --push / -x all exit 129, which the code already reads as "no remote". No validation needed.
  • The reset effect does fire on a switch — remoteVersion as a dependency handles it.
  • The i18n parity test passes and all ten locales are real translations.

Suggested tests

Each of these fails today and would have caught the matching item:

  • An issue task triggered on a non-origin remote pushes to the fork and opens a cross-repo PR (and, for GitLab, is refused with a clear message rather than failing at push time).
  • A settings save with settings: null on a folder that has a remote selected — the selection must survive.
  • A folder following the global row: pick a remote, change the global row, assert the folder still sees the change.
  • Two "clients": one changes the selection, the other sends a write carrying the old coordinates — assert it's refused, not redirected.
  • Switching the remote resets page and the label filter, and leaves no rows from the previous repository behind.

Thanks again — the shape of the panel work is right, and the picker turns out to be the thing that makes cross-fork delivery expressible at all. Happy to pair on the delivery half or take the backend part if that's easier; just say the word.

qwe12369 and others added 5 commits September 17, 2026 17:27
…lone

Switching the remote re-points every request at another repository, so
everything that was only true of the previous one has to go with it. The page
number and the label selection keyed on the folder, which such a switch does
not change; the list and count scopes carried no repository at all, so a
response already in flight could land under the unchanged scope and paint the
rows of the repository the reader had left; and the detail sheet asked for the
signed-in account and the permitted merge methods by folder alone.

All four now key on the folder AND the remote, derived during render so the
reset is committed in the same pass that resolves the new remote — before any
effect can fetch against it. A switch also claims a request generation of its
own, which is what keeps a stale answer from being believed in the two states
where no refetch is fired at all: a remote that resolves to nothing readable,
and the frame between the teardown and the resolution.

Both sides spell the repository the same way: the page hands the detail sheet
the `server_host/owner_repo` string its own scopes are built from.
…not drop

The picker's choice lived as a field of the panel-settings blob, and that blob
is saved WHOLESALE by the trigger dialog — "use the global defaults" saves by
dropping a folder's whole row, so the choice went with it, silently. The
picker's own save had the mirror-image problem: it spread the settings in force
and wrote them back, which detached the folder from the global row from the
first pick on, and made every click a read-modify-write race against the dialog.

The selection now lives under its own app_metadata key, one entry per folder
and no global row: a remote names a git remote that exists in ONE folder's
worktree, so there is nothing for an "all folders" default to mean. A folder
with no entry reads the historical `origin`, and a blank or absent name clears
the entry — absence IS the default answer.

The picker gets its own commands (`forge_remote_get` / `forge_remote_set`, in
both modes) and an explicit "Default (origin)" item, which is now the only way
back to no choice: the settings dialog does not edit this at all, so without
that item a folder that had picked a remote could never go back to the default.
The four write commands carried a folder id and nothing else, so a write
followed whatever remote the selection named by the time it arrived: a second
window, a browser tab or an old build holding a stale view could post, close,
file or merge into a repository other than the one its header showed, and
nothing reported it — the request was valid for the folder it named.

Every write now carries the coordinates the panel is showing, flattened beside
its own fields and optional, so a build that predates the check keeps working.
The resolution compares them with what the folder's remote actually resolves to
BEFORE spending a credential, and refuses a mismatch with an i18n key the
panel recognises. The panel's answer is to re-resolve the repository rather
than only report the refusal: the panel is stale, and re-resolving is the one
thing that fixes it.

Reads deliberately do not carry them: a stale read shows a wrong list rather
than writing into the wrong place, and the cost of adding them is every read
request struct and every frontend call site.
Triggering a task from an issue on the parent — which is exactly what the picker
is for — pushed the work branch to the parent too, where the contributor who
owns the fork has no write access. The task failed at delivery, after the agent
had done the work; the same happened to a review of someone else's pull request.
The picker is what makes both halves expressible: what the panel reads is the
source, and the folder's own `origin` is the copy codeg can write to.

The fork is recorded at trigger time and every delivery step reads it.
`delivery_push_repo` answers where the work lands for every shape of delivery (a
pull request pushes back to its own head, an issue task to the recorded fork,
the source otherwise), the push uses it, and a new pull request goes out
cross-repository — as `owner:branch` on GitHub and Gitea, and on GitLab as a
request ADDRESSED to the fork with the target named in its body, which is what
that API accepts: GitLab resolves `source_branch` in the project the request is
sent to, so naming the fork while addressing the target is answered
"source_branch does not exist".

The claim path is the half a single delivery cannot see: the search for an
existing pull request, the four-way match and `check_pull_target` all judged the
head by the SOURCE, so a retry opened a duplicate — which the forge answers with
a 422, after the work has been pushed. The list is the source's either way (a
pull request lives where it is merged into); the head's repository is what the
match compares against and what GitHub's `head={owner}:{branch}` pre-filter
names. GitLab's list payload, which names a foreign source project by number
alone (`project-{id}`), is resolved back to the fork's path before anything
compares it — keyed on the id the task recorded, so another fork's merge request
still matches nothing.

GitLab's two project ids — the fork its merge requests are created on, and the
target they are aimed at — are resolved at the trigger, and a task whose fork or
target cannot be read is refused there, where the user can still choose
something else, rather than after the agent's work has nowhere to go.

A review from somebody else's fork is delivered to that fork rather than refused
up front: whether this account may write there is a server-side fact (the
author's "allow edits from maintainers"), so the push is what decides — which is
also the only way a maintainer can push a fix into a contributor's review. A
refusal there names the way out, and the task keeps its commits either way.
@dawNotPoi

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review. I’ve addressed the four areas you identified, including cross-fork delivery, stale-write protection, repository-scoped frontend state, and moving remote persistence to independent per-folder keys to avoid the shared-blob race.

The latest CI matrix is fully green. Re-review requested when you have time. @xintaofei

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.

[FR] Allow the repository panel to switch remotes

3 participants