feat(worktrees): fetch remote source branch before worktree creation (#3296)

* feat(worktrees): fetch remote source branch before worktree creation

New worktrees based on a local branch that is behind its upstream now
fetch first and branch from the remote-tracking ref, so they are not
born stale. A global setting (on by default) in Settings > Behavior
controls this, and fetch failures toast a warning and fall back to
local state instead of blocking creation.

* fix(worktrees): wire fetch-source toggle to store and honor failed runtime fetches

The Behavior toggle only persisted the setting; the consumer reads the
config store at creation time, so a just-toggled-off setting kept
fetching until the next hydration. Update the store optimistically on
toggle and on page load, and roll it back when the save fails.

The VS Code runtime bridge resolves git fetches with { success: false }
instead of throwing, which the consumer read as success and silently
based the worktree on the stale remote ref. Treat any non-success
result as a failed fetch: warn and fall back to local state, matching
the web/desktop/mobile path.

* fix(worktrees): stop new remote-based worktrees from tracking the base branch

Creating a worktree with a remote start ref made git auto-track the
base branch (branch.autoSetupMerge), so with the new remote fetch every
behind-root worktree was born with upstream origin/<base> and plain
git push refused under push.default=simple.

The new branch's own upstream does not exist until its first push, and
the bootstrap deliberately refuses to write tracking config for refs
that were never fetched, so --set-upstream-to cannot re-point it.
Suppress the auto-track with --no-track on new-mode creation from a
remote ref: the branch ships with no upstream, matching the behavior
before the remote fetch until the first push sets it. Explicit
upstream keys now also win over the remote start ref inference,
aligning the create path with the validate path and the VS Code
runtime.

* fix(worktrees): keep the pre-create remote ref refresh soft

The client fetch and the server's pre-create fetchRemoteBranchRef both
refresh the same branch, and the second fetch throws on failure — so a
connection dropped between the two turned the promised soft fallback
into a rejected creation even though the remote-tracking ref was
already available locally.

The refresh is now best-effort when the ref exists locally (creation
proceeds from it) and still mandatory when the ref was never fetched,
preserving the materialization behavior for remote-only branches.
Applied to both the web server and the VS Code runtime.

* chore: ignore the .openchamber app runtime state directory
This commit is contained in:
James Tatum
2026-09-03 14:03:32 +03:00
committed by GitHub
parent 40f5ed73c9
commit 5995802fe3
38 changed files with 789 additions and 14 deletions
@@ -0,0 +1,305 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import type { CreateWorktreeArgs, ProjectRef } from '@/lib/worktrees/worktreeManager';
interface MockBranchTracking {
main?: string;
}
const project: ProjectRef = { id: 'project-1', path: '/repo' };
let fetchSourceEnabled = true;
let projectRoot = '/repo';
let gitStatus: {
current: string;
tracking: string | null;
ahead: number;
behind: number;
} | null = null;
let branchTracking: MockBranchTracking = {};
let gitFetchError: Error | null = null;
let gitFetchSuccess = true;
const gitFetchCalls: Array<{ directory: string; remote?: string; branch?: string }> = [];
const createdPayloads: CreateWorktreeArgs[] = [];
const toastWarnings: string[] = [];
mock.module('@/components/ui', () => ({
toast: {
warning: (message: string) => {
toastWarnings.push(message);
},
},
}));
mock.module('@/stores/useConfigStore', () => ({
useConfigStore: {
getState: () => ({ settingsWorktreeFetchSource: fetchSourceEnabled }),
},
}));
mock.module('@/lib/gitApi', () => ({
getGitStatus: () => (gitStatus ? Promise.resolve(gitStatus) : Promise.reject(new Error('no status'))),
getGitBranches: () => Promise.resolve({
all: [],
current: '',
branches: branchTracking.main
? { main: { current: false, name: 'main', commit: 'abc1234', label: '', tracking: branchTracking.main } }
: {},
}),
gitFetch: (directory: string, options: { remote?: string; branch?: string } = {}) => {
gitFetchCalls.push({ directory, ...options });
if (gitFetchError) {
return Promise.reject(gitFetchError);
}
return Promise.resolve({ success: gitFetchSuccess });
},
}));
mock.module('@/lib/worktrees/worktreeStatus', () => ({
resolveProjectRoot: (directory: string) => Promise.resolve(projectRoot || directory),
invalidateResolvedProjectRootCache: mock(),
getRootBranch: () => Promise.resolve(gitStatus?.current || 'HEAD'),
}));
mock.module('@/lib/worktrees/worktreeManager', () => ({
createWorktree: (_project: ProjectRef, args: CreateWorktreeArgs) => {
createdPayloads.push(args);
return Promise.resolve({
source: 'sdk',
name: 'wt',
path: '/repo/.oc/worktrees/wt',
projectDirectory: '/repo',
branch: 'wt',
label: 'wt',
worktreeRoot: '/repo/.oc/worktrees/wt',
worktreeStatus: 'ready',
headState: 'branch',
worktreeSource: 'created-for-session',
});
},
}));
const { createWorktreeWithDefaults, withWorktreeFetchedStartRef } = await import('./worktreeCreate');
const baseArgs = (overrides: CreateWorktreeArgs = {}): CreateWorktreeArgs => ({
preferredName: 'openchamber/feature',
mode: 'new',
branchName: 'openchamber/feature',
worktreeName: 'openchamber/feature',
...overrides,
});
describe('withWorktreeFetchedStartRef', () => {
beforeEach(() => {
fetchSourceEnabled = true;
projectRoot = '/repo';
gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 0 };
branchTracking = {};
gitFetchError = null;
gitFetchSuccess = true;
gitFetchCalls.length = 0;
createdPayloads.length = 0;
toastWarnings.length = 0;
});
test('bases the new worktree on the fetched remote-tracking ref', async () => {
gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 };
const args = await withWorktreeFetchedStartRef(project, baseArgs());
expect(args.startRef).toBe('remotes/origin/main');
expect(gitFetchCalls).toEqual([{ directory: '/repo', remote: 'origin', branch: 'main' }]);
});
test('fetches the branch the explicit start ref names when it is the current branch', async () => {
gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 3 };
const args = await withWorktreeFetchedStartRef(project, baseArgs({ startRef: 'main' }));
expect(args.startRef).toBe('remotes/origin/main');
expect(gitFetchCalls).toHaveLength(1);
});
test('falls back to the original args when the fetch fails', async () => {
gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 };
gitFetchError = new Error('network down');
const args = baseArgs();
const resolved = await withWorktreeFetchedStartRef(project, args);
expect(resolved).toBe(args);
expect(resolved.startRef).toBe(undefined);
expect(gitFetchCalls).toHaveLength(1);
expect(toastWarnings).toHaveLength(1);
});
test('falls back to the original args when the fetch resolves unsuccessful', async () => {
gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 };
gitFetchSuccess = false;
const args = baseArgs();
const resolved = await withWorktreeFetchedStartRef(project, args);
expect(resolved).toBe(args);
expect(resolved.startRef).toBe(undefined);
expect(gitFetchCalls).toHaveLength(1);
expect(toastWarnings).toHaveLength(1);
});
test('does not fetch when disabled in config', async () => {
fetchSourceEnabled = false;
const args = baseArgs();
const resolved = await withWorktreeFetchedStartRef(project, args);
expect(resolved).toBe(args);
expect(gitFetchCalls).toHaveLength(0);
expect(toastWarnings).toHaveLength(0);
});
test('does not fetch when the base branch has local-only commits', async () => {
gitStatus = { current: 'main', tracking: 'origin/main', ahead: 2, behind: 15 };
const args = baseArgs();
const resolved = await withWorktreeFetchedStartRef(project, args);
expect(resolved).toBe(args);
expect(gitFetchCalls).toHaveLength(0);
});
test('does not fetch without upstream tracking', async () => {
gitStatus = { current: 'main', tracking: null, ahead: 0, behind: 0 };
const args = baseArgs();
const resolved = await withWorktreeFetchedStartRef(project, args);
expect(resolved).toBe(args);
expect(gitFetchCalls).toHaveLength(0);
});
test('does not fetch when git status is unavailable', async () => {
gitStatus = null;
const args = baseArgs();
const resolved = await withWorktreeFetchedStartRef(project, args);
expect(resolved).toBe(args);
expect(gitFetchCalls).toHaveLength(0);
});
test('keeps an explicit non-root local start ref untouched', async () => {
gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 };
const args = baseArgs({ startRef: 'release/1.2' });
const resolved = await withWorktreeFetchedStartRef(project, args);
expect(resolved).toBe(args);
expect(gitFetchCalls).toHaveLength(0);
});
test('keeps an explicit remote start ref untouched', async () => {
const args = baseArgs({ startRef: 'remotes/origin/main' });
const resolved = await withWorktreeFetchedStartRef(project, args);
expect(resolved).toBe(args);
expect(gitFetchCalls).toHaveLength(0);
});
test('keeps a commit SHA start ref untouched', async () => {
const sha = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0';
const args = baseArgs({ startRef: sha });
const resolved = await withWorktreeFetchedStartRef(project, args);
expect(resolved).toBe(args);
expect(gitFetchCalls).toHaveLength(0);
});
test('does not fetch in existing mode', async () => {
const args = baseArgs({ mode: 'existing', existingBranch: 'origin/feature' });
const resolved = await withWorktreeFetchedStartRef(project, args);
expect(resolved).toBe(args);
expect(gitFetchCalls).toHaveLength(0);
});
test('does not fetch on a detached root checkout', async () => {
gitStatus = { current: '', tracking: null, ahead: 0, behind: 0 };
const args = baseArgs();
const resolved = await withWorktreeFetchedStartRef(project, args);
expect(resolved).toBe(args);
expect(gitFetchCalls).toHaveLength(0);
});
test('reads the root checkout state, not the project directory state', async () => {
projectRoot = '/primary';
gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 };
const args = await withWorktreeFetchedStartRef(project, baseArgs());
expect(args.startRef).toBe('remotes/origin/main');
expect(gitFetchCalls).toEqual([{ directory: '/repo', remote: 'origin', branch: 'main' }]);
});
});
describe('createWorktreeWithDefaults fetch integration', () => {
beforeEach(() => {
fetchSourceEnabled = true;
projectRoot = '/repo';
gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 0 };
branchTracking = { main: 'origin/main' };
gitFetchError = null;
gitFetchSuccess = true;
gitFetchCalls.length = 0;
createdPayloads.length = 0;
toastWarnings.length = 0;
});
test('sets the new branch\'s own upstream when refreshed from remote', async () => {
gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 };
await createWorktreeWithDefaults(project, baseArgs());
expect(createdPayloads).toHaveLength(1);
expect(createdPayloads[0].startRef).toBe('remotes/origin/main');
expect(createdPayloads[0].setUpstream).toBe(true);
expect(createdPayloads[0].upstreamRemote).toBe('origin');
expect(createdPayloads[0].upstreamBranch).toBe('openchamber/feature');
});
test('keeps upstream defaults when the start ref is untouched', async () => {
gitStatus = { current: 'main', tracking: 'origin/main', ahead: 2, behind: 0 };
await createWorktreeWithDefaults(project, baseArgs());
expect(createdPayloads).toHaveLength(1);
expect(createdPayloads[0].startRef).toBe(undefined);
expect(createdPayloads[0].setUpstream).toBe(true);
expect(createdPayloads[0].upstreamRemote).toBe('origin');
expect(createdPayloads[0].upstreamBranch).toBe('openchamber/feature');
});
test('keeps upstream defaults when the fetch falls back', async () => {
gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 };
gitFetchError = new Error('network down');
await createWorktreeWithDefaults(project, baseArgs());
expect(createdPayloads).toHaveLength(1);
expect(createdPayloads[0].startRef).toBe(undefined);
expect(createdPayloads[0].setUpstream).toBe(true);
expect(createdPayloads[0].upstreamRemote).toBe('origin');
expect(createdPayloads[0].upstreamBranch).toBe('openchamber/feature');
});
test('passes an explicit remote start ref through with upstream defaults as before', async () => {
await createWorktreeWithDefaults(project, baseArgs({ startRef: 'remotes/origin/main' }));
expect(createdPayloads).toHaveLength(1);
expect(createdPayloads[0].startRef).toBe('remotes/origin/main');
expect(createdPayloads[0].setUpstream).toBe(true);
expect(createdPayloads[0].upstreamRemote).toBe('origin');
expect(createdPayloads[0].upstreamBranch).toBe('openchamber/feature');
expect(gitFetchCalls).toHaveLength(0);
});
});