feat: move sessions to new worktrees
Add a root-session action that creates a generated worktree from the session directory's current branch, transfers uncommitted changes, and moves the parent session plus its descendants through OpenCode's control-plane API. Reuse existing project/worktree topology and quick-create behavior, keep the UI non-blocking, reconcile live and global session state across directories, and roll back partial moves and failed worktree creation safely. Split worktree bootstrap readiness into directory-created, git-ready, and setup-ready phases across web and VS Code. Session moves wait for Git readiness while existing setup-aware flows continue waiting for full setup completion, and worktree removal is serialized with active bootstrap tasks. Expose the move only for idle root sessions, show localized progress and explanatory tooltips in the sidebar, and keep pending/ready worktree metadata synchronized with authoritative session attachments to avoid stale setup indicators. Add coverage for control-plane payloads, session-state migration, bootstrap phase ordering and compatibility, removal races, progress metadata, and fast-ready attachment races.
This commit is contained in:
@@ -14,11 +14,23 @@ import type { API as GitAPI, Repository, GitExtension, Status } from './git.d';
|
||||
|
||||
let gitApi: GitAPI | null = null;
|
||||
let gitExtensionEnabled = false;
|
||||
const worktreeBootstrapState = new Map<string, { status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }>();
|
||||
|
||||
type WorktreeBootstrapStatus = {
|
||||
status: 'pending' | 'ready' | 'failed';
|
||||
phase: 'directory-created' | 'git-ready' | 'setup-ready';
|
||||
error: string | null;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
const worktreeBootstrapState = new Map<string, WorktreeBootstrapStatus>();
|
||||
const activeWorktreeBootstrapTasks = new Map<string, Promise<unknown>>();
|
||||
|
||||
const WORKTREE_BOOTSTRAP_PENDING = 'pending' as const;
|
||||
const WORKTREE_BOOTSTRAP_READY = 'ready' as const;
|
||||
const WORKTREE_BOOTSTRAP_FAILED = 'failed' as const;
|
||||
const WORKTREE_PHASE_DIRECTORY_CREATED = 'directory-created' as const;
|
||||
const WORKTREE_PHASE_GIT_READY = 'git-ready' as const;
|
||||
const WORKTREE_PHASE_SETUP_READY = 'setup-ready' as const;
|
||||
const WORKTREE_INDEX_LOCK_RETRY_DELAY_MS = 250;
|
||||
const WORKTREE_INDEX_LOCK_STALE_DELAY_MS = 750;
|
||||
|
||||
@@ -30,16 +42,35 @@ const toBootstrapStateKey = (directory: string): string => {
|
||||
return path.resolve(normalized);
|
||||
};
|
||||
|
||||
const setWorktreeBootstrapState = (directory: string, status: 'pending' | 'ready' | 'failed', error: string | null = null): void => {
|
||||
const setWorktreeBootstrapState = (
|
||||
directory: string,
|
||||
status: WorktreeBootstrapStatus['status'],
|
||||
phase: WorktreeBootstrapStatus['phase'],
|
||||
error: string | null = null,
|
||||
): WorktreeBootstrapStatus | null => {
|
||||
const key = toBootstrapStateKey(directory);
|
||||
if (!key) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
worktreeBootstrapState.set(key, {
|
||||
|
||||
const state: WorktreeBootstrapStatus = {
|
||||
status,
|
||||
phase,
|
||||
error: typeof error === 'string' && error.trim().length > 0 ? error.trim() : null,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
};
|
||||
worktreeBootstrapState.set(key, state);
|
||||
return state;
|
||||
};
|
||||
|
||||
const setWorktreeBootstrapFailure = (directory: string, error: unknown): void => {
|
||||
const current = worktreeBootstrapState.get(toBootstrapStateKey(directory));
|
||||
setWorktreeBootstrapState(
|
||||
directory,
|
||||
WORKTREE_BOOTSTRAP_FAILED,
|
||||
current?.phase ?? WORKTREE_PHASE_DIRECTORY_CREATED,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
};
|
||||
|
||||
const clearWorktreeBootstrapState = (directory: string): void => {
|
||||
@@ -50,6 +81,36 @@ const clearWorktreeBootstrapState = (directory: string): void => {
|
||||
worktreeBootstrapState.delete(key);
|
||||
};
|
||||
|
||||
const trackWorktreeBootstrapTask = (directory: string, task: Promise<unknown>): void => {
|
||||
const key = toBootstrapStateKey(directory);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeWorktreeBootstrapTasks.set(key, task);
|
||||
const clearTask = () => {
|
||||
if (activeWorktreeBootstrapTasks.get(key) === task) {
|
||||
activeWorktreeBootstrapTasks.delete(key);
|
||||
}
|
||||
};
|
||||
void task.then(clearTask, clearTask);
|
||||
};
|
||||
|
||||
const waitForActiveWorktreeBootstrap = async (directory: string): Promise<void> => {
|
||||
const key = toBootstrapStateKey(directory);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const task = activeWorktreeBootstrapTasks.get(key);
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
await task.catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
|
||||
|
||||
@@ -780,7 +841,7 @@ export interface GitWorktreeInfo {
|
||||
branch: string;
|
||||
path: string;
|
||||
directoryCreated?: true;
|
||||
bootstrapStatus?: { status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number };
|
||||
bootstrapStatus?: WorktreeBootstrapStatus;
|
||||
}
|
||||
|
||||
type WorktreeListEntry = {
|
||||
@@ -1485,8 +1546,8 @@ const queueWorktreeBootstrap = (args: {
|
||||
ensureRemoteUrl,
|
||||
startCommand,
|
||||
} = args;
|
||||
setTimeout(() => {
|
||||
const run = async () => {
|
||||
const task = new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
.then(async () => {
|
||||
await populateWorktreeWithLockRecovery(directory);
|
||||
if (setUpstream) {
|
||||
await applyUpstreamConfiguration({
|
||||
@@ -1502,21 +1563,18 @@ const queueWorktreeBootstrap = (args: {
|
||||
console.warn('[GitService] Worktree upstream configuration failed:', error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
}
|
||||
setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_PENDING, WORKTREE_PHASE_GIT_READY);
|
||||
await runWorktreeStartScripts(directory, projectID, startCommand).catch((error) => {
|
||||
console.warn('[GitService] Worktree start script task failed:', error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_READY);
|
||||
};
|
||||
|
||||
void run().catch((error) => {
|
||||
setWorktreeBootstrapState(
|
||||
directory,
|
||||
WORKTREE_BOOTSTRAP_FAILED,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
setWorktreeBootstrapState(directory, WORKTREE_BOOTSTRAP_READY, WORKTREE_PHASE_SETUP_READY);
|
||||
})
|
||||
.catch((error) => {
|
||||
setWorktreeBootstrapFailure(directory, error);
|
||||
console.warn('[GitService] Worktree bootstrap task failed:', error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
}, 0);
|
||||
|
||||
trackWorktreeBootstrapTask(directory, task);
|
||||
};
|
||||
|
||||
const ensureRemoteWithUrl = async (primaryWorktree: string, remoteName: string, remoteUrl: string) => {
|
||||
@@ -1915,9 +1973,13 @@ async function attachGitWorktreeToCandidate(
|
||||
const upstreamRemote = String(input?.upstreamRemote || inferredUpstream?.remote || '').trim();
|
||||
const upstreamBranch = String(input?.upstreamBranch || inferredUpstream?.branch || '').trim();
|
||||
|
||||
setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING);
|
||||
const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? {
|
||||
const bootstrapStatus = setWorktreeBootstrapState(
|
||||
candidate.directory,
|
||||
WORKTREE_BOOTSTRAP_PENDING,
|
||||
WORKTREE_PHASE_DIRECTORY_CREATED,
|
||||
) ?? {
|
||||
status: WORKTREE_BOOTSTRAP_PENDING,
|
||||
phase: WORKTREE_PHASE_DIRECTORY_CREATED,
|
||||
error: null,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
@@ -1977,9 +2039,13 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
|
||||
console.warn('[GitService] Failed to sync OpenCode sandbox metadata (add):', error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
||||
setWorktreeBootstrapState(candidate.directory, WORKTREE_BOOTSTRAP_PENDING);
|
||||
const bootstrapStatus = worktreeBootstrapState.get(toBootstrapStateKey(candidate.directory)) ?? {
|
||||
const bootstrapStatus = setWorktreeBootstrapState(
|
||||
candidate.directory,
|
||||
WORKTREE_BOOTSTRAP_PENDING,
|
||||
WORKTREE_PHASE_DIRECTORY_CREATED,
|
||||
) ?? {
|
||||
status: WORKTREE_BOOTSTRAP_PENDING,
|
||||
phase: WORKTREE_PHASE_DIRECTORY_CREATED,
|
||||
error: null,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
@@ -1987,15 +2053,12 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
|
||||
? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim())
|
||||
: candidate.branch;
|
||||
|
||||
void attachGitWorktreeToCandidate(context, candidate, input).catch((error) => {
|
||||
setWorktreeBootstrapState(
|
||||
candidate.directory,
|
||||
WORKTREE_BOOTSTRAP_FAILED,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
void cleanupFailedFastWorktreeCreate(context, candidate);
|
||||
const task = attachGitWorktreeToCandidate(context, candidate, input).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 {
|
||||
head: '',
|
||||
@@ -2010,7 +2073,7 @@ export async function createWorktree(directory: string, input: CreateGitWorktree
|
||||
return attachGitWorktreeToCandidate(context, candidate, input);
|
||||
}
|
||||
|
||||
export async function getWorktreeBootstrapStatus(directory: string): Promise<{ status: 'pending' | 'ready' | 'failed'; error: string | null; updatedAt: number }> {
|
||||
export async function getWorktreeBootstrapStatus(directory: string): Promise<WorktreeBootstrapStatus> {
|
||||
const key = toBootstrapStateKey(directory);
|
||||
if (!key) {
|
||||
throw new Error('Worktree directory is required');
|
||||
@@ -2023,6 +2086,7 @@ export async function getWorktreeBootstrapStatus(directory: string): Promise<{ s
|
||||
|
||||
return {
|
||||
status: WORKTREE_BOOTSTRAP_READY,
|
||||
phase: WORKTREE_PHASE_SETUP_READY,
|
||||
error: null,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
@@ -2034,6 +2098,8 @@ export async function removeWorktree(directory: string, input: RemoveGitWorktree
|
||||
throw new Error('Worktree directory is required');
|
||||
}
|
||||
|
||||
await waitForActiveWorktreeBootstrap(targetDirectory);
|
||||
|
||||
const context = await resolveWorktreeProjectContext(directory);
|
||||
const deleteLocalBranch = input?.deleteLocalBranch === true;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user