Skip to content

Commit 45cec9a

Browse files
geidoDiego Pucci
andauthored
fix(repos,ui): pin user-supplied default_branch end-to-end + don't reset typed sourceBranch on WebSocket events (#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 #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 #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>
1 parent 3b7fba1 commit 45cec9a

17 files changed

Lines changed: 691 additions & 51 deletions

File tree

apps/agor-daemon/src/register-routes.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2092,7 +2092,7 @@ export async function registerRoutes(ctx: RegisterRoutesContext): Promise<void>
20922092
'/repos/clone',
20932093
{
20942094
async create(
2095-
data: { url: string; name?: string; destination?: string },
2095+
data: { url: string; name?: string; destination?: string; default_branch?: string },
20962096
params: RouteParams
20972097
) {
20982098
return reposService.cloneRepository(data, params);

apps/agor-daemon/src/services/repos.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ export class ReposService extends DrizzleService<Repo, Partial<Repo>, RepoParams
144144
* Client receives 'repos.created' WebSocket event when complete.
145145
*/
146146
async cloneRepository(
147-
data: { url: string; slug?: string; name?: string },
147+
data: { url: string; slug?: string; name?: string; default_branch?: string },
148148
params?: RepoParams
149149
): Promise<{ status: 'pending' | 'exists'; slug: string }> {
150150
// Note: `||` (not `??`) is intentional — we want an empty `data.slug`
@@ -200,6 +200,10 @@ export class ReposService extends DrizzleService<Repo, Partial<Repo>, RepoParams
200200
params: {
201201
url: data.url,
202202
slug,
203+
// Forward the user-supplied default_branch so the executor
204+
// persists what the operator typed in "Add Repository" instead
205+
// of silently overwriting it with origin/HEAD.
206+
...(data.default_branch ? { default_branch: data.default_branch } : {}),
203207
createDbRecord: true,
204208
userId: userId as string | undefined,
205209
initUnixGroup: rbacEnabled,
@@ -217,10 +221,20 @@ export class ReposService extends DrizzleService<Repo, Partial<Repo>, RepoParams
217221
const io = (app as unknown as { io?: { emit: (event: string, data: unknown) => void } })
218222
.io;
219223
if (io) {
224+
// Include the pinned branch in the message so an operator who
225+
// typo'd the Default Branch can self-diagnose. `git clone
226+
// --branch <X>` failure is one of the most common reasons a
227+
// clone exits non-zero, but the executor's stderr is consumed
228+
// by spawnExecutorFireAndForget — without this hint the user
229+
// sees only "Clone failed (exit code 128)" and has no idea
230+
// the branch field is the cause.
231+
const branchHint = data.default_branch
232+
? ` Default Branch was set to '${data.default_branch}' — verify it exists on the remote.`
233+
: '';
220234
io.emit('repo:cloneError', {
221235
slug,
222236
url: data.url,
223-
error: `Clone failed (exit code ${code}). Check that the repository URL is correct and accessible.`,
237+
error: `Clone failed (exit code ${code}). Check that the repository URL is correct and accessible.${branchHint}`,
224238
});
225239
}
226240
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* Regression tests for WorktreeTab source-branch preservation.
3+
*
4+
* Same root cause as NewWorktreeModal.test.tsx — every `repos.patched`
5+
* WebSocket event gave the parent component a new `repoById` Map
6+
* reference, re-firing the form-init `useEffect`, and `setFieldsValue`
7+
* silently overwrote the user's typed `sourceBranch` with the repo's
8+
* `default_branch`. Different surface (the WorktreeTab inside the unified
9+
* CreateDialog) — same fix (useRef gate so init runs once per mount).
10+
*/
11+
12+
import type { Repo } from '@agor-live/client';
13+
import { fireEvent, render, screen } from '@testing-library/react';
14+
import { describe, expect, it, vi } from 'vitest';
15+
import { WorktreeTab, type WorktreeTabConfig } from './WorktreeTab';
16+
17+
function makeRepo(overrides: Partial<Repo> = {}): Repo {
18+
return {
19+
repo_id: 'repo-1',
20+
slug: 'org/repo-1',
21+
name: 'repo-1',
22+
default_branch: 'main',
23+
repo_type: 'remote',
24+
remote_url: 'https://github.com/org/repo-1.git',
25+
local_path: '/tmp/repo-1',
26+
...overrides,
27+
} as unknown as Repo;
28+
}
29+
30+
describe('WorktreeTab — source-branch preservation', { timeout: 10_000 }, () => {
31+
it('preserves user-typed sourceBranch across `repoById` Map reference churn (WebSocket patches)', () => {
32+
const formRef: React.MutableRefObject<(() => Promise<WorktreeTabConfig | null>) | null> = {
33+
current: null,
34+
};
35+
const repo = makeRepo({ default_branch: 'main' });
36+
37+
const { rerender } = render(
38+
<WorktreeTab
39+
repoById={new Map([[repo.repo_id, repo]])}
40+
onValidityChange={vi.fn()}
41+
formRef={formRef}
42+
/>
43+
);
44+
45+
const branchInput = screen.getByLabelText(/Source Branch/i) as HTMLInputElement;
46+
expect(branchInput.value).toBe('main');
47+
48+
fireEvent.change(branchInput, { target: { value: 'release/2024-q1' } });
49+
expect(branchInput.value).toBe('release/2024-q1');
50+
51+
// New Map reference, same data — pre-fix this would reset the field.
52+
rerender(
53+
<WorktreeTab
54+
repoById={new Map([[repo.repo_id, repo]])}
55+
onValidityChange={vi.fn()}
56+
formRef={formRef}
57+
/>
58+
);
59+
60+
expect((screen.getByLabelText(/Source Branch/i) as HTMLInputElement).value).toBe(
61+
'release/2024-q1'
62+
);
63+
});
64+
});

apps/agor-ui/src/components/CreateDialog/tabs/WorktreeTab.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Repo } from '@agor-live/client';
22
import { Form } from 'antd';
3-
import { useCallback, useEffect, useState } from 'react';
3+
import { useCallback, useEffect, useRef, useState } from 'react';
44
import { mapToArray } from '@/utils/mapHelpers';
55
import { WorktreeFormFields } from '../../WorktreeFormFields';
66

@@ -46,19 +46,27 @@ export const WorktreeTab: React.FC<WorktreeTabProps> = ({
4646
}, 0);
4747
}, [form, onValidityChange]);
4848

49-
// Remember last used repo
49+
// Initialize form once per mount. Without this guard the effect re-fires
50+
// on every `repos.patched` WebSocket event (which gives `repoById` a new
51+
// Map reference), and `setFieldsValue({ sourceBranch })` silently
52+
// overwrites whatever the user typed back to the repo's default branch.
53+
// The user notices only after submitting that the worktree got created
54+
// off `main` instead of their chosen branch.
55+
const initialized = useRef(false);
5056
useEffect(() => {
51-
if (repoById.size === 0) return;
57+
if (initialized.current || repoById.size === 0) return;
5258

5359
const lastRepoId = localStorage.getItem('agor-last-repo-id');
5460
if (lastRepoId && repoById.has(lastRepoId)) {
61+
initialized.current = true;
5562
form.setFieldsValue({
5663
repoId: lastRepoId,
5764
sourceBranch: repoById.get(lastRepoId)?.default_branch,
5865
});
5966
setSelectedRepoId(lastRepoId);
6067
handleValuesChange();
6168
} else if (repoById.size > 0) {
69+
initialized.current = true;
6270
const firstRepo = mapToArray(repoById)[0];
6371
form.setFieldsValue({
6472
repoId: firstRepo.repo_id,
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* Regression tests for NewWorktreeModal source-branch preservation.
3+
*
4+
* Bug: every `repos.patched` WebSocket event gave the parent component a
5+
* new `repoById` Map reference, which re-fired the form-init `useEffect`
6+
* and `setFieldsValue({ sourceBranch })` silently overwrote whatever the
7+
* user typed back to the repo's `default_branch`. The user only noticed
8+
* after submitting that the worktree was created off `main` instead of
9+
* their chosen branch.
10+
*
11+
* The fix gates initialization with a `useRef` so it runs exactly once per
12+
* modal-open session. These tests pin that guardrail.
13+
*/
14+
15+
import type { Repo } from '@agor-live/client';
16+
import { fireEvent, render, screen } from '@testing-library/react';
17+
import { describe, expect, it, vi } from 'vitest';
18+
import { NewWorktreeModal } from './NewWorktreeModal';
19+
20+
function makeRepo(overrides: Partial<Repo> = {}): Repo {
21+
return {
22+
repo_id: 'repo-1',
23+
slug: 'org/repo-1',
24+
name: 'repo-1',
25+
default_branch: 'main',
26+
repo_type: 'remote',
27+
remote_url: 'https://github.com/org/repo-1.git',
28+
local_path: '/tmp/repo-1',
29+
...overrides,
30+
} as unknown as Repo;
31+
}
32+
33+
describe('NewWorktreeModal — source-branch preservation', { timeout: 10_000 }, () => {
34+
it('preserves user-typed sourceBranch across `repoById` Map reference churn (WebSocket patches)', async () => {
35+
const repo = makeRepo({ default_branch: 'main' });
36+
const { rerender } = render(
37+
<NewWorktreeModal
38+
open
39+
onClose={vi.fn()}
40+
onCreate={vi.fn()}
41+
repoById={new Map([[repo.repo_id, repo]])}
42+
/>
43+
);
44+
45+
// The init effect should populate sourceBranch from repo.default_branch
46+
// on first render. Use the visible label to locate the field.
47+
const branchInput = screen.getByLabelText(/Source Branch/i) as HTMLInputElement;
48+
expect(branchInput.value).toBe('main');
49+
50+
// User clears the field and types their preferred branch.
51+
fireEvent.change(branchInput, { target: { value: 'release/2024-q1' } });
52+
expect(branchInput.value).toBe('release/2024-q1');
53+
54+
// Simulate a WebSocket `repos.patched` event by handing the modal a NEW
55+
// Map reference. The repo data hasn't changed; only the reference has.
56+
// Pre-fix, this re-fired the effect and reset sourceBranch back to
57+
// 'main'. With the useRef guard, the typed value must persist.
58+
rerender(
59+
<NewWorktreeModal
60+
open
61+
onClose={vi.fn()}
62+
onCreate={vi.fn()}
63+
repoById={new Map([[repo.repo_id, repo]])}
64+
/>
65+
);
66+
67+
expect((screen.getByLabelText(/Source Branch/i) as HTMLInputElement).value).toBe(
68+
'release/2024-q1'
69+
);
70+
});
71+
72+
it('re-initializes sourceBranch when the modal is closed and re-opened', async () => {
73+
// Closing the modal must clear the "initialized" flag so the next open
74+
// populates fresh defaults. Otherwise a user who typed a stale value,
75+
// closed, came back later — and the form would still have the stale
76+
// typed value with no way to know it didn't come from the new repo.
77+
const repo = makeRepo({ default_branch: 'main' });
78+
const { rerender } = render(
79+
<NewWorktreeModal
80+
open
81+
onClose={vi.fn()}
82+
onCreate={vi.fn()}
83+
repoById={new Map([[repo.repo_id, repo]])}
84+
/>
85+
);
86+
87+
fireEvent.change(screen.getByLabelText(/Source Branch/i), {
88+
target: { value: 'some-typed-value' },
89+
});
90+
91+
rerender(
92+
<NewWorktreeModal
93+
open={false}
94+
onClose={vi.fn()}
95+
onCreate={vi.fn()}
96+
repoById={new Map([[repo.repo_id, repo]])}
97+
/>
98+
);
99+
100+
// Re-open with an updated default_branch — the form should pick it up.
101+
const repoUpdated = makeRepo({ default_branch: 'develop' });
102+
rerender(
103+
<NewWorktreeModal
104+
open
105+
onClose={vi.fn()}
106+
onCreate={vi.fn()}
107+
repoById={new Map([[repoUpdated.repo_id, repoUpdated]])}
108+
/>
109+
);
110+
111+
expect((screen.getByLabelText(/Source Branch/i) as HTMLInputElement).value).toBe('develop');
112+
});
113+
});

apps/agor-ui/src/components/NewWorktreeModal/NewWorktreeModal.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Repo } from '@agor-live/client';
22
import { Button, Form, Modal } from 'antd';
3-
import { useCallback, useEffect, useState } from 'react';
3+
import { useCallback, useEffect, useRef, useState } from 'react';
44
import { mapToArray } from '@/utils/mapHelpers';
55
import type { WorktreeTabConfig } from '../CreateDialog/tabs/WorktreeTab';
66
import { WorktreeFormFields } from '../WorktreeFormFields';
@@ -43,14 +43,25 @@ export const NewWorktreeModal: React.FC<NewWorktreeModalProps> = ({
4343
}, 0);
4444
}, [form]);
4545

46-
// Remember last used repo from localStorage
46+
// Initialize form once per modal-open session. Without this guard the
47+
// effect re-fires on every `repos.patched` WebSocket event (which gives
48+
// `repoById` a new Map reference), and `setFieldsValue({ sourceBranch })`
49+
// silently overwrites whatever the user typed back to the repo's default
50+
// branch. The user notices only after submitting that the worktree got
51+
// created off `main` instead of their chosen branch.
52+
const initialized = useRef(false);
4753
useEffect(() => {
48-
if (!open || repoById.size === 0) return;
54+
if (!open) {
55+
initialized.current = false;
56+
return;
57+
}
58+
if (initialized.current || repoById.size === 0) return;
4959

5060
const lastRepoId = localStorage.getItem('agor-last-repo-id');
5161

5262
// If we have a last used repo and it still exists, use it
5363
if (lastRepoId && repoById.has(lastRepoId)) {
64+
initialized.current = true;
5465
form.setFieldsValue({
5566
repoId: lastRepoId,
5667
sourceBranch: repoById.get(lastRepoId)?.default_branch,
@@ -60,6 +71,7 @@ export const NewWorktreeModal: React.FC<NewWorktreeModalProps> = ({
6071
handleValuesChange();
6172
} else if (repoById.size > 0) {
6273
// No last-repo-id or it doesn't exist anymore - auto-select first repo
74+
initialized.current = true;
6375
const firstRepo = mapToArray(repoById)[0];
6476
form.setFieldsValue({
6577
repoId: firstRepo.repo_id,
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* Regression test for WorktreesTable source-branch preservation in the
3+
* Settings → Worktrees → Create Worktree modal.
4+
*
5+
* Same root cause as NewWorktreeModal / WorktreeTab — every `repos.patched`
6+
* (or `boards.patched`) WebSocket event hands the table new array
7+
* references for `repos` / `boards`, which re-fired the form-init
8+
* `useEffect`, and `setFieldsValue({ sourceBranch })` silently overwrote
9+
* whatever the user typed back to the repo's `default_branch`.
10+
*
11+
* The fix is the same useRef guard so init runs exactly once per
12+
* `createModalOpen=true` session.
13+
*/
14+
15+
import type { Board, Repo, Worktree } from '@agor-live/client';
16+
import { fireEvent, render, screen } from '@testing-library/react';
17+
import { describe, expect, it } from 'vitest';
18+
import { WorktreesTable } from './WorktreesTable';
19+
20+
function makeRepo(overrides: Partial<Repo> = {}): Repo {
21+
return {
22+
repo_id: 'repo-1',
23+
slug: 'org/repo-1',
24+
name: 'repo-1',
25+
default_branch: 'main',
26+
repo_type: 'remote',
27+
remote_url: 'https://github.com/org/repo-1.git',
28+
local_path: '/tmp/repo-1',
29+
...overrides,
30+
} as unknown as Repo;
31+
}
32+
33+
describe('WorktreesTable — source-branch preservation', { timeout: 10_000 }, () => {
34+
it('preserves user-typed sourceBranch across `repoById` / `boardById` Map reference churn', () => {
35+
const repo = makeRepo({ default_branch: 'main' });
36+
const repoById = new Map([[repo.repo_id, repo]]);
37+
const boardById = new Map<string, Board>();
38+
const worktreeById = new Map<string, Worktree>();
39+
const sessionsByWorktree = new Map<string, never[]>();
40+
41+
const { rerender } = render(
42+
<WorktreesTable
43+
client={null}
44+
worktreeById={worktreeById}
45+
repoById={repoById}
46+
boardById={boardById}
47+
sessionsByWorktree={sessionsByWorktree as Map<string, never[]>}
48+
/>
49+
);
50+
51+
// Open the create modal
52+
fireEvent.click(screen.getByRole('button', { name: /Create Worktree/i }));
53+
54+
// The init effect populates sourceBranch from the repo's default_branch
55+
const branchInput = screen.getByLabelText(/Source Branch/i) as HTMLInputElement;
56+
expect(branchInput.value).toBe('main');
57+
58+
// User types their pinned branch
59+
fireEvent.change(branchInput, { target: { value: 'release/2024-q1' } });
60+
expect(branchInput.value).toBe('release/2024-q1');
61+
62+
// Simulate a `repos.patched` WebSocket event by handing the table NEW
63+
// Map references for repoById and boardById. Same data, different refs.
64+
rerender(
65+
<WorktreesTable
66+
client={null}
67+
worktreeById={worktreeById}
68+
repoById={new Map([[repo.repo_id, repo]])}
69+
boardById={new Map<string, Board>()}
70+
sessionsByWorktree={sessionsByWorktree as Map<string, never[]>}
71+
/>
72+
);
73+
74+
expect((screen.getByLabelText(/Source Branch/i) as HTMLInputElement).value).toBe(
75+
'release/2024-q1'
76+
);
77+
});
78+
});

0 commit comments

Comments
 (0)