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
+15 -3
View File
@@ -4295,6 +4295,7 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) {
const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
if (parsedRemoteStartRef) {
worktreeAddArgs.splice(2, 0, '--no-track');
inferredUpstream = {
remote: parsedRemoteStartRef.remote,
branch: parsedRemoteStartRef.branch,
@@ -4309,17 +4310,28 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) {
if (mode === 'new') {
const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
if (parsedRemoteStartRef) {
await fetchRemoteBranchRef(context.primaryWorktree, parsedRemoteStartRef.remote, parsedRemoteStartRef.branch);
try {
await fetchRemoteBranchRef(context.primaryWorktree, parsedRemoteStartRef.remote, parsedRemoteStartRef.branch);
} catch (error) {
const refExists = await runGitCommand(
context.primaryWorktree,
['show-ref', '--verify', '--quiet', parsedRemoteStartRef.fullRef]
);
if (!refExists.success) {
throw error;
}
console.warn(`Worktree create: failed to refresh ${parsedRemoteStartRef.remote}/${parsedRemoteStartRef.branch}, proceeding with the existing remote-tracking ref`);
}
}
}
await runGitCommandOrThrow(context.primaryWorktree, worktreeAddArgs, 'Failed to create git worktree');
const upstreamRemote = shouldSetUpstream
? String(inferredUpstream?.remote || input?.upstreamRemote || '').trim()
? String(input?.upstreamRemote || inferredUpstream?.remote || '').trim()
: '';
const upstreamBranch = shouldSetUpstream
? String(inferredUpstream?.branch || input?.upstreamBranch || '').trim()
? String(input?.upstreamBranch || inferredUpstream?.branch || '').trim()
: '';
const bootstrapStatus = setWorktreeBootstrapState(
+135
View File
@@ -55,6 +55,14 @@ const runGit = (cwd, args) =>
stdio: ['ignore', 'pipe', 'pipe'],
});
const readBranchConfig = (cwd, branch, key) => {
try {
return runGit(cwd, ['config', '--get', `branch.${branch}.${key}`]).trim();
} catch {
return '';
}
};
/**
* A repository on `next` whose only remote publishes `defaultBranch` and has it
* recorded as that remote's HEAD — the shape of every repository whose default
@@ -855,6 +863,133 @@ describe('createWorktree', () => {
}
}
});
it('does not auto-track the remote start ref when creating a new branch from it', async () => {
if (!canRunGit()) return;
const previousXdgDataHome = process.env.XDG_DATA_HOME;
const dataHome = createTempDir();
process.env.XDG_DATA_HOME = dataHome;
try {
const { repository } = createRepositoryWithRemote({ defaultBranch: 'main' });
const created = await createWorktree(repository, {
mode: 'new',
branchName: 'openchamber/feature',
worktreeName: 'feature-wt',
startRef: 'remotes/origin/main',
setUpstream: true,
upstreamRemote: 'origin',
upstreamBranch: 'openchamber/feature',
});
expect(created.branch).toBe('openchamber/feature');
await expect.poll(
() => getWorktreeBootstrapStatus(created.path).then((status) => status.status === 'ready' || status.status === 'failed'),
{ timeout: 5_000 }
).toBe(true);
expect(readBranchConfig(created.path, 'openchamber/feature', 'remote')).toBe('');
expect(readBranchConfig(created.path, 'openchamber/feature', 'merge')).toBe('');
} finally {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;
} else {
process.env.XDG_DATA_HOME = previousXdgDataHome;
}
}
}, 30_000);
it('falls back to the remote start ref for upstream tracking when no explicit keys are given', async () => {
if (!canRunGit()) return;
const previousXdgDataHome = process.env.XDG_DATA_HOME;
const dataHome = createTempDir();
process.env.XDG_DATA_HOME = dataHome;
try {
const { repository } = createRepositoryWithRemote({ defaultBranch: 'main' });
const created = await createWorktree(repository, {
mode: 'new',
branchName: 'openchamber/fallback-wt',
worktreeName: 'fallback-wt',
startRef: 'remotes/origin/main',
setUpstream: true,
});
await expect.poll(
() => readBranchConfig(created.path, 'openchamber/fallback-wt', 'merge'),
{ timeout: 5_000 }
).toBe('refs/heads/main');
expect(readBranchConfig(created.path, 'openchamber/fallback-wt', 'remote')).toBe('origin');
} finally {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;
} else {
process.env.XDG_DATA_HOME = previousXdgDataHome;
}
}
}, 30_000);
it('creates from a remote start ref when the refresh fetch fails but the ref exists locally', async () => {
if (!canRunGit()) return;
const previousXdgDataHome = process.env.XDG_DATA_HOME;
const dataHome = createTempDir();
process.env.XDG_DATA_HOME = dataHome;
try {
const { repository } = createRepositoryWithRemote({ defaultBranch: 'main' });
runGit(repository, ['remote', 'set-url', 'origin', '/nonexistent/openchamber-unreachable.git']);
const created = await createWorktree(repository, {
mode: 'new',
branchName: 'openchamber/stale-ref-wt',
worktreeName: 'stale-ref-wt',
startRef: 'remotes/origin/main',
});
expect(created.branch).toBe('openchamber/stale-ref-wt');
const expectedHead = runGit(repository, ['rev-parse', 'refs/remotes/origin/main']).trim();
expect(runGit(created.path, ['rev-parse', 'HEAD']).trim()).toBe(expectedHead);
} finally {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;
} else {
process.env.XDG_DATA_HOME = previousXdgDataHome;
}
}
}, 30_000);
it('rejects creation from a remote start ref that was never fetched and cannot be fetched', async () => {
if (!canRunGit()) return;
const previousXdgDataHome = process.env.XDG_DATA_HOME;
const dataHome = createTempDir();
process.env.XDG_DATA_HOME = dataHome;
try {
const { repository } = createRepositoryWithRemote({ defaultBranch: 'main' });
runGit(repository, ['update-ref', '-d', 'refs/remotes/origin/main']);
runGit(repository, ['remote', 'set-url', 'origin', '/nonexistent/openchamber-unreachable.git']);
await expect(createWorktree(repository, {
mode: 'new',
branchName: 'openchamber/never-fetched-wt',
worktreeName: 'never-fetched-wt',
startRef: 'remotes/origin/main',
})).rejects.toThrow(/does not appear to be a git repository|Could not read from remote repository/i);
} finally {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;
} else {
process.env.XDG_DATA_HOME = previousXdgDataHome;
}
}
}, 30_000);
});
// ---------------------------------------------------------------------------
@@ -480,6 +480,9 @@ export const createSettingsHelpers = (dependencies) => {
if (typeof candidate.autoCreateWorktree === 'boolean') {
result.autoCreateWorktree = candidate.autoCreateWorktree;
}
if (typeof candidate.worktreeFetchSource === 'boolean') {
result.worktreeFetchSource = candidate.worktreeFetchSource;
}
if (typeof candidate.gitmojiEnabled === 'boolean') {
result.gitmojiEnabled = candidate.gitmojiEnabled;
}