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
+1
View File
@@ -23,6 +23,7 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t
- `gitService.ts`
- Owns VS Code Git and worktree operations.
- Fetches the current tracked source branch once before worktree creation. Fetch failure falls back to the local branch and reports it to the shared UI.
- Fast worktree creation reports bootstrap phases explicitly: `directory-created`, then `git-ready` after Git population/upstream work, and `setup-ready` after setup commands. Existing worktrees without tracked bootstrap state fall back to `ready`/`setup-ready`; shared webview consumers also accept legacy responses without `phase`.
- Worktree removal waits for an active create/bootstrap task for the same directory so background Git and setup work cannot race deletion or restore stale bootstrap state.
- Worktree population enables Git `core.longpaths` (local repo config plus `-c core.longpaths=true` on `git reset --hard`) so deeply nested checkouts under the managed data-dir worktree root do not fail on Windows MAX_PATH with "Filename too long".
+70 -26
View File
@@ -860,6 +860,7 @@ export interface GitWorktreeInfo {
path: string;
directoryCreated?: true;
bootstrapStatus?: WorktreeBootstrapStatus;
sourceFetchFailed?: true;
}
type WorktreeListEntry = {
@@ -1987,6 +1988,7 @@ async function attachGitWorktreeToCandidate(
const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
if (parsedRemoteStartRef) {
worktreeAddArgs.splice(2, 0, '--no-track');
inferredUpstream = {
remote: parsedRemoteStartRef.remote,
branch: parsedRemoteStartRef.branch,
@@ -1994,28 +1996,10 @@ async function attachGitWorktreeToCandidate(
}
}
if (ensureRemoteName && ensureRemoteUrl) {
if (mode === 'existing' && ensureRemoteName && ensureRemoteUrl) {
await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl);
}
if (mode === 'new') {
const parsedRemoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
if (parsedRemoteStartRef) {
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(`[GitService] 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 shouldSetUpstream = Boolean(input?.setUpstream);
@@ -2059,6 +2043,59 @@ async function attachGitWorktreeToCandidate(
};
}
const prepareWorktreeCreateSource = async (
context: Awaited<ReturnType<typeof resolveWorktreeProjectContext>>,
input: CreateGitWorktreePayload,
): Promise<{ input: CreateGitWorktreePayload; sourceFetchFailed: boolean }> => {
if (input.mode === 'existing') {
return { input, sourceFetchFailed: false };
}
const ensureRemoteName = String(input.ensureRemoteName || '').trim();
const ensureRemoteUrl = String(input.ensureRemoteUrl || '').trim();
if (ensureRemoteName && ensureRemoteUrl) {
await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl);
}
const startRef = normalizeStartRef(input.startRef);
const remoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
if (!remoteStartRef) {
return { input, sourceFetchFailed: false };
}
const status = await getGitStatus(context.primaryWorktree, { mode: 'light' }).catch(() => null);
const trackingRef = status?.tracking
? await resolveRemoteBranchRef(context.primaryWorktree, status.tracking)
: null;
const canFallbackToLocal = Boolean(
status?.current
&& status.ahead === 0
&& trackingRef?.fullRef === remoteStartRef.fullRef
);
try {
await fetchRemoteBranchRef(context.primaryWorktree, remoteStartRef.remote, remoteStartRef.branch);
return { input, sourceFetchFailed: false };
} catch (error) {
if (canFallbackToLocal && status?.current) {
return {
input: { ...input, startRef: status.current },
sourceFetchFailed: true,
};
}
const refExists = await runGitCommand(
context.primaryWorktree,
['show-ref', '--verify', '--quiet', remoteStartRef.fullRef],
);
if (!refExists.success) {
throw error;
}
console.warn(`[GitService] failed to refresh ${remoteStartRef.remote}/${remoteStartRef.branch}, proceeding with the existing remote-tracking ref`);
return { input, sourceFetchFailed: false };
}
};
export async function createWorktree(directory: string, input: CreateGitWorktreePayload = {}): Promise<GitWorktreeInfo> {
const mode = input?.mode === 'existing' ? 'existing' : 'new';
const context = await resolveWorktreeProjectContext(directory);
@@ -2066,11 +2103,13 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
if (input?.returnAfterDirectoryCreated === true) {
await assertWorktreeCreatePreflight(directory, input);
}
const prepared = await prepareWorktreeCreateSource(context, input);
const preparedInput = prepared.input;
await fs.promises.mkdir(context.worktreeRoot, { recursive: true });
const preferredName = String(input?.worktreeName || input?.name || '').trim();
const preferredBranchName = cleanBranchName(String(input?.branchName || '').trim());
const preferredName = String(preparedInput.worktreeName || preparedInput.name || '').trim();
const preferredBranchName = cleanBranchName(String(preparedInput.branchName || '').trim());
const candidate = await resolveCandidateDirectory(
context.worktreeRoot,
@@ -2079,7 +2118,7 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
context.primaryWorktree
);
if (input?.returnAfterDirectoryCreated === true) {
if (preparedInput.returnAfterDirectoryCreated === true) {
await fs.promises.mkdir(candidate.directory, { recursive: false });
const bootstrapStatus = setWorktreeBootstrapState(
@@ -2093,17 +2132,17 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
updatedAt: Date.now(),
};
const localBranch = mode === 'existing'
? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim())
? cleanBranchName(String(preparedInput.branchName || preparedInput.existingBranch || candidate.branch || '').trim())
: candidate.branch;
const task = attachGitWorktreeToCandidate(context, candidate, input).catch(async (error) => {
const task = attachGitWorktreeToCandidate(context, candidate, preparedInput).catch(async (error) => {
setWorktreeBootstrapFailure(candidate.directory, error);
await cleanupFailedFastWorktreeCreate(context, candidate);
console.warn('[GitService] Background worktree creation failed:', error instanceof Error ? error.message : String(error));
});
trackWorktreeBootstrapTask(candidate.directory, task);
return {
const result: GitWorktreeInfo = {
head: '',
name: candidate.name,
branch: localBranch,
@@ -2111,9 +2150,14 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
directoryCreated: true,
bootstrapStatus,
};
if (prepared.sourceFetchFailed) {
result.sourceFetchFailed = true;
}
return result;
}
return attachGitWorktreeToCandidate(context, candidate, input);
const result = await attachGitWorktreeToCandidate(context, candidate, preparedInput);
return prepared.sourceFetchFailed ? { ...result, sourceFetchFailed: true } : result;
}
export async function getWorktreeBootstrapStatus(directory: string): Promise<WorktreeBootstrapStatus> {
@@ -58,7 +58,7 @@ afterEach(() => {
});
describe('VS Code worktree create from a remote start ref', () => {
it('creates from the existing remote-tracking ref when the refresh fetch fails', async () => {
it('falls back to the tracked local branch when the source fetch fails', async () => {
if (!canRunGit()) return;
const previousXdgDataHome = process.env.XDG_DATA_HOME;
@@ -66,6 +66,7 @@ describe('VS Code worktree create from a remote start ref', () => {
try {
const { repository } = createRepositoryWithRemote();
runGit(repository, ['branch', '--set-upstream-to=origin/main', 'next']);
runGit(repository, ['remote', 'set-url', 'origin', '/nonexistent/openchamber-unreachable.git']);
const created = await createWorktree(repository, {
@@ -76,7 +77,8 @@ describe('VS Code worktree create from a remote start ref', () => {
});
expect(created.branch).toBe('openchamber/stale-ref-wt');
const expectedHead = runGit(repository, ['rev-parse', 'refs/remotes/origin/main']).trim();
expect(created.sourceFetchFailed).toBe(true);
const expectedHead = runGit(repository, ['rev-parse', 'next']).trim();
expect(runGit(created.path, ['rev-parse', 'HEAD']).trim()).toBe(expectedHead);
} finally {
if (previousXdgDataHome === undefined) {