* Enter the sandbox. Returns true if isolation is now active, false if it * couldn't be set up (non-git, detached HEAD, etc.) — in which case the caller * proceeds without isolation.
(taskId: string, signal?: AbortSignal)
| 138 | * proceeds without isolation. |
| 139 | */ |
| 140 | async begin(taskId: string, signal?: AbortSignal): Promise<boolean> { |
| 141 | let lock: LockHandle | null = null; |
| 142 | try { |
| 143 | if (!(await isGitRepo(this.cwd, signal))) { |
| 144 | logger.debug('GitSandbox: not a git repo — running without isolation'); |
| 145 | return false; |
| 146 | } |
| 147 | const repoRoot = await repoToplevel(this.cwd, signal); |
| 148 | if (!repoRoot) { |
| 149 | logger.debug('GitSandbox: could not resolve repo root — skipping isolation'); |
| 150 | return false; |
| 151 | } |
| 152 | |
| 153 | // Advisory repo lock: stop two sessions in one repo interleaving HEAD/stash |
| 154 | // mutations. A short retry budget means a LIVE holder makes us decline (run |
| 155 | // without isolation) rather than block; a dead holder's stale lock is |
| 156 | // reclaimed by acquireLock's staleMs path. The lock dir must exist first. |
| 157 | const lockPath = sandboxLockPath(repoRoot); |
| 158 | await fs.mkdir(path.dirname(lockPath), { recursive: true }).catch(() => {}); |
| 159 | try { |
| 160 | lock = await acquireLock(lockPath, { retries: 3, intervalMs: 150, staleMs: 30_000 }); |
| 161 | } catch { |
| 162 | logger.info('GitSandbox: another session holds the sandbox lock — running without isolation'); |
| 163 | return false; |
| 164 | } |
| 165 | |
| 166 | const origin = await this.currentBranch(signal); |
| 167 | if (!origin) { |
| 168 | logger.debug('GitSandbox: detached HEAD — skipping isolation'); |
| 169 | await lock.release(); |
| 170 | return false; |
| 171 | } |
| 172 | const base = await this.headSha(signal); |
| 173 | if (!base) { await lock.release(); return false; } |
| 174 | |
| 175 | // Stash any uncommitted work so the sandbox starts from a clean tree and |
| 176 | // the user's WIP is preserved untouched. The message is unique per task so |
| 177 | // finish() can restore THIS stash by message, never another session's. |
| 178 | const status = await git(['status', '--porcelain'], { cwd: this.cwd, signal }); |
| 179 | const dirty = (status.exitCode === 0) && status.stdout.trim().length > 0; |
| 180 | const stashMessage = `qodex-sandbox-wip-${taskId}`; |
| 181 | let stashed = false; |
| 182 | if (dirty) { |
| 183 | const s = await git(['stash', 'push', '-u', '-m', stashMessage], { cwd: this.cwd, signal }); |
| 184 | stashed = (s.exitCode === 0); |
| 185 | if (!(s.exitCode === 0)) { |
| 186 | logger.debug('GitSandbox: stash failed — skipping isolation to avoid touching dirty tree'); |
| 187 | await lock.release(); |
| 188 | return false; |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | const branch = `qodex/sandbox-${taskId}`; |
| 193 | const co = await git(['checkout', '-b', branch], { cwd: this.cwd, signal }); |
| 194 | if (!(co.exitCode === 0)) { |
| 195 | // Roll back the stash if we made one, then bail. |
| 196 | if (stashed) await this.restoreStashByMessage(stashMessage, signal); |
| 197 | logger.debug('GitSandbox: branch create failed — no isolation', { err: co.stderr }); |
no test coverage detected