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