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
+2 -1
View File
@@ -49,7 +49,7 @@ The following functions are exported and used by the web server:
### Worktree Operations
- `getWorktrees(directory)`: List all git worktrees for a repository.
- `validateWorktreeCreate(directory, input)`: Validate worktree creation parameters (mode, branchName, startRef, upstream config).
- `createWorktree(directory, input)`: Create a new worktree (supports 'new' and 'existing' modes, upstream setup). After populating the worktree, the repository's `post-checkout` hook runs once with git's standard arguments (null ref as previous HEAD, the checked-out HEAD, and flag `1`) from the worktree directory, mirroring `git worktree add` without `--no-checkout`; a missing or non-executable hook is skipped and a failing hook is logged as a warning, never failing worktree creation or the session bootstrap.
- `createWorktree(directory, input)`: Create a new worktree (supports 'new' and 'existing' modes, upstream setup). When the current tracked branch has no unpublished commits, the UI supplies its remote-tracking ref and this operation fetches that branch once before creating the worktree. A failed fetch falls back to the local branch and reports `sourceFetchFailed`; other remote start refs still require an existing local ref when their fetch fails. After populating the worktree, the repository's `post-checkout` hook runs once with git's standard arguments (null ref as previous HEAD, the checked-out HEAD, and flag `1`) from the worktree directory, mirroring `git worktree add` without `--no-checkout`; a missing or non-executable hook is skipped and a failing hook is logged as a warning, never failing worktree creation or the session bootstrap.
- `removeWorktree(directory, input)`: Remove a worktree (optionally delete local branch).
- `isLinkedWorktree(directory)`: Check if directory is a linked worktree (not primary).
@@ -143,6 +143,7 @@ The following functions are internal helpers used by exported functions:
- `path`: Absolute path to worktree directory.
- `directoryCreated`: Present when create returned after the target directory exists while background Git/bootstrap work continues.
- `bootstrapStatus`: Background setup state. The legacy `status` remains `pending`, `ready`, or `failed`, while `phase` reports `directory-created`, `git-ready`, or `setup-ready`. Fast create starts at `pending`/`directory-created`; population and upstream Git completion advances to `pending`/`git-ready` before setup/start scripts; completed setup is `ready`/`setup-ready`. A missing in-memory state falls back to `ready`/`setup-ready`; clients continue to accept legacy status responses that omit `phase`.
- `sourceFetchFailed`: Present when the automatic source-branch fetch failed and creation fell back to the tracked local branch.
- Fast-create background failures remove OpenCode sandbox metadata for directories that never became Git worktrees, and remove the pre-created directory only if it is still empty. User-created files are never recursively deleted by this cleanup.
- Worktree removal waits for any active create/bootstrap task for that directory before deleting it, preventing a background Git or setup task from restoring removed state or racing filesystem cleanup.
- Worktree bootstrap retries transient `index.lock` conflicts. If the lock remains byte-for-byte and metadata-identical across the retry window, it is treated as stale, removed, and population continues automatically; changing locks are left untouched and reported as failures.
+65 -26
View File
@@ -4303,28 +4303,10 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) {
}
}
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(`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
@@ -4366,6 +4348,50 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) {
};
}
const prepareWorktreeCreateSource = async (context, input = {}) => {
if (input?.mode === 'existing') {
return { input, sourceFetchFailed: false };
}
const startRef = normalizeStartRef(input?.startRef);
const remoteStartRef = await resolveRemoteBranchRef(context.primaryWorktree, startRef);
if (!remoteStartRef) {
return { input, sourceFetchFailed: false };
}
const status = await getStatus(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) {
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(`Worktree create: failed to refresh ${remoteStartRef.remote}/${remoteStartRef.branch}, proceeding with the existing remote-tracking ref`);
return { input, sourceFetchFailed: false };
}
};
export async function createWorktree(directory, input = {}) {
const mode = input?.mode === 'existing' ? 'existing' : 'new';
const context = await resolveWorktreeProjectContext(directory);
@@ -4374,10 +4400,18 @@ export async function createWorktree(directory, input = {}) {
await assertWorktreeCreatePreflight(directory, input);
}
const ensureRemoteName = String(input?.ensureRemoteName || '').trim();
const ensureRemoteUrl = String(input?.ensureRemoteUrl || '').trim();
if (ensureRemoteName && ensureRemoteUrl) {
await ensureRemoteWithUrl(context.primaryWorktree, ensureRemoteName, ensureRemoteUrl);
}
const prepared = await prepareWorktreeCreateSource(context, input);
const preparedInput = prepared.input;
await fsp.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,
@@ -4386,7 +4420,7 @@ export async function createWorktree(directory, input = {}) {
context.primaryWorktree
);
if (input?.returnAfterDirectoryCreated === true) {
if (preparedInput?.returnAfterDirectoryCreated === true) {
await fsp.mkdir(candidate.directory, { recursive: false });
const bootstrapStatus = setWorktreeBootstrapState(
@@ -4395,10 +4429,10 @@ export async function createWorktree(directory, input = {}) {
WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED
);
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) => {
setWorktreeBootstrapState(
candidate.directory,
WORKTREE_BOOTSTRAP_FAILED,
@@ -4410,7 +4444,7 @@ export async function createWorktree(directory, input = {}) {
});
trackWorktreeBootstrapTask(candidate.directory, task);
return {
const result = {
head: '',
name: candidate.name,
branch: localBranch,
@@ -4418,9 +4452,14 @@ export async function createWorktree(directory, input = {}) {
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) {
+4 -2
View File
@@ -934,7 +934,7 @@ describe('createWorktree', () => {
}
}, 30_000);
it('creates from a remote start ref when the refresh fetch fails but the ref exists locally', 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;
@@ -943,6 +943,7 @@ describe('createWorktree', () => {
try {
const { repository } = createRepositoryWithRemote({ defaultBranch: 'main' });
runGit(repository, ['branch', '--set-upstream-to=origin/main', 'next']);
runGit(repository, ['remote', 'set-url', 'origin', '/nonexistent/openchamber-unreachable.git']);
const created = await createWorktree(repository, {
@@ -953,7 +954,8 @@ describe('createWorktree', () => {
});
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) {
@@ -480,9 +480,6 @@ 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;
}