diff --git a/apps/cli/src/commands/claude.ts b/apps/cli/src/commands/claude.ts index 1f6e4cc..181a768 100644 --- a/apps/cli/src/commands/claude.ts +++ b/apps/cli/src/commands/claude.ts @@ -48,7 +48,7 @@ import { import { cloudAgentAttach } from './_cloud-attach.js'; import { cloudAgentCreate } from './_cloud-agent-create.js'; import { runCarryGate } from '../lib/carry-gate.js'; -import { FromBranchError, resolveFromBranch } from '../lib/from-branch.js'; +import { FromBranchError, UseBranchError, resolveBranchSelection } from '../lib/from-branch.js'; import { providerForBox, providerForCreate } from '../provider/registry.js'; import { prepareTeleport, @@ -170,13 +170,15 @@ interface ClaudeCreateOptions { provider?: string; /** --from-branch : base the box's per-box branch on this ref instead of HEAD. */ fromBranch?: string; + /** -b / --use-branch : reuse an existing branch directly instead of forking agentbox/. */ + useBranch?: string; /** -v / --verbose: bypass the spinner and stream raw provider output. */ verbose?: boolean; /** Raw `--attach-in ` value; validated by `parseAttachInOption`. */ attachIn?: string; /** --inline: shortcut for `--attach-in same` (long-form only — `-i` is `--initial-prompt`). */ inline?: boolean; - /** Commander parses `-b, --no-attach` as `attach: false` (defaults true). */ + /** Commander parses `-d, --no-attach` as `attach: false` (defaults true). */ attach?: boolean; /** * `-i, --initial-prompt `: seed the claude TUI with this user turn @@ -349,13 +351,17 @@ export const claudeCommand = new Command('claude') '--from-branch ', "base the box's per-box branch on this ref (branch / tag / SHA) instead of HEAD. Branch/tag names are fetched from origin first.", ) + .option( + '-b, --use-branch ', + "reuse an existing branch directly instead of forking agentbox/. Commits/pushes flow straight to it. Docker fails if the host already has it checked out. Mutually exclusive with --from-branch.", + ) .option( '-v, --verbose', 'bypass the spinner and stream raw provider output (docker build / Daytona snapshot create) to stderr. The same content always lands in ~/.agentbox/logs/claude.log.', ) .option('--attach-in ', ATTACH_IN_HELP) .option('--inline', INLINE_HELP) - .option('-b, --no-attach', NO_ATTACH_HELP) + .option('-d, --no-attach', NO_ATTACH_HELP) .option( '-i, --initial-prompt ', 'seed the claude session with this initial user turn and run in background (no attach). Jobs go through the host-wide queue (queue.maxConcurrent). NOTE: this is NOT claude\'s own `-p` headless print mode — for that, pass `-- -p ...`.', @@ -542,13 +548,21 @@ export const claudeCommand = new Command('claude') effectiveClaudeArgs = buildPromptArgs('claude-code', wiz.initialPrompt, claudeArgs); } - // Validate --from-branch before any provider work so a typo doesn't + // Validate branch selection before any provider work so a typo doesn't // leave a half-created box. let fromBranch: string | undefined; + let useBranch: string | undefined; try { - fromBranch = await resolveFromBranch(opts.fromBranch, { repo: opts.workspace }); + ({ fromBranch, useBranch } = await resolveBranchSelection({ + useBranch: opts.useBranch, + fromBranch: opts.fromBranch, + repo: opts.workspace, + providerName, + cloudUseCurrentBranch: cfg.effective.cloud.useCurrentBranch, + log: (m) => cmdLog.write(m), + })); } catch (err) { - if (err instanceof FromBranchError) { + if (err instanceof FromBranchError || err instanceof UseBranchError) { log.error(err.message); cmdLog.close(); process.exit(2); @@ -576,6 +590,7 @@ export const claudeCommand = new Command('claude') vnc: { enabled: cfg.effective.box.vnc }, limits: resolveLimits(cfg.effective.box, opts), fromBranch, + useBranch, projectRoot, }, binary: 'claude', @@ -634,6 +649,7 @@ export const claudeCommand = new Command('claude') useSnapshot, checkpointRef, fromBranch, + useBranch, image: cfg.effective.box.image, claudeConfig: { isolate: cfg.effective.box.isolateClaudeConfig }, claudeEnv: resolved.env, @@ -986,7 +1002,7 @@ const claudeStartCommand = new Command('start') ) .option('--attach-in ', ATTACH_IN_HELP) .option('-i, --inline', INLINE_HELP) - .option('-b, --no-attach', NO_ATTACH_HELP) + .option('-d, --no-attach', NO_ATTACH_HELP) .option( '-c, --continue', 'teleport the most recent host Claude Code session for this cwd into the box and resume', diff --git a/apps/cli/src/commands/codex.ts b/apps/cli/src/commands/codex.ts index d0e12d7..6bf763e 100644 --- a/apps/cli/src/commands/codex.ts +++ b/apps/cli/src/commands/codex.ts @@ -44,7 +44,7 @@ import { import { cloudAgentAttach } from './_cloud-attach.js'; import { cloudAgentCreate } from './_cloud-agent-create.js'; import { runCarryGate } from '../lib/carry-gate.js'; -import { FromBranchError, resolveFromBranch } from '../lib/from-branch.js'; +import { FromBranchError, UseBranchError, resolveBranchSelection } from '../lib/from-branch.js'; import { providerForBox, providerForCreate } from '../provider/registry.js'; import { prepareTeleport, @@ -153,13 +153,15 @@ interface CodexCreateOptions { provider?: string; /** --from-branch : base the box's per-box branch on this ref instead of HEAD. */ fromBranch?: string; + /** -b / --use-branch : reuse an existing branch directly instead of forking agentbox/. */ + useBranch?: string; /** -v / --verbose: bypass the spinner and stream raw provider output. */ verbose?: boolean; /** Raw `--attach-in ` value; validated by `parseAttachInOption`. */ attachIn?: string; /** --inline: shortcut for `--attach-in same` (long-form only — `-i` is `--initial-prompt`). */ inline?: boolean; - /** Commander parses `-b, --no-attach` as `attach: false` (defaults true). */ + /** Commander parses `-d, --no-attach` as `attach: false` (defaults true). */ attach?: boolean; /** `-i, --initial-prompt `: seed codex with this user turn; runs in background. */ initialPrompt?: string; @@ -294,13 +296,17 @@ export const codexCommand = new Command('codex') '--from-branch ', "base the box's per-box branch on this ref (branch / tag / SHA) instead of HEAD. Branch/tag names are fetched from origin first.", ) + .option( + '-b, --use-branch ', + "reuse an existing branch directly instead of forking agentbox/. Commits/pushes flow straight to it. Docker fails if the host already has it checked out. Mutually exclusive with --from-branch.", + ) .option( '-v, --verbose', 'bypass the spinner and stream raw provider output to stderr. The same content always lands in ~/.agentbox/logs/codex.log.', ) .option('--attach-in ', ATTACH_IN_HELP) .option('--inline', INLINE_HELP) - .option('-b, --no-attach', NO_ATTACH_HELP) + .option('-d, --no-attach', NO_ATTACH_HELP) .option( '-i, --initial-prompt ', 'seed the codex session with this initial user turn and run in background (no attach). Jobs go through the host-wide queue (queue.maxConcurrent).', @@ -434,10 +440,18 @@ export const codexCommand = new Command('codex') } let fromBranch: string | undefined; + let useBranch: string | undefined; try { - fromBranch = await resolveFromBranch(opts.fromBranch, { repo: opts.workspace }); + ({ fromBranch, useBranch } = await resolveBranchSelection({ + useBranch: opts.useBranch, + fromBranch: opts.fromBranch, + repo: opts.workspace, + providerName, + cloudUseCurrentBranch: cfg.effective.cloud.useCurrentBranch, + log: (m) => cmdLog.write(m), + })); } catch (err) { - if (err instanceof FromBranchError) { + if (err instanceof FromBranchError || err instanceof UseBranchError) { log.error(err.message); cmdLog.close(); process.exit(2); @@ -462,6 +476,7 @@ export const codexCommand = new Command('codex') vnc: { enabled: cfg.effective.box.vnc }, limits: resolveLimits(cfg.effective.box, opts), fromBranch, + useBranch, projectRoot, }, binary: 'codex', @@ -528,6 +543,7 @@ export const codexCommand = new Command('codex') useSnapshot, checkpointRef, fromBranch, + useBranch, image: cfg.effective.box.image, codexConfig: { isolate: cfg.effective.box.isolateCodexConfig }, withPlaywright, @@ -817,7 +833,7 @@ const codexStartCommand = new Command('start') ) .option('--attach-in ', ATTACH_IN_HELP) .option('-i, --inline', INLINE_HELP) - .option('-b, --no-attach', NO_ATTACH_HELP) + .option('-d, --no-attach', NO_ATTACH_HELP) .option( '-c, --continue', 'teleport the most recent host Codex session for this cwd into the box and resume', diff --git a/apps/cli/src/commands/create.ts b/apps/cli/src/commands/create.ts index 7eeb03e..012fc2e 100644 --- a/apps/cli/src/commands/create.ts +++ b/apps/cli/src/commands/create.ts @@ -16,7 +16,7 @@ import { import { Command } from 'commander'; import { execSync, spawnSync } from 'node:child_process'; import { runCarryGate } from '../lib/carry-gate.js'; -import { FromBranchError, resolveFromBranch } from '../lib/from-branch.js'; +import { FromBranchError, UseBranchError, resolveBranchSelection } from '../lib/from-branch.js'; import { openCommandLog } from '../lib/log-file.js'; import { makeProgressReporter } from '../lib/progress.js'; import { maybePromptPortless, setupPortlessHost } from '../portless-prompt.js'; @@ -59,6 +59,8 @@ interface CreateOptions { bundleDepth?: number; /** --from-branch : base the box's per-box branch on this ref (branch / tag / SHA) instead of HEAD. */ fromBranch?: string; + /** -b / --use-branch : reuse an existing branch directly instead of forking agentbox/. */ + useBranch?: string; /** -v / --verbose: also stream raw build / provision output to stderr. */ verbose?: boolean; } @@ -173,6 +175,10 @@ export const createCommand = new Command('create') '--from-branch ', "base the box's per-box branch on this ref (branch / tag / SHA) instead of HEAD. Branch/tag names are fetched from origin first.", ) + .option( + '-b, --use-branch ', + "reuse an existing branch directly instead of forking agentbox/. Commits/pushes flow straight to it. Docker fails if the host already has it checked out. Mutually exclusive with --from-branch.", + ) .option('-y, --yes', 'skip prompts, accept defaults') .option( '--carry-yes', @@ -302,11 +308,22 @@ export const createCommand = new Command('create') // provider for 'daytona'; everything below is provider-neutral. const provider = await providerForCreate({ flag: opts.provider, config: cfg.effective }); let fromBranch: string | undefined; + let useBranch: string | undefined; try { - fromBranch = await resolveFromBranch(opts.fromBranch, { repo: opts.workspace }); + ({ fromBranch, useBranch } = await resolveBranchSelection({ + useBranch: opts.useBranch, + fromBranch: opts.fromBranch, + repo: opts.workspace, + providerName: provider.name, + cloudUseCurrentBranch: cfg.effective.cloud.useCurrentBranch, + log: (m) => { + s.message(m); + cmdLog.write(m); + }, + })); } catch (err) { - if (err instanceof FromBranchError) { - s.stop('aborting: invalid --from-branch'); + if (err instanceof FromBranchError || err instanceof UseBranchError) { + s.stop('aborting: invalid branch selection'); log.error(err.message); cmdLog.close(); process.exit(2); @@ -326,6 +343,7 @@ export const createCommand = new Command('create') limits: resolveLimits(cfg.effective.box, opts), bundleDepth: cfg.effective.box.bundleDepth, fromBranch, + useBranch, projectRoot, onLog: (line) => { s.message(line); diff --git a/apps/cli/src/commands/opencode.ts b/apps/cli/src/commands/opencode.ts index 263af73..c3689c4 100644 --- a/apps/cli/src/commands/opencode.ts +++ b/apps/cli/src/commands/opencode.ts @@ -43,7 +43,7 @@ import { import { cloudAgentAttach } from './_cloud-attach.js'; import { cloudAgentCreate } from './_cloud-agent-create.js'; import { runCarryGate } from '../lib/carry-gate.js'; -import { FromBranchError, resolveFromBranch } from '../lib/from-branch.js'; +import { FromBranchError, UseBranchError, resolveBranchSelection } from '../lib/from-branch.js'; import { providerForCreate } from '../provider/registry.js'; import { prepareTeleport, TeleportError } from '../session-teleport/index.js'; import { clampSpinnerLine } from '../spinner-line.js'; @@ -145,13 +145,15 @@ interface OpencodeCreateOptions { provider?: string; /** --from-branch : base the box's per-box branch on this ref instead of HEAD. */ fromBranch?: string; + /** -b / --use-branch : reuse an existing branch directly instead of forking agentbox/. */ + useBranch?: string; /** -v / --verbose: bypass the spinner and stream raw provider output. */ verbose?: boolean; /** Raw `--attach-in ` value; validated by `parseAttachInOption`. */ attachIn?: string; /** --inline: shortcut for `--attach-in same` (long-form only — `-i` is `--initial-prompt`). */ inline?: boolean; - /** Commander parses `-b, --no-attach` as `attach: false` (defaults true). */ + /** Commander parses `-d, --no-attach` as `attach: false` (defaults true). */ attach?: boolean; /** `-i, --initial-prompt `: seed opencode with this user turn; runs in background. */ initialPrompt?: string; @@ -290,13 +292,17 @@ export const opencodeCommand = new Command('opencode') '--from-branch ', "base the box's per-box branch on this ref (branch / tag / SHA) instead of HEAD. Branch/tag names are fetched from origin first.", ) + .option( + '-b, --use-branch ', + "reuse an existing branch directly instead of forking agentbox/. Commits/pushes flow straight to it. Docker fails if the host already has it checked out. Mutually exclusive with --from-branch.", + ) .option( '-v, --verbose', 'bypass the spinner and stream raw provider output to stderr. The same content always lands in ~/.agentbox/logs/opencode.log.', ) .option('--attach-in ', ATTACH_IN_HELP) .option('--inline', INLINE_HELP) - .option('-b, --no-attach', NO_ATTACH_HELP) + .option('-d, --no-attach', NO_ATTACH_HELP) .option( '-i, --initial-prompt ', 'seed the opencode session with this initial user turn and run in background (no attach). Jobs go through the host-wide queue (queue.maxConcurrent).', @@ -420,10 +426,18 @@ export const opencodeCommand = new Command('opencode') } let fromBranch: string | undefined; + let useBranch: string | undefined; try { - fromBranch = await resolveFromBranch(opts.fromBranch, { repo: opts.workspace }); + ({ fromBranch, useBranch } = await resolveBranchSelection({ + useBranch: opts.useBranch, + fromBranch: opts.fromBranch, + repo: opts.workspace, + providerName, + cloudUseCurrentBranch: cfg.effective.cloud.useCurrentBranch, + log: (m) => cmdLog.write(m), + })); } catch (err) { - if (err instanceof FromBranchError) { + if (err instanceof FromBranchError || err instanceof UseBranchError) { log.error(err.message); cmdLog.close(); process.exit(2); @@ -448,6 +462,7 @@ export const opencodeCommand = new Command('opencode') vnc: { enabled: cfg.effective.box.vnc }, limits: resolveLimits(cfg.effective.box, opts), fromBranch, + useBranch, projectRoot, }, binary: 'opencode', @@ -494,6 +509,7 @@ export const opencodeCommand = new Command('opencode') useSnapshot, checkpointRef, fromBranch, + useBranch, image: cfg.effective.box.image, opencodeConfig: { isolate: cfg.effective.box.isolateOpencodeConfig }, withPlaywright, @@ -720,7 +736,7 @@ const opencodeStartCommand = new Command('start') ) .option('--attach-in ', ATTACH_IN_HELP) .option('-i, --inline', INLINE_HELP) - .option('-b, --no-attach', NO_ATTACH_HELP) + .option('-d, --no-attach', NO_ATTACH_HELP) .option( '-c, --continue', 'session teleport (not yet supported for opencode in v1; emits a friendly error)', diff --git a/apps/cli/src/lib/from-branch.ts b/apps/cli/src/lib/from-branch.ts index 2e77932..188c043 100644 --- a/apps/cli/src/lib/from-branch.ts +++ b/apps/cli/src/lib/from-branch.ts @@ -1,6 +1,6 @@ /** - * Host-side validation for `--from-branch ` on `create` / `claude` / - * `codex` / `opencode` / `code`. + * Host-side validation for `--from-branch ` and `--use-branch ` + * on `create` / `claude` / `codex` / `opencode` / `code`. * * The flag tells the provider what base ref to fork the box's per-box branch * from instead of the host's current `HEAD`. We validate the ref *here*, @@ -80,3 +80,124 @@ export async function resolveFromBranch( } return ref; } + +export class UseBranchError extends Error { + constructor(message: string) { + super(message); + this.name = 'UseBranchError'; + } +} + +/** + * Host-side validation for `--use-branch `. Unlike `--from-branch` + * (which only picks a *base ref* to fork a fresh `agentbox/` branch + * from), `--use-branch` checks out the existing branch directly — so we + * require a real local **branch** ref, not a tag or detached SHA. A box that + * checks out a detached ref has nowhere to `git push`, so those are rejected. + * + * Best-effort `git fetch ` first so the branch tracks the + * remote tip (the cloud bundle is built from the host's local ref state). + * Returns the name verbatim on success; throws `UseBranchError` otherwise. + * `undefined` / empty input → returns `undefined` without touching git. + */ +export async function resolveUseBranch( + name: string | undefined, + opts: ResolveFromBranchOpts, +): Promise { + if (!name || name.length === 0) return undefined; + const remote = opts.remote ?? 'origin'; + + // Update the local branch to the remote tip when possible. Soft-fail: the + // branch may be local-only, in which case the show-ref check below still + // passes against the existing local ref. + await execa('git', ['-C', opts.repo, 'fetch', '--quiet', remote, name], { + reject: false, + }); + + const exists = await execa( + 'git', + ['-C', opts.repo, 'show-ref', '--verify', '--quiet', `refs/heads/${name}`], + { reject: false }, + ); + if (exists.exitCode !== 0) { + throw new UseBranchError( + `--use-branch: no local branch "${name}" in ${opts.repo}. ` + + `Create or check it out on the host first (--use-branch reuses an ` + + `existing branch; use --from-branch to fork a new box branch from a ref).`, + ); + } + return name; +} + +/** + * The host workspace's current branch name (`git rev-parse --abbrev-ref + * HEAD`). Returns `undefined` when HEAD is detached (git prints the literal + * `HEAD`) or when the command fails. Used by the `cloud.useCurrentBranch` + * config path to default cloud boxes onto the host's current branch. + */ +export async function currentHostBranch(repo: string): Promise { + const r = await execa('git', ['-C', repo, 'rev-parse', '--abbrev-ref', 'HEAD'], { + reject: false, + }); + if (r.exitCode !== 0) return undefined; + const branch = r.stdout.trim(); + if (!branch || branch === 'HEAD') return undefined; + return branch; +} + +export interface BranchSelectionOpts { + /** Raw `--use-branch ` value (undefined when not passed). */ + useBranch?: string; + /** Raw `--from-branch ` value (undefined when not passed). */ + fromBranch?: string; + /** Host repo path (the workspace root). */ + repo: string; + /** Provider the box will be created on; gates the cloud.useCurrentBranch default. */ + providerName: string; + /** `cfg.effective.cloud.useCurrentBranch`. */ + cloudUseCurrentBranch: boolean; + /** Optional logger for informational notes (e.g. detached-HEAD fallback). */ + log?: (msg: string) => void; +} + +/** + * Resolve the box's branch strategy from the two flags plus the + * `cloud.useCurrentBranch` config. Single source of truth shared by + * `create` / `claude` / `codex` / `opencode` so the mutex + precedence stay + * identical across commands. + * + * Precedence: `--use-branch` > `--from-branch` > (cloud only) + * `cloud.useCurrentBranch` > default fork. Throws `UseBranchError` on the + * mutex conflict or an invalid `--use-branch`, `FromBranchError` on an + * invalid `--from-branch`; callers catch both and exit before provider work. + */ +export async function resolveBranchSelection( + opts: BranchSelectionOpts, +): Promise<{ useBranch?: string; fromBranch?: string }> { + if (opts.useBranch && opts.fromBranch) { + throw new UseBranchError( + '--use-branch and --from-branch are mutually exclusive: --use-branch reuses an ' + + 'existing branch, --from-branch forks a new box branch from a base ref. Pass only one.', + ); + } + if (opts.useBranch) { + return { useBranch: await resolveUseBranch(opts.useBranch, { repo: opts.repo }) }; + } + if (opts.fromBranch) { + return { fromBranch: await resolveFromBranch(opts.fromBranch, { repo: opts.repo }) }; + } + // cloud.useCurrentBranch defaults cloud boxes onto the host's current + // branch. Docker can't reuse it (the host already has it checked out → a + // worktree-registry collision), so this only fires for cloud providers. + if (opts.providerName !== 'docker' && opts.cloudUseCurrentBranch) { + const current = await currentHostBranch(opts.repo); + if (current) { + opts.log?.(`cloud.useCurrentBranch: starting box on host branch "${current}"`); + return { useBranch: current }; + } + opts.log?.( + 'cloud.useCurrentBranch is set but host HEAD is detached; forking a fresh branch instead', + ); + } + return {}; +} diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 0aa559d..2401b18 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -99,6 +99,9 @@ export interface UserConfig { enabled?: boolean; maxConcurrent?: number; }; + cloud?: { + useCurrentBranch?: boolean; + }; maintenance?: { pruneProjectConfigs?: boolean; pruneProjectConfigsEvery?: number; @@ -186,6 +189,9 @@ export interface EffectiveConfig { enabled: boolean; maxConcurrent: number; }; + cloud: { + useCurrentBranch: boolean; + }; maintenance: { pruneProjectConfigs: boolean; pruneProjectConfigsEvery: number; @@ -292,6 +298,9 @@ export const BUILT_IN_DEFAULTS: EffectiveConfig = { enabled: true, maxConcurrent: 5, }, + cloud: { + useCurrentBranch: false, + }, maintenance: { pruneProjectConfigs: true, pruneProjectConfigsEvery: 50, @@ -562,6 +571,12 @@ export const KEY_REGISTRY: readonly KeyDescriptor[] = [ description: 'Max number of simultaneously-running boxes (across providers) before background `-i` jobs queue up instead of starting immediately. Per-invocation override: `--max-running `.', }, + { + key: 'cloud.useCurrentBranch', + type: 'bool', + description: + "On cloud providers (daytona/hetzner), start new boxes on the host's current branch instead of forking a new agentbox/ branch. Overridden by an explicit --use-branch / --from-branch.", + }, { key: 'maintenance.pruneProjectConfigs', type: 'bool', diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index ffbf83d..1a012d7 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -85,6 +85,17 @@ export interface CreateBoxRequest { * any provider work — the provider trusts whatever it gets here. */ fromBranch?: string; + /** + * Reuse an existing branch directly instead of forking a fresh + * `agentbox/` branch. The box checks out `` as-is (root + * repo only); commits and `git push` flow straight to it. Mutually + * exclusive with `fromBranch` (the CLI enforces this). Docker: `git + * worktree add ` (no `-b`) — fails if the host already has + * the branch checked out. Cloud: the clone lands on the branch and we skip + * the `checkout -B` rename. The CLI validates the branch exists host-side + * before any provider work. + */ + useBranch?: string; /** Provider-specific knobs (docker: sharedCache/portless; daytona: resources/region). */ providerOptions?: Record; onLog?: (line: string) => void; diff --git a/packages/sandbox-cloud/src/cloud-provider.ts b/packages/sandbox-cloud/src/cloud-provider.ts index cf9c2dc..56fe9e7 100644 --- a/packages/sandbox-cloud/src/cloud-provider.ts +++ b/packages/sandbox-cloud/src/cloud-provider.ts @@ -244,7 +244,9 @@ export function createCloudProvider( return { id, name, - branch: `agentbox/${name}`, + // --use-branch reuses the named branch directly; otherwise fork a fresh + // per-box branch. The CLI validated `useBranch` exists host-side. + branch: req.useBranch ?? `agentbox/${name}`, }; } @@ -354,6 +356,7 @@ export function createCloudProvider( workspaceDir: CLOUD_WORKSPACE_DIR, bundleDepth: req.bundleDepth, fromBranch: req.fromBranch, + useBranch: req.useBranch, onLog: log, }); } diff --git a/packages/sandbox-cloud/src/workspace-seed.ts b/packages/sandbox-cloud/src/workspace-seed.ts index c9eb4c0..0dbcbd5 100644 --- a/packages/sandbox-cloud/src/workspace-seed.ts +++ b/packages/sandbox-cloud/src/workspace-seed.ts @@ -48,6 +48,15 @@ export interface SeedCloudWorkspaceArgs { * validating the ref host-side. */ fromBranch?: string; + /** + * Reuse an existing branch directly (root repo only) instead of forking a + * fresh per-box branch. The host clone pins `--branch ` so the + * clone HEAD lands on it, and the in-sandbox checkout is a plain `git + * checkout ` (no `-B` reset). Mutually exclusive with + * `fromBranch` (enforced by the CLI). When set, `branch` equals + * `useBranch`. + */ + useBranch?: string; onLog?: (line: string) => void; } @@ -83,6 +92,7 @@ export async function seedCloudWorkspace( workspaceDir, bundleDepth: args.bundleDepth, fromBranch: args.fromBranch, + useBranch: args.useBranch, onLog: log, }); // Each nested repo gets its own clone at /workspace/. We do these @@ -125,6 +135,8 @@ interface SeedFromGitCloneArgs { bundleDepth?: number; /** See `SeedCloudWorkspaceArgs.fromBranch`. */ fromBranch?: string; + /** See `SeedCloudWorkspaceArgs.useBranch`. Root clone only. */ + useBranch?: string; onLog?: (line: string) => void; } @@ -162,8 +174,15 @@ async function seedFromGitClone(args: SeedFromGitCloneArgs): Promise { // fetch after the initial clone (HEAD-only clone doesn't pull arbitrary // refs). The untracked tar uploads on the side and the in-sandbox script // untars it after `git checkout` materializes the working tree. - const stashSha = await safeStashCreate(args.hostRepo); - const untrackedSize = await maybeBuildUntrackedTar(args.hostRepo, untrackedTarPath); + // + // --use-branch skips carry-over entirely: the box gets the reused branch's + // committed tip, not the host's uncommitted state (which may belong to a + // different branch). Mirrors the docker reuse path, which builds its + // RepoCarryOver with `stashSha: null` / empty untracked. + const stashSha = args.useBranch ? null : await safeStashCreate(args.hostRepo); + const untrackedSize = args.useBranch + ? 0 + : await maybeBuildUntrackedTar(args.hostRepo, untrackedTarPath); let stashRefCreated = false; try { if (stashSha) { @@ -195,7 +214,10 @@ async function seedFromGitClone(args: SeedFromGitCloneArgs): Promise { ? 'clone: depth=full (configured)' : `clone: depth=${String(initialDepth)} (configured)`, ); - await runShallowClone(args.hostRepo, cloneDir, initialDepth, stashRefCreated, args.fromBranch); + // --use-branch reuses the named branch directly; otherwise --from-branch + // (or nothing) picks the fork base. Either way it pins the clone's HEAD. + const cloneBranch = args.useBranch ?? args.fromBranch; + await runShallowClone(args.hostRepo, cloneDir, initialDepth, stashRefCreated, cloneBranch); await tarCloneDir(cloneDir, tarPath); if (adaptive && initialDepth !== null) { const size = await safeFileSize(tarPath); @@ -206,7 +228,7 @@ async function seedFromGitClone(args: SeedFromGitCloneArgs): Promise { ); await rm(cloneDir, { recursive: true, force: true }); await rm(tarPath, { force: true }); - await runShallowClone(args.hostRepo, cloneDir, LARGE_BUNDLE_DEPTH, stashRefCreated, args.fromBranch); + await runShallowClone(args.hostRepo, cloneDir, LARGE_BUNDLE_DEPTH, stashRefCreated, cloneBranch); await tarCloneDir(cloneDir, tarPath); } } @@ -269,7 +291,12 @@ async function seedFromGitClone(args: SeedFromGitCloneArgs): Promise { `$SUDO chown "$(id -un):$(id -gn)" ${quoteShellArgv([args.workspaceDir])}`, `tar -C ${quoteShellArgv([args.workspaceDir])} -xzf ${quoteShellArgv([remoteTar])}`, setOrigin, - `git -C ${quoteShellArgv([args.workspaceDir])} checkout -B ${quoteShellArgv([args.branch])}`, + // reuse: the clone already landed on `` (pinned via `--branch`); + // a plain checkout materializes the working tree without resetting the + // ref. fork: `-B` (re)points `` at the clone HEAD. + args.useBranch + ? `git -C ${quoteShellArgv([args.workspaceDir])} checkout ${quoteShellArgv([args.branch])}` + : `git -C ${quoteShellArgv([args.workspaceDir])} checkout -B ${quoteShellArgv([args.branch])}`, ...carryOverSteps, `rm -f ${quoteShellArgv([remoteTar])}`, ].join('\n'); diff --git a/packages/sandbox-docker/src/create.ts b/packages/sandbox-docker/src/create.ts index 5875d01..a72579b 100644 --- a/packages/sandbox-docker/src/create.ts +++ b/packages/sandbox-docker/src/create.ts @@ -43,6 +43,7 @@ import { chownGitBindParents, collectRepoCarryOver, gitWorktreePathFor, + removeInBoxWorktree, seedWorkspace, seedWorkspaceFromDir, type RepoCarryOver, @@ -122,6 +123,16 @@ export interface CreateBoxOptions { * any ref `git rev-parse` resolves; passed verbatim to `git worktree add`. */ fromBranch?: string; + /** + * Reuse an existing branch (root repo only) instead of forking a fresh + * `agentbox/`. The root worktree is created with `git worktree add + * ` (no `-b`), so git fails fast if the host already has + * `` checked out. No host stash / untracked carry-over is + * replayed — the box gets the branch's committed tip. Nested repos keep + * their per-box `agentbox/--` branches. Mutually exclusive with + * `fromBranch` (enforced by the CLI). + */ + useBranch?: string; image?: string; onLog?: (line: string) => void; /** @@ -448,13 +459,42 @@ export async function createBox(opts: CreateBoxOptions): Promise { ); } for (const r of repos) { + const containerPath = + r.kind === 'root' ? '/workspace' : `/workspace/${r.relPathFromWorkspace}`; + // --use-branch reuses the named branch for the *root* repo only: no + // pickFreshBranch (we want the exact branch), no carry-over replay (the + // user wants the branch's committed tip, not host uncommitted state). + // Nested repos always fork their own per-box branch — the root's reused + // branch won't exist in a nested repo. + const reuseBranch = r.kind === 'root' && opts.useBranch !== undefined; + if (reuseBranch) { + const branch = opts.useBranch as string; + const gitWorktreePath = gitWorktreePathFor(branch); + repoCarryOvers.push({ + repo: r, + containerPath, + gitWorktreePath, + branch, + stashSha: null, + untrackedNul: '', + hostSource: r.hostMainRepo, + reuseBranch: true, + }); + gitWorktreeRecords.push({ + kind: r.kind, + hostMainRepo: r.hostMainRepo, + containerPath, + gitWorktreePath, + branch, + relPathFromWorkspace: r.relPathFromWorkspace, + }); + continue; + } const branchBase = r.kind === 'root' ? `agentbox/${name}` : `agentbox/${name}--${r.relPathFromWorkspace.replace(/[^A-Za-z0-9._-]+/g, '_')}`; const branch = await pickFreshBranch(r.hostMainRepo, branchBase); - const containerPath = - r.kind === 'root' ? '/workspace' : `/workspace/${r.relPathFromWorkspace}`; const gitWorktreePath = gitWorktreePathFor(branch); const carry = await collectRepoCarryOver(r, branch, containerPath, gitWorktreePath); repoCarryOvers.push(carry); @@ -818,9 +858,23 @@ export async function createBox(opts: CreateBoxOptions): Promise { }); log('seeded /workspace from in-container git worktree(s)'); } catch (err) { - log( - `seedWorkspace failed; leaving ${containerName} running so you can inspect it`, - ); + // --use-branch seed failures are almost always "branch already used by + // another worktree" (the host has it checked out). There's nothing + // useful to inspect, and a leftover container + worktree registration + // would block the next attempt, so tear it down. Other seed failures + // keep the existing inspect-on-failure behavior. + if (opts.useBranch !== undefined) { + log(`seedWorkspace failed for --use-branch ${opts.useBranch}; cleaning up the box`); + await execa('docker', ['rm', '-f', containerName], { reject: false }); + for (const w of gitWorktreeRecords) { + await removeInBoxWorktree({ + hostMainRepo: w.hostMainRepo, + gitWorktreePath: w.gitWorktreePath, + }); + } + } else { + log(`seedWorkspace failed; leaving ${containerName} running so you can inspect it`); + } throw err; } } else { diff --git a/packages/sandbox-docker/src/docker-provider.ts b/packages/sandbox-docker/src/docker-provider.ts index 3682573..ab814c3 100644 --- a/packages/sandbox-docker/src/docker-provider.ts +++ b/packages/sandbox-docker/src/docker-provider.ts @@ -60,6 +60,7 @@ export const dockerProvider: Provider = { useSnapshot: po.useSnapshot ?? false, checkpointRef: req.checkpointRef, fromBranch: req.fromBranch, + useBranch: req.useBranch, image: req.image, onLog: req.onLog, claudeConfig: po.claudeConfig, diff --git a/packages/sandbox-docker/src/in-box-git.ts b/packages/sandbox-docker/src/in-box-git.ts index 27aca80..5c021b5 100644 --- a/packages/sandbox-docker/src/in-box-git.ts +++ b/packages/sandbox-docker/src/in-box-git.ts @@ -66,6 +66,13 @@ export interface RepoCarryOver { untrackedNul: string; /** Host dir to tar from (== repo.hostMainRepo, kept here so seedWorkspace doesn't need to know about the repo shape). */ hostSource: string; + /** + * Reuse the existing branch `` instead of forking a fresh one: + * `git worktree add ` (no `-b`, no base ref). Set by the + * `--use-branch` path for the root repo. When unset/false the worktree is + * created with `-b ` from `fromBranch ?? HEAD` (the default fork). + */ + reuseBranch?: boolean; } /** @@ -282,24 +289,17 @@ export async function seedWorkspace(opts: SeedWorkspaceOptions): Promise { for (const r of opts.repos) { const main = r.repo.hostMainRepo; const wt = r.gitWorktreePath; + // reuse: check out the existing branch directly (`git worktree add + // `). Git refuses if the host already has it checked out — that + // stderr is surfaced verbatim below. fork (default): create a fresh + // branch with `-b` from `fromBranch ?? HEAD` (root only; nested → HEAD). const baseRef = r.repo.kind === 'root' ? (opts.fromBranch ?? 'HEAD') : 'HEAD'; + const addArgs = r.reuseBranch + ? ['worktree', 'add', wt, r.branch] + : ['worktree', 'add', '-b', r.branch, wt, baseRef]; const add = await execa( 'docker', - [ - 'exec', - '--user', - 'vscode', - opts.container, - 'git', - '-C', - main, - 'worktree', - 'add', - '-b', - r.branch, - wt, - baseRef, - ], + ['exec', '--user', 'vscode', opts.container, 'git', '-C', main, ...addArgs], { reject: false }, ); if (add.exitCode !== 0) {