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:
@@ -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".
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user