fix(worktrees): remove worktrees in the background (#3319)

* refactor(worktrees): fetch source once during creation

* fix(worktrees): remove worktrees in background

* fix(worktrees): show background removal progress

* fix(worktrees): name the worktree in removal toasts
This commit is contained in:
𝖎𝖚𝖑𝖎𝖎𝖆
2026-09-03 15:17:28 +03:00
committed by GitHub
parent 5995802fe3
commit 0d8709a72e
43 changed files with 326 additions and 447 deletions
@@ -7,7 +7,6 @@ interface MockBranchTracking {
const project: ProjectRef = { id: 'project-1', path: '/repo' };
let fetchSourceEnabled = true;
let projectRoot = '/repo';
let gitStatus: {
current: string;
@@ -16,25 +15,7 @@ let gitStatus: {
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'))),
@@ -45,13 +26,6 @@ mock.module('@/lib/gitApi', () => ({
? { 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', () => ({
@@ -78,7 +52,7 @@ mock.module('@/lib/worktrees/worktreeManager', () => ({
},
}));
const { createWorktreeWithDefaults, withWorktreeFetchedStartRef } = await import('./worktreeCreate');
const { createWorktreeWithDefaults, withWorktreeRemoteStartRef } = await import('./worktreeCreate');
const baseArgs = (overrides: CreateWorktreeArgs = {}): CreateWorktreeArgs => ({
preferredName: 'openchamber/feature',
@@ -88,174 +62,116 @@ const baseArgs = (overrides: CreateWorktreeArgs = {}): CreateWorktreeArgs => ({
...overrides,
});
describe('withWorktreeFetchedStartRef', () => {
describe('withWorktreeRemoteStartRef', () => {
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 () => {
test('selects the tracked remote ref for the runtime to fetch', async () => {
gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 };
const args = await withWorktreeFetchedStartRef(project, baseArgs());
const args = await withWorktreeRemoteStartRef(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 () => {
test('selects the tracked remote ref when the explicit start ref names the current branch', async () => {
gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 3 };
const args = await withWorktreeFetchedStartRef(project, baseArgs({ startRef: 'main' }));
const args = await withWorktreeRemoteStartRef(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 () => {
test('keeps the local base when it has unpublished commits', async () => {
gitStatus = { current: 'main', tracking: 'origin/main', ahead: 2, behind: 15 };
const args = baseArgs();
const resolved = await withWorktreeFetchedStartRef(project, args);
const resolved = await withWorktreeRemoteStartRef(project, args);
expect(resolved).toBe(args);
expect(gitFetchCalls).toHaveLength(0);
});
test('does not fetch without upstream tracking', async () => {
test('keeps the local base without upstream tracking', async () => {
gitStatus = { current: 'main', tracking: null, ahead: 0, behind: 0 };
const args = baseArgs();
const resolved = await withWorktreeFetchedStartRef(project, args);
const resolved = await withWorktreeRemoteStartRef(project, args);
expect(resolved).toBe(args);
expect(gitFetchCalls).toHaveLength(0);
});
test('does not fetch when git status is unavailable', async () => {
test('keeps the requested base when git status is unavailable', async () => {
gitStatus = null;
const args = baseArgs();
const resolved = await withWorktreeFetchedStartRef(project, args);
const resolved = await withWorktreeRemoteStartRef(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);
const resolved = await withWorktreeRemoteStartRef(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);
const resolved = await withWorktreeRemoteStartRef(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);
const resolved = await withWorktreeRemoteStartRef(project, args);
expect(resolved).toBe(args);
expect(gitFetchCalls).toHaveLength(0);
});
test('does not fetch in existing mode', async () => {
test('keeps the requested base in existing mode', async () => {
const args = baseArgs({ mode: 'existing', existingBranch: 'origin/feature' });
const resolved = await withWorktreeFetchedStartRef(project, args);
const resolved = await withWorktreeRemoteStartRef(project, args);
expect(resolved).toBe(args);
expect(gitFetchCalls).toHaveLength(0);
});
test('does not fetch on a detached root checkout', async () => {
test('keeps the requested base on a detached root checkout', async () => {
gitStatus = { current: '', tracking: null, ahead: 0, behind: 0 };
const args = baseArgs();
const resolved = await withWorktreeFetchedStartRef(project, args);
const resolved = await withWorktreeRemoteStartRef(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());
const args = await withWorktreeRemoteStartRef(project, baseArgs());
expect(args.startRef).toBe('remotes/origin/main');
expect(gitFetchCalls).toEqual([{ directory: '/repo', remote: 'origin', branch: 'main' }]);
});
});
describe('createWorktreeWithDefaults fetch integration', () => {
describe('createWorktreeWithDefaults remote source 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 () => {
test('sets the new branch\'s own upstream when using the tracked remote source', async () => {
gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 };
await createWorktreeWithDefaults(project, baseArgs());
@@ -279,19 +195,6 @@ describe('createWorktreeWithDefaults fetch integration', () => {
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' }));
@@ -300,6 +203,5 @@ describe('createWorktreeWithDefaults fetch integration', () => {
expect(createdPayloads[0].setUpstream).toBe(true);
expect(createdPayloads[0].upstreamRemote).toBe('origin');
expect(createdPayloads[0].upstreamBranch).toBe('openchamber/feature');
expect(gitFetchCalls).toHaveLength(0);
});
});