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:
Bohdan Triapitsyn
2026-07-19 00:00:31 +03:00
parent e9d93a6744
commit 3fd6627196
31 changed files with 1322 additions and 151 deletions
+2 -1
View File
@@ -120,8 +120,9 @@ The following functions are internal helpers used by exported functions:
- `branch`: Local branch name.
- `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 status, with `pending`, `ready`, or `failed`.
- `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`.
- 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.
### Log Response
+84 -33
View File
@@ -12,6 +12,7 @@ const execFileAsync = promisify(execFile);
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
let resolvedGitBinary = null;
const worktreeBootstrapState = new Map();
const activeWorktreeBootstrapTasks = new Map();
const remoteExistenceCache = new Map();
const SIMPLE_GIT_SAFE_BINARY_PATTERN = /^([a-z]:)?([a-z0-9/.\\_~-]+)$/i;
const SIMPLE_GIT_UNSAFE_BINARY_WARNING = 'Invalid value supplied for custom binary, restricted characters must be removed';
@@ -21,6 +22,9 @@ const gitIndexMutationQueues = new Map();
const WORKTREE_BOOTSTRAP_PENDING = 'pending';
const WORKTREE_BOOTSTRAP_READY = 'ready';
const WORKTREE_BOOTSTRAP_FAILED = 'failed';
const WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED = 'directory-created';
const WORKTREE_BOOTSTRAP_PHASE_GIT_READY = 'git-ready';
const WORKTREE_BOOTSTRAP_PHASE_SETUP_READY = 'setup-ready';
const WORKTREE_INDEX_LOCK_RETRY_DELAY_MS = 250;
const WORKTREE_INDEX_LOCK_STALE_DELAY_MS = 750;
@@ -32,16 +36,21 @@ const toBootstrapStateKey = (directory) => {
return path.resolve(normalized);
};
const setWorktreeBootstrapState = (directory, status, error = null) => {
const createWorktreeBootstrapState = (status, phase, error = null) => ({
status,
phase,
error: typeof error === 'string' && error.trim().length > 0 ? error.trim() : null,
updatedAt: Date.now(),
});
const setWorktreeBootstrapState = (directory, status, phase, error = null) => {
const key = toBootstrapStateKey(directory);
if (!key) {
return;
return null;
}
worktreeBootstrapState.set(key, {
status,
error: typeof error === 'string' && error.trim().length > 0 ? error.trim() : null,
updatedAt: Date.now(),
});
const state = createWorktreeBootstrapState(status, phase, error);
worktreeBootstrapState.set(key, state);
return state;
};
const clearWorktreeBootstrapState = (directory) => {
@@ -52,6 +61,37 @@ const clearWorktreeBootstrapState = (directory) => {
worktreeBootstrapState.delete(key);
};
const trackWorktreeBootstrapTask = (directory, task) => {
const key = toBootstrapStateKey(directory);
if (!key) {
return task;
}
activeWorktreeBootstrapTasks.set(key, task);
const clearTask = () => {
if (activeWorktreeBootstrapTasks.get(key) === task) {
activeWorktreeBootstrapTasks.delete(key);
}
};
void task.then(clearTask, clearTask);
return task;
};
const waitForActiveWorktreeBootstrap = async (directory) => {
const key = toBootstrapStateKey(directory);
if (!key) {
return;
}
while (true) {
const task = activeWorktreeBootstrapTasks.get(key);
if (!task) {
return;
}
await task.catch(() => undefined);
}
};
const isExecutableFile = (candidate) => {
if (typeof candidate !== 'string' || candidate.trim().length === 0) {
return false;
@@ -1730,8 +1770,8 @@ const queueWorktreeBootstrap = (args) => {
ensureRemoteUrl,
startCommand,
} = args;
setTimeout(() => {
const run = async () => {
const task = new Promise((resolve) => setTimeout(resolve, 0))
.then(async () => {
await populateWorktreeWithLockRecovery(directory);
if (setUpstream) {
await applyUpstreamConfiguration({
@@ -1747,21 +1787,31 @@ const queueWorktreeBootstrap = (args) => {
console.warn('Worktree upstream configuration failed:', error instanceof Error ? error.message : String(error));
});
}
setWorktreeBootstrapState(
directory,
WORKTREE_BOOTSTRAP_PENDING,
WORKTREE_BOOTSTRAP_PHASE_GIT_READY
);
await runWorktreeStartScripts(directory, projectID, startCommand).catch((error) => {
console.warn('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_READY,
WORKTREE_BOOTSTRAP_PHASE_SETUP_READY
);
})
.catch((error) => {
setWorktreeBootstrapState(
directory,
WORKTREE_BOOTSTRAP_FAILED,
WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED,
error instanceof Error ? error.message : String(error)
);
console.warn('Worktree bootstrap task failed:', error instanceof Error ? error.message : String(error));
});
}, 0);
trackWorktreeBootstrapTask(directory, task);
};
const ensureRemoteWithUrl = async (primaryWorktree, remoteName, remoteUrl) => {
@@ -3756,12 +3806,11 @@ async function attachGitWorktreeToCandidate(context, candidate, input = {}) {
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)) ?? {
status: WORKTREE_BOOTSTRAP_PENDING,
error: null,
updatedAt: Date.now(),
};
const bootstrapStatus = setWorktreeBootstrapState(
candidate.directory,
WORKTREE_BOOTSTRAP_PENDING,
WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED
);
queueWorktreeBootstrap({
directory: candidate.directory,
@@ -3818,25 +3867,26 @@ export async function createWorktree(directory, input = {}) {
console.warn('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)) ?? {
status: WORKTREE_BOOTSTRAP_PENDING,
error: null,
updatedAt: Date.now(),
};
const bootstrapStatus = setWorktreeBootstrapState(
candidate.directory,
WORKTREE_BOOTSTRAP_PENDING,
WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED
);
const localBranch = mode === 'existing'
? cleanBranchName(String(input?.branchName || input?.existingBranch || candidate.branch || '').trim())
: candidate.branch;
void attachGitWorktreeToCandidate(context, candidate, input).catch((error) => {
const task = attachGitWorktreeToCandidate(context, candidate, input).catch(async (error) => {
setWorktreeBootstrapState(
candidate.directory,
WORKTREE_BOOTSTRAP_FAILED,
WORKTREE_BOOTSTRAP_PHASE_DIRECTORY_CREATED,
error instanceof Error ? error.message : String(error)
);
void cleanupFailedFastWorktreeCreate(context, candidate);
await cleanupFailedFastWorktreeCreate(context, candidate);
console.warn('Background worktree creation failed:', error instanceof Error ? error.message : String(error));
});
trackWorktreeBootstrapTask(candidate.directory, task);
return {
head: '',
@@ -3862,11 +3912,10 @@ export async function getWorktreeBootstrapStatus(directory) {
return current;
}
return {
status: WORKTREE_BOOTSTRAP_READY,
error: null,
updatedAt: Date.now(),
};
return createWorktreeBootstrapState(
WORKTREE_BOOTSTRAP_READY,
WORKTREE_BOOTSTRAP_PHASE_SETUP_READY
);
}
export async function removeWorktree(directory, input = {}) {
@@ -3875,6 +3924,8 @@ export async function removeWorktree(directory, input = {}) {
throw new Error('Worktree directory is required');
}
await waitForActiveWorktreeBootstrap(targetDirectory);
const context = await resolveWorktreeProjectContext(directory);
const deleteLocalBranch = input?.deleteLocalBranch === true;
+130
View File
@@ -9,6 +9,7 @@ import {
checkoutCommit,
cherryPick,
createWorktree,
getWorktreeBootstrapStatus,
getStatus,
populateWorktreeWithLockRecovery,
removeWorktree,
@@ -322,6 +323,135 @@ describe('worktree root resolution', () => {
// ---------------------------------------------------------------------------
describe('createWorktree', () => {
it('returns ready/setup-ready when no bootstrap state is recorded', async () => {
const directory = path.join(createTempDir(), 'missing-worktree');
await expect(getWorktreeBootstrapStatus(directory)).resolves.toMatchObject({
status: 'ready',
phase: 'setup-ready',
error: null,
});
});
it('reports directory, Git, and setup bootstrap phases while preserving legacy status', async () => {
if (!canRunGit()) return;
const previousXdgDataHome = process.env.XDG_DATA_HOME;
const dataHome = createTempDir();
const setupMarker = path.join(dataHome, 'setup-started');
const setupScript = path.join(dataHome, 'setup-phase.cjs');
process.env.XDG_DATA_HOME = dataHome;
fs.writeFileSync(
setupScript,
`require('node:fs').writeFileSync(${JSON.stringify(setupMarker)}, 'started'); setTimeout(() => {}, 1000);\n`,
);
try {
const repo = createTempDir();
runGit(repo, ['init', '-b', 'main']);
runGit(repo, ['config', 'user.email', 'test@example.com']);
runGit(repo, ['config', 'user.name', 'Test User']);
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
runGit(repo, ['add', 'README.md']);
runGit(repo, ['commit', '-m', 'Initial commit']);
const created = await createWorktree(repo, {
mode: 'new',
branchName: 'feature/bootstrap-phases',
worktreeName: 'bootstrap-phases',
returnAfterDirectoryCreated: true,
startCommand: `${JSON.stringify(process.execPath)} ${JSON.stringify(setupScript)}`,
});
expect(created.bootstrapStatus).toMatchObject({
status: 'pending',
phase: 'directory-created',
error: null,
});
await expect.poll(() => fs.existsSync(setupMarker), { timeout: 5_000 }).toBe(true);
await expect(getWorktreeBootstrapStatus(created.path)).resolves.toMatchObject({
status: 'pending',
phase: 'git-ready',
error: null,
});
await expect.poll(
async () => (await getWorktreeBootstrapStatus(created.path)).phase,
{ timeout: 5_000 },
).toBe('setup-ready');
await expect(getWorktreeBootstrapStatus(created.path)).resolves.toMatchObject({
status: 'ready',
phase: 'setup-ready',
error: null,
});
} finally {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;
} else {
process.env.XDG_DATA_HOME = previousXdgDataHome;
}
}
});
it('waits for active bootstrap work before removing a worktree', async () => {
if (!canRunGit()) return;
const previousXdgDataHome = process.env.XDG_DATA_HOME;
const dataHome = createTempDir();
const setupStarted = path.join(dataHome, 'remove-race-started');
const setupCompleted = path.join(dataHome, 'remove-race-completed');
const setupScript = path.join(dataHome, 'remove-race.cjs');
process.env.XDG_DATA_HOME = dataHome;
fs.writeFileSync(
setupScript,
`const fs = require('node:fs'); fs.writeFileSync(${JSON.stringify(setupStarted)}, 'started'); setTimeout(() => fs.writeFileSync(${JSON.stringify(setupCompleted)}, 'completed'), 300);\n`,
);
try {
const repo = createTempDir();
runGit(repo, ['init', '-b', 'main']);
runGit(repo, ['config', 'user.email', 'test@example.com']);
runGit(repo, ['config', 'user.name', 'Test User']);
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
runGit(repo, ['add', 'README.md']);
runGit(repo, ['commit', '-m', 'Initial commit']);
const created = await createWorktree(repo, {
mode: 'new',
branchName: 'feature/remove-bootstrap-race',
worktreeName: 'remove-bootstrap-race',
returnAfterDirectoryCreated: true,
startCommand: `${JSON.stringify(process.execPath)} ${JSON.stringify(setupScript)}`,
});
await expect.poll(() => fs.existsSync(setupStarted), { timeout: 5_000 }).toBe(true);
let removalCompleted = false;
const removal = removeWorktree(repo, { directory: created.path }).then(() => {
removalCompleted = true;
});
await new Promise((resolve) => setTimeout(resolve, 25));
expect(removalCompleted).toBe(false);
await removal;
expect(fs.existsSync(setupCompleted)).toBe(true);
expect(fs.existsSync(created.path)).toBe(false);
await expect(getWorktreeBootstrapStatus(created.path)).resolves.toMatchObject({
status: 'ready',
phase: 'setup-ready',
});
} finally {
if (previousXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;
} else {
process.env.XDG_DATA_HOME = previousXdgDataHome;
}
}
});
it('recovers from an unchanged stale index lock while populating a worktree', async () => {
if (!canRunGit()) return;