Skip to content

fix(repos,ui): pin user-supplied default_branch end-to-end + don't reset typed sourceBranch on WebSocket events#1127

Merged
mistercrunch merged 4 commits into
mainfrom
fix/worktree-base-branch-end-to-end
May 9, 2026
Merged

fix(repos,ui): pin user-supplied default_branch end-to-end + don't reset typed sourceBranch on WebSocket events#1127
mistercrunch merged 4 commits into
mainfrom
fix/worktree-base-branch-end-to-end

Conversation

@geido
Copy link
Copy Markdown
Contributor

@geido geido commented May 8, 2026

Summary

End-to-end fix for the "Add Repository → Default Branch" form silently being ignored, plus the worktree-create modal resetting your typed sourceBranch mid-session. Three logically-separable fixes in one PR (one commit each):

  1. Node 25 compatcreateClient() rejects Node 25's broken globalThis.localStorage stub (no setItem) so the Feathers auth client doesn't throw _a.setItem is not a function on first authenticate. Same fix in apps/agor-ui/src/test/setup.ts so component tests work when NODE_OPTIONS=--localstorage-file=... is set.

  2. default_branch end-to-end through clone — the field was declared on the UI form but dropped at every layer (route signature → service signature → executor params → executor handler), so the DB always wrote whatever origin/HEAD resolved to. Threaded through every layer; cloneRepo() also forwards the pin to git clone --branch <name> so the working tree lands on that branch (otherwise .agor.yml on a non-default branch wouldn't be visible at parse time).

  3. Worktree-create form preserves typed sourceBranch — every repos.patched WebSocket event handed the parent component a new repoById Map reference; the form-init useEffect re-fired and setFieldsValue silently overwrote the user's typed value with repo.default_branch. Guarded with a useRef so init runs exactly once per modal-open / mount session.

Same anti-pattern as PR #1001 (session list filters being reset by WebSocket events), applied to two more surfaces.

What you'll see fixed

  • "Add Repository" with a custom Default Branch now actually persists to the DB record (and the working tree is on that branch, so the daemon's .agor.yml parser finds it).
  • Typing a custom Source Branch in the worktree-create modal no longer mysteriously reverts to main if the daemon emits any unrelated repo patch event before you click Create.
  • Source-tree daemon / executor / CLI run cleanly on Node 25 hosts.

Test plan

  • pnpm -w test clean across @agor/core (2152), @agor/executor (344), agor-ui (109)
  • 11 new tests added; 1 setup helper. Full list:
    • packages/core/src/git/index.test.tscloneRepo:
      • should check out the pinned branch when options.branch is set
      • should fall back to remote HEAD when options.branch is not set
      • should fail loudly when options.branch does not exist on the remote
    • packages/core/src/api/index.test.tscreateClient / authentication configuration:
      • should reject a localStorage stub without setItem (Node 25)
      • should reject a localStorage stub whose setItem is not a function
    • packages/executor/src/payload-types.test.tsGitClonePayloadSchema:
      • should accept default_branch in params
      • should treat default_branch as optional
    • packages/executor/src/commands/index.test.tsexecuteCommand git.clone:
      • should echo user-supplied default_branch in dry-run response
    • apps/agor-ui/src/components/NewWorktreeModal/NewWorktreeModal.test.tsx (new file):
      • preserves user-typed sourceBranch across repoById Map reference churn
      • re-initializes sourceBranch when the modal is closed and re-opened
    • apps/agor-ui/src/components/CreateDialog/tabs/WorktreeTab.test.tsx (new file):
      • preserves user-typed sourceBranch across repoById Map reference churn
  • Manual end-to-end: cloned preset-io/preset-codespaces with default_branch=geido/feat/codespaces-ultra → DB record has the pin, working tree is on that branch, .agor.yml parsed, environment variants populated, new worktrees default to that base.

Files changed

File Why
packages/core/src/api/index.ts (+ test) Node 25 localStorage guard
apps/agor-ui/src/test/setup.ts In-memory Storage shim for jsdom under broken Node 25 global
packages/core/src/git/index.ts (+ test) CloneOptions.branchgit clone --branch <name>; defaultBranch in result reflects the pin
packages/executor/src/payload-types.ts (+ test) GitClonePayloadSchema.params.default_branch?
packages/executor/src/commands/git.ts (+ test) Forward pin as branch to cloneRepo; prefer pin when writing repo DB record; echo in dry-run
apps/agor-daemon/src/services/repos.ts Pipe default_branch from request body into executor params
apps/agor-daemon/src/register-routes.ts Add default_branch? to /repos/clone body type
apps/agor-ui/src/components/NewWorktreeModal/NewWorktreeModal.tsx (+ test) useRef gate for form-init effect
apps/agor-ui/src/components/CreateDialog/tabs/WorktreeTab.tsx (+ test) Same guard for the unified-create-dialog tab

🤖 Generated with Claude Code

Diego Pucci added 4 commits May 8, 2026 15:08
Node 25 exposes a `globalThis.localStorage` stub that lacks the standard
Storage methods (no `setItem`/`getItem`/etc.), so the Feathers auth
client throws `_a.setItem is not a function` on first authenticate().
This blocks any in-tree daemon / executor / CLI run on a Node 25 host —
`createClient()` configures authentication with that broken storage and
the very first auth attempt blows up.

`createClient()` now treats the global as Storage only when its
`setItem` is callable; otherwise it passes `storage: undefined` and the
Feathers auth client falls back to its in-memory mode (correct for any
non-browser runtime).

Tests:
  - "should reject a localStorage stub without setItem (Node 25)"
  - "should reject a localStorage stub whose setItem is not a function"

Same broken global also bleeds into the agor-ui test runner when the
shell exports NODE_OPTIONS=--localstorage-file=..., displacing jsdom's
real Storage implementation. apps/agor-ui/src/test/setup.ts now
installs an in-memory Storage shim under the same guard, so component
tests that read localStorage.getItem(...) work on Node 25 without
operators having to unset the env var.
The "Add Repository → Default Branch" form lets the operator pick a
non-default base branch (e.g. a long-lived feature branch). Pre-fix,
that field was silently dropped at every layer and the executor wrote
whatever `origin/HEAD` pointed at into the DB record:

  UI sends `default_branch` to /repos/clone
    └─ daemon route declared `{ url, name?, destination? }` only
       └─ ReposService.cloneRepository signature took the same shape
          └─ Executor params did not include `default_branch`
             └─ Executor wrote `cloneResult.defaultBranch`
                = `git symbolic-ref refs/remotes/origin/HEAD` = "main"

Two visible symptoms:
  - Repo's `default_branch` in the DB always equals the upstream's
    HEAD, no matter what the operator typed (and re-opening the edit
    dialog kept showing "main", looking like the edit silently failed).
  - Worktrees created off that repo defaulted to "main" too, so the
    "preserved my typed sourceBranch" UI fix from a sibling commit had
    nothing useful to fall back to either.

There was also a related second-order bug: even when `default_branch`
finally reached the executor, `cloneRepo()` still ran a default
checkout of the remote's HEAD. Repos whose `.agor.yml` lives on a
non-default branch would be cloned with the file missing on disk, and
the daemon's environment-variant ingestion logged "No environment
variants configured" even though the operator had picked the right
branch.

Threads `default_branch` end-to-end:

  - apps/agor-daemon/src/register-routes.ts: add `default_branch?` to
    POST /repos/clone body type.
  - apps/agor-daemon/src/services/repos.ts: pipe `data.default_branch`
    into the executor `git.clone` params (only when set, so existing
    `getDefaultBranch()` fallback is preserved).
  - packages/executor/src/payload-types.ts: add optional `default_branch`
    to `GitClonePayloadSchema.params`.
  - packages/executor/src/commands/git.ts:
    - forward as `branch` to `cloneRepo()` so the working tree lands
      on the pinned branch (fixes the .agor.yml-not-found case);
    - prefer `payload.params.default_branch` when writing the repo DB
      record, falling back to `cloneResult.defaultBranch` only when
      unset (keeps existing behavior for un-pinned clones);
    - echo the field in the dry-run response so callers can verify
      the field actually reached the handler.
  - packages/core/src/git/index.ts: `CloneOptions.branch` opt;
    `cloneRepo()` forwards it as `git clone --branch <name>` and
    sets `defaultBranch` in the result to the pin (so the DB record
    matches what's on disk).

Tests:
  - cloneRepo: should check out the pinned branch when options.branch
    is set / fall back to remote HEAD when it isn't / fail loudly when
    the pin doesn't exist on the remote.
  - GitClonePayloadSchema: accept default_branch in params; treat it
    as optional.
  - executeCommand git.clone: echo user-supplied default_branch in
    dry-run response.
Agor real-time-syncs repo metadata over the FeathersJS WebSocket. Every
`repos.patched` event hands the modal/tab a NEW `repoById` Map
reference, which re-fired the form-init `useEffect`. The effect then
called `setFieldsValue({ sourceBranch: repo.default_branch })` and
silently overwrote whatever the operator had typed.

End-user symptom: type a non-default branch, wait a few seconds for any
`repos.patched` event, click Create — the worktree lands on `main`
anyway. No toast, no console warning, just a wrong base branch.

Same anti-pattern in two surfaces — guard both with a useRef so init
runs exactly once per modal-open / mount session:

  - NewWorktreeModal.tsx (open-prop modal): the ref resets on close so
    re-opening always re-initializes from the fresh repo metadata.
  - WorktreeTab.tsx (tab inside CreateDialog): mounts/unmounts with
    the dialog, so the per-mount ref is sufficient.

Other potential fields with the same pattern were left alone — the
guard is specifically for sourceBranch which a user types directly;
fields like repoId only change via explicit dropdown handlers
(handleRepoChange) which are intentionally still allowed to reset
sourceBranch.

This is the same shape of fix as #1001 (session list filters being
reset by WebSocket events), applied to a different code path.

Tests (regression coverage):
  - NewWorktreeModal: preserves typed sourceBranch across repoById
    Map reference churn / re-initializes on close-and-reopen.
  - WorktreeTab: preserves typed sourceBranch across repoById churn.
Three followups from a code review of #1127. The PR's primary fixes are
correct, but each one had an adjacent surface where the same root cause
still bit users.

1. cloneRepo's existing-repo early-return ignored options.branch.

   When `~/.agor/repos/<slug>` already exists (re-clone after a half-
   broken first attempt, manual provisioning, restart loop), cloneRepo
   returned early with `defaultBranch = await getDefaultBranch(targetPath)`,
   leaving the working tree on whatever was previously checked out. The
   executor then wrote the user-supplied pin into the DB record. Net:
   DB claimed `feat/x`, disk on `main`, `.agor.yml` parsed at
   `cloneResult.path` came from `main` — exactly the symptom the
   --branch fix was supposed to close.

   Now: when the existing checkout is on a different branch than the
   pin, fetch origin/<pin> and check out. Failure (dirty working tree,
   branch missing on remote) throws with a clear message instead of
   silently returning a stale defaultBranch.

   New tests in cloneRepo:
     - switches the working tree on a reused clone with a pinned branch
     - rejects reuse when the pin can't be checked out

2. Settings → Worktrees → Create Worktree had the same useEffect anti-
   pattern as NewWorktreeModal / WorktreeTab.

   `useEffect([..., repos, boards, ...])` where `repos` and `boards`
   are derived via `mapToArray(repoById)` / `mapToArray(boardById)`
   on every render. WebSocket-triggered Map ref churn re-fired the
   effect and `setFieldsValue({ sourceBranch })` overwrote typed
   values. Same fix shape as the rest of the PR — useRef gate so init
   runs exactly once per `createModalOpen=true` session.

   New test mirroring NewWorktreeModal / WorktreeTab tests.

3. Surface the pinned branch in the cloneError message.

   The clone exits non-zero with no useful detail when the operator
   typo'd the Default Branch field — `git clone --branch <typo>`
   returns 128, the executor's stderr is consumed by
   spawnExecutorFireAndForget, and the user gets `Clone failed (exit
   code 128). Check that the repository URL is correct and accessible.`
   with no hint that the branch is the cause.

   When a default_branch was supplied, append it to the error so the
   operator can self-diagnose without diving into daemon logs. (Data
   integrity is fine; cloneRepo throwing means the executor never
   reaches the DB-write call site, so no half-formed repo records
   sneak through. The cleanup is purely about the UX of the error
   message reaching the UI.)
Copy link
Copy Markdown
Member

@mistercrunch mistercrunch left a comment

Choose a reason for hiding this comment

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

LGTM

@mistercrunch mistercrunch merged commit 45cec9a into main May 9, 2026
10 checks passed
@mistercrunch mistercrunch deleted the fix/worktree-base-branch-end-to-end branch May 9, 2026 19:43
dhavli pushed a commit to Code-Fixxers/agor that referenced this pull request May 12, 2026
…set typed sourceBranch on WebSocket events (preset-io#1127)

* fix(core): handle Node 25's broken localStorage global

Node 25 exposes a `globalThis.localStorage` stub that lacks the standard
Storage methods (no `setItem`/`getItem`/etc.), so the Feathers auth
client throws `_a.setItem is not a function` on first authenticate().
This blocks any in-tree daemon / executor / CLI run on a Node 25 host —
`createClient()` configures authentication with that broken storage and
the very first auth attempt blows up.

`createClient()` now treats the global as Storage only when its
`setItem` is callable; otherwise it passes `storage: undefined` and the
Feathers auth client falls back to its in-memory mode (correct for any
non-browser runtime).

Tests:
  - "should reject a localStorage stub without setItem (Node 25)"
  - "should reject a localStorage stub whose setItem is not a function"

Same broken global also bleeds into the agor-ui test runner when the
shell exports NODE_OPTIONS=--localstorage-file=..., displacing jsdom's
real Storage implementation. apps/agor-ui/src/test/setup.ts now
installs an in-memory Storage shim under the same guard, so component
tests that read localStorage.getItem(...) work on Node 25 without
operators having to unset the env var.

* fix(repos): pin user-supplied default_branch end-to-end through clone

The "Add Repository → Default Branch" form lets the operator pick a
non-default base branch (e.g. a long-lived feature branch). Pre-fix,
that field was silently dropped at every layer and the executor wrote
whatever `origin/HEAD` pointed at into the DB record:

  UI sends `default_branch` to /repos/clone
    └─ daemon route declared `{ url, name?, destination? }` only
       └─ ReposService.cloneRepository signature took the same shape
          └─ Executor params did not include `default_branch`
             └─ Executor wrote `cloneResult.defaultBranch`
                = `git symbolic-ref refs/remotes/origin/HEAD` = "main"

Two visible symptoms:
  - Repo's `default_branch` in the DB always equals the upstream's
    HEAD, no matter what the operator typed (and re-opening the edit
    dialog kept showing "main", looking like the edit silently failed).
  - Worktrees created off that repo defaulted to "main" too, so the
    "preserved my typed sourceBranch" UI fix from a sibling commit had
    nothing useful to fall back to either.

There was also a related second-order bug: even when `default_branch`
finally reached the executor, `cloneRepo()` still ran a default
checkout of the remote's HEAD. Repos whose `.agor.yml` lives on a
non-default branch would be cloned with the file missing on disk, and
the daemon's environment-variant ingestion logged "No environment
variants configured" even though the operator had picked the right
branch.

Threads `default_branch` end-to-end:

  - apps/agor-daemon/src/register-routes.ts: add `default_branch?` to
    POST /repos/clone body type.
  - apps/agor-daemon/src/services/repos.ts: pipe `data.default_branch`
    into the executor `git.clone` params (only when set, so existing
    `getDefaultBranch()` fallback is preserved).
  - packages/executor/src/payload-types.ts: add optional `default_branch`
    to `GitClonePayloadSchema.params`.
  - packages/executor/src/commands/git.ts:
    - forward as `branch` to `cloneRepo()` so the working tree lands
      on the pinned branch (fixes the .agor.yml-not-found case);
    - prefer `payload.params.default_branch` when writing the repo DB
      record, falling back to `cloneResult.defaultBranch` only when
      unset (keeps existing behavior for un-pinned clones);
    - echo the field in the dry-run response so callers can verify
      the field actually reached the handler.
  - packages/core/src/git/index.ts: `CloneOptions.branch` opt;
    `cloneRepo()` forwards it as `git clone --branch <name>` and
    sets `defaultBranch` in the result to the pin (so the DB record
    matches what's on disk).

Tests:
  - cloneRepo: should check out the pinned branch when options.branch
    is set / fall back to remote HEAD when it isn't / fail loudly when
    the pin doesn't exist on the remote.
  - GitClonePayloadSchema: accept default_branch in params; treat it
    as optional.
  - executeCommand git.clone: echo user-supplied default_branch in
    dry-run response.

* fix(ui): preserve typed sourceBranch across WebSocket repo updates

Agor real-time-syncs repo metadata over the FeathersJS WebSocket. Every
`repos.patched` event hands the modal/tab a NEW `repoById` Map
reference, which re-fired the form-init `useEffect`. The effect then
called `setFieldsValue({ sourceBranch: repo.default_branch })` and
silently overwrote whatever the operator had typed.

End-user symptom: type a non-default branch, wait a few seconds for any
`repos.patched` event, click Create — the worktree lands on `main`
anyway. No toast, no console warning, just a wrong base branch.

Same anti-pattern in two surfaces — guard both with a useRef so init
runs exactly once per modal-open / mount session:

  - NewWorktreeModal.tsx (open-prop modal): the ref resets on close so
    re-opening always re-initializes from the fresh repo metadata.
  - WorktreeTab.tsx (tab inside CreateDialog): mounts/unmounts with
    the dialog, so the per-mount ref is sufficient.

Other potential fields with the same pattern were left alone — the
guard is specifically for sourceBranch which a user types directly;
fields like repoId only change via explicit dropdown handlers
(handleRepoChange) which are intentionally still allowed to reset
sourceBranch.

This is the same shape of fix as preset-io#1001 (session list filters being
reset by WebSocket events), applied to a different code path.

Tests (regression coverage):
  - NewWorktreeModal: preserves typed sourceBranch across repoById
    Map reference churn / re-initializes on close-and-reopen.
  - WorktreeTab: preserves typed sourceBranch across repoById churn.

* fix(repos,ui): close gaps in default_branch end-to-end coverage

Three followups from a code review of preset-io#1127. The PR's primary fixes are
correct, but each one had an adjacent surface where the same root cause
still bit users.

1. cloneRepo's existing-repo early-return ignored options.branch.

   When `~/.agor/repos/<slug>` already exists (re-clone after a half-
   broken first attempt, manual provisioning, restart loop), cloneRepo
   returned early with `defaultBranch = await getDefaultBranch(targetPath)`,
   leaving the working tree on whatever was previously checked out. The
   executor then wrote the user-supplied pin into the DB record. Net:
   DB claimed `feat/x`, disk on `main`, `.agor.yml` parsed at
   `cloneResult.path` came from `main` — exactly the symptom the
   --branch fix was supposed to close.

   Now: when the existing checkout is on a different branch than the
   pin, fetch origin/<pin> and check out. Failure (dirty working tree,
   branch missing on remote) throws with a clear message instead of
   silently returning a stale defaultBranch.

   New tests in cloneRepo:
     - switches the working tree on a reused clone with a pinned branch
     - rejects reuse when the pin can't be checked out

2. Settings → Worktrees → Create Worktree had the same useEffect anti-
   pattern as NewWorktreeModal / WorktreeTab.

   `useEffect([..., repos, boards, ...])` where `repos` and `boards`
   are derived via `mapToArray(repoById)` / `mapToArray(boardById)`
   on every render. WebSocket-triggered Map ref churn re-fired the
   effect and `setFieldsValue({ sourceBranch })` overwrote typed
   values. Same fix shape as the rest of the PR — useRef gate so init
   runs exactly once per `createModalOpen=true` session.

   New test mirroring NewWorktreeModal / WorktreeTab tests.

3. Surface the pinned branch in the cloneError message.

   The clone exits non-zero with no useful detail when the operator
   typo'd the Default Branch field — `git clone --branch <typo>`
   returns 128, the executor's stderr is consumed by
   spawnExecutorFireAndForget, and the user gets `Clone failed (exit
   code 128). Check that the repository URL is correct and accessible.`
   with no hint that the branch is the cause.

   When a default_branch was supplied, append it to the error so the
   operator can self-diagnose without diving into daemon logs. (Data
   integrity is fine; cloneRepo throwing means the executor never
   reaches the DB-write call site, so no half-formed repo records
   sneak through. The cleanup is purely about the UX of the error
   message reaching the UI.)

---------

Co-authored-by: Diego Pucci <geido@192.168.0.21>
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.

2 participants