mirror of https://github.com/garrytan/gstack.git
feat: worktree-per-session isolation for sidebar agent
Each sidebar session gets an isolated git worktree so the agent's file operations don't conflict with the user's working directory: - createWorktree() creates detached HEAD worktree in ~/.gstack/worktrees/ - Falls back to main cwd for non-git repos or on creation failure - Handles collision cleanup from prior crashes - removeWorktree() cleans up on session switch and shutdown - worktreePath persisted in session.json Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cf8416290d
commit
d8cd0e3f7c
|
|
@ -108,6 +108,7 @@ interface SidebarSession {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
claudeSessionId: string | null;
|
claudeSessionId: string | null;
|
||||||
|
worktreePath: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
lastActiveAt: string;
|
lastActiveAt: string;
|
||||||
}
|
}
|
||||||
|
|
@ -193,12 +194,81 @@ function loadSession(): SidebarSession | null {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a git worktree for session isolation.
|
||||||
|
* Falls back to null (use main cwd) if:
|
||||||
|
* - not in a git repo
|
||||||
|
* - git worktree add fails (submodules, LFS, permissions)
|
||||||
|
* - worktree dir already exists (collision from prior crash)
|
||||||
|
*/
|
||||||
|
function createWorktree(sessionId: string): string | null {
|
||||||
|
try {
|
||||||
|
// Check if we're in a git repo
|
||||||
|
const gitCheck = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], {
|
||||||
|
stdout: 'pipe', stderr: 'pipe', timeout: 3000,
|
||||||
|
});
|
||||||
|
if (gitCheck.exitCode !== 0) return null;
|
||||||
|
const repoRoot = gitCheck.stdout.toString().trim();
|
||||||
|
|
||||||
|
const worktreeDir = path.join(process.env.HOME || '/tmp', '.gstack', 'worktrees', sessionId.slice(0, 8));
|
||||||
|
|
||||||
|
// Clean up if dir exists from prior crash
|
||||||
|
if (fs.existsSync(worktreeDir)) {
|
||||||
|
Bun.spawnSync(['git', 'worktree', 'remove', '--force', worktreeDir], {
|
||||||
|
cwd: repoRoot, stdout: 'pipe', stderr: 'pipe', timeout: 5000,
|
||||||
|
});
|
||||||
|
try { fs.rmSync(worktreeDir, { recursive: true, force: true }); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current branch/commit
|
||||||
|
const headCheck = Bun.spawnSync(['git', 'rev-parse', 'HEAD'], {
|
||||||
|
cwd: repoRoot, stdout: 'pipe', stderr: 'pipe', timeout: 3000,
|
||||||
|
});
|
||||||
|
if (headCheck.exitCode !== 0) return null;
|
||||||
|
const head = headCheck.stdout.toString().trim();
|
||||||
|
|
||||||
|
// Create worktree (detached HEAD — no branch conflicts)
|
||||||
|
const result = Bun.spawnSync(['git', 'worktree', 'add', '--detach', worktreeDir, head], {
|
||||||
|
cwd: repoRoot, stdout: 'pipe', stderr: 'pipe', timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
console.log(`[browse] Worktree creation failed: ${result.stderr.toString().trim()}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[browse] Created worktree: ${worktreeDir}`);
|
||||||
|
return worktreeDir;
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log(`[browse] Worktree creation error: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeWorktree(worktreePath: string | null): void {
|
||||||
|
if (!worktreePath) return;
|
||||||
|
try {
|
||||||
|
const gitCheck = Bun.spawnSync(['git', 'rev-parse', '--show-toplevel'], {
|
||||||
|
stdout: 'pipe', stderr: 'pipe', timeout: 3000,
|
||||||
|
});
|
||||||
|
if (gitCheck.exitCode === 0) {
|
||||||
|
Bun.spawnSync(['git', 'worktree', 'remove', '--force', worktreePath], {
|
||||||
|
cwd: gitCheck.stdout.toString().trim(), stdout: 'pipe', stderr: 'pipe', timeout: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Cleanup dir if git worktree remove didn't
|
||||||
|
try { fs.rmSync(worktreePath, { recursive: true, force: true }); } catch {}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
function createSession(): SidebarSession {
|
function createSession(): SidebarSession {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
|
const worktreePath = createWorktree(id);
|
||||||
const session: SidebarSession = {
|
const session: SidebarSession = {
|
||||||
id,
|
id,
|
||||||
name: 'Chrome sidebar',
|
name: 'Chrome sidebar',
|
||||||
claudeSessionId: null,
|
claudeSessionId: null,
|
||||||
|
worktreePath,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
lastActiveAt: new Date().toISOString(),
|
lastActiveAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
@ -294,7 +364,7 @@ function spawnClaude(userMessage: string): void {
|
||||||
|
|
||||||
const proc = spawn('claude', args, {
|
const proc = spawn('claude', args, {
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
cwd: (sidebarSession as any)?.worktreePath || process.cwd(), // worktreePath added in Phase 6
|
cwd: sidebarSession?.worktreePath || process.cwd(),
|
||||||
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile },
|
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile },
|
||||||
} as any);
|
} as any);
|
||||||
proc.stdin?.end();
|
proc.stdin?.end();
|
||||||
|
|
@ -602,6 +672,7 @@ async function shutdown() {
|
||||||
killAgent();
|
killAgent();
|
||||||
messageQueue = [];
|
messageQueue = [];
|
||||||
saveSession(); // Persist chat history before exit
|
saveSession(); // Persist chat history before exit
|
||||||
|
if (sidebarSession?.worktreePath) removeWorktree(sidebarSession.worktreePath);
|
||||||
if (agentHealthInterval) clearInterval(agentHealthInterval);
|
if (agentHealthInterval) clearInterval(agentHealthInterval);
|
||||||
clearInterval(flushInterval);
|
clearInterval(flushInterval);
|
||||||
clearInterval(idleCheckInterval);
|
clearInterval(idleCheckInterval);
|
||||||
|
|
@ -907,6 +978,8 @@ async function start() {
|
||||||
}
|
}
|
||||||
killAgent();
|
killAgent();
|
||||||
messageQueue = [];
|
messageQueue = [];
|
||||||
|
// Clean up old session's worktree before creating new one
|
||||||
|
if (sidebarSession?.worktreePath) removeWorktree(sidebarSession.worktreePath);
|
||||||
sidebarSession = createSession();
|
sidebarSession = createSession();
|
||||||
return new Response(JSON.stringify({ ok: true, session: sidebarSession }), {
|
return new Response(JSON.stringify({ ok: true, session: sidebarSession }), {
|
||||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue