Skip to content

Git bridge and branch workflows

The git bridge

In clone mode the container has its own working tree, so commits move between host and container over a small bridge — the container acts as a git remote — instead of round-tripping through GitHub. The transport is git's own ext:: helper running incus exec --user <uid> … git upload-pack (reading) or git receive-pack (writing) inside the container, so no daemon, port or key is involved. Little travels either way: the container was cloned with git clone --shared, so its alternates point at the host's object store and only the objects the other side actually lacks are sent. Each container records the base branch it was forked from (user.jailbee.base_branch, set at jailbee new time); pulls merge into that base and jailbee ls / jailbee git diff measure "ahead" against it.

Container → host:

jailbee git fetch feat-foo       # places the host branch + submodule branches, no checkout
jailbee git fetch feat-foo --as alt        # write host branch 'alt' instead
jailbee git fetch feat-foo --force         # overwrite a diverged host branch
jailbee git checkout feat-foo    # fetch + fast-forward/create the host branch, AND switch onto it
jailbee git checkout feat-foo --as alt     # …under a different host branch name
jailbee git pull feat-foo        # fetch + merge the container branch into its BASE branch
jailbee git pull feat-foo --current        # merge into the host's checked-out branch

jailbee git fetch fetches into refs/jailbee/<short>/<branch>, transports the submodule objects, then points the host branch and every submodule's branch of the same name at what the container has — without switching the working tree. jailbee git checkout does the same and then switches onto it. A host branch that has diverged is left alone with a warning; fetch's --force overwrites it, except when it is the branch currently checked out (always refused there, since moving it out from under the checkout would desync the index and working tree). To move the tree onto what fetch just placed, run jailbee branch <branch> afterwards.

On every container → host command, -b/--branch selects which branch is read inside the container — it never names the host-side branch. Naming the host side is a separate flag per command: jailbee git fetch --as <name>, jailbee git checkout --as <name>, jailbee git pull --into <name>. A -b naming a branch the container doesn't have is rejected up front, with the container's actual branch names listed.

jailbee git pull merges the container's branch into the recorded base branch (not the host's current HEAD). By default (pull.ff: auto) it fast-forwards when the host branch is strictly behind the container's tip and writes a merge commit otherwise — the same rule git merge itself uses; --into <branch> retargets the merge, --current merges into whichever branch the host currently has checked out instead (mirrors jailbee git push --current; mutually exclusive with --into), --ff demands a fast-forward and fails on divergence, --no-ff always writes a merge commit (the behaviour before 1.4.0 — set pull.ff: never to keep it permanently), --checkout checks the base out (staying on it) if it isn't current, and --cleanup/--no-cleanup force or skip the post-merge destroy + branch-delete (otherwise driven by the pull: config block). See Tags and Fast-forward policy below for the full picture. With no name on a TTY it opens a multi-select picker and stops at the first failure.

Host → container:

jailbee git push feat-foo                  # send a host branch in (source/action per config or prompt)
jailbee git push feat-foo --current --merge  # send current branch + merge it in the container
jailbee git push feat-foo --pr             # PR containers: refresh the PR head from GitHub first
jailbee git push feat-foo --from-local     # send the host's local branch, unfetched
jailbee pr feat-foo                        # create a draft PR, or push new commits + optionally update it

--merge/--rebase apply the pushed ref to the container's branch (conflicts left for jailbee shell); --plain is transport only. With no name on a TTY it opens a multi-select picker (failures don't stop the batch; ✓/✗ summary at the end).

--merge picks its own mode: a fast-forward when the container is already on the branch being pushed — which a --pr push always is — and a merge commit otherwise. When the container has commits the pushed ref does not, that fast-forward is impossible, and JailBee prints both commit counts and asks whether to make a merge commit instead. --no-ff answers that up front, --ff refuses it and fails on divergence. Off a TTY the divergence is an error naming --no-ff rather than a silent choice either way.

Confirming an auto-picked container

With two or more containers, jailbee git push / pull / checkout show a picker. With exactly one and no name argument they used to just go, which is where the "wrong branch" mistakes happened: neither the source branch nor the target branch is necessarily visible in the command line — push.default_source defaults to the container's user.jailbee.base_branch label, push.push_from defaults to the origin/ copy, and jailbee git pull merges into the recorded base branch.

So in that case the command now prints what it is about to do and waits:

Push  host ──▶ container
  container : feat-foo  (app-feat-foo, Running)
  source    : origin/main  a1b2c3d "Bump deps"
  target    : feat/foo     9f8e7d6 "WIP parser"
            : 4 commit(s) to apply
  action    : merge
Continue? [Y/n]

Enter proceeds — the value is in reading the block, not in an extra keystroke. Declining aborts before anything reaches the container, a host branch, or the working tree — except for what already ran to build the plan you're looking at: push's hoisted fetch (below), and, when push.default_source: ask led you to pick the PR-head option on a PR container, the refresh that already fetched the PR head from GitHub into the host's refs/jailbee/pr/<N>/head before the block was even printed. For push specifically, the host may already have run git fetch origin <source> by the time you decline: that fetch is hoisted ahead of the prompt so the block shows the tip the push would really send, not a stale one. It only advances the host's origin/<branch> remote-tracking ref — no container, no host branch, no working tree — so declining is still safe, just not a literal no-op on the push path.

--no-confirm skips the prompt for one run, --confirm forces it when the config has it off, and confirm.auto_target: false turns it off for the repo or the user. Off a TTY, pull and checkout still print the block and only skip the prompt. push behaves differently: without an explicit name it requires a TTY in the first place — off one it errors with "No container name given…" before it ever lists containers — so it never shows the block there either. A container named explicitly, or chosen from the picker, is never confirmed. If JailBee cannot build the plan at all (the container vanished, a daemon hiccup), the confirmation is silently skipped and the operation proceeds to produce its own error.

Which copy of the source branch travels

By default jailbee git push fetches origin/<source> on the host and pushes that ref, not refs/heads/<source>. git fetch only moves refs/remotes/origin/<branch>; the local branch advances on git pull. So for a branch you never check out on the host — usually the base branch that push sends by default — the local ref is stale precisely when you just fetched. Pushing it would also force-move the container's refs/jailbee/base/<base> anchor backwards, inflating jailbee ls AHEAD counts.

  • --from-local — push refs/heads/<source> as-is, skipping the fetch. Use when the host has commits not yet on origin.
  • --from-origin — force the origin ref (overrides push.push_from: local and the --current default).
  • --no-fetch — push the origin ref without refreshing it first.
  • --current always resolves locally: the checked-out branch is the work in progress.
  • --pr bypasses the choice: the head is fetched into refs/jailbee/pr/<N>/head and that exact ref is pushed, so --from-local/--from-origin are rejected alongside it.

When the origin ref is pushed while the local branch holds commits it lacks, jailbee warns with the count and points at --from-local — nothing is dropped silently. Configure the defaults with push.push_from / push.autofetch (see config.md).

Throughout this document origin means the upstream copy, not necessarily a remote literally called origin. jailbee resolves which remote that is (see Which remote is the upstream?); the flag names --from-origin / --from-local and the push_from: origin value keep the word regardless of what your remote is called.

What the push writes inside the container

A push updates up to three refs in the container:

Ref When
refs/jailbee/host/<source> always — the transport's landing ref, force-updated
refs/jailbee/base/<base> when the pushed source is the container's base branch, so jailbee ls AHEAD measures against the fresh base
refs/heads/<source> when it can be fast-forwarded (see below)

That last one exists because the refs/jailbee/* namespace is invisible to everyday git: a container whose local dev never moved makes an in-container git rebase dev silently use a stale base, and the container cannot fix that itself — its origin is the real upstream URL, so git fetch there needs network and credentials that strict mode does not grant.

The update is strictly fast-forward and never fails a push:

  • HEAD's own branch is skipped. Moving it would leave the index and working tree describing a commit the branch no longer points at, and receive.denyCurrentBranch refuses a push into it for the same reason. This is the case for a container forked from the base branch itself, and for every --pr push (a PR container is checked out on the head ref). --merge and --rebase advance that branch themselves.
  • An absent branch is created. git clone gives the container only the host's HEAD branch, so a container created off dev from a host sitting on main has no local dev at all until the first push.
  • A diverged branch is reported and left alone — a commit made in the container on that branch is never discarded here. Reconcile it by hand in jailbee shell, or compare against refs/jailbee/host/<source>.
  • The write uses git update-ref's compare-and-swap form, so a commit made in the container mid-push loses the race rather than the commit.

The push summary prints one line when the branch was created or fast-forwarded, a warning when it diverged or the update failed, and nothing in the two benign cases (already current, or HEAD's own branch).

Both jailbee git push and jailbee git pull print a one-line <source> (…) ──▶ <target> (…) banner before the detailed summary, so the direction of the sync is always unambiguous at a glance.

jailbee pull / jailbee push / jailbee diff / jailbee fetch / jailbee checkout / jailbee retarget / jailbee merge are top-level aliases. jailbee pr is a first-class top-level command in its own right (jailbee git pr is its hidden alias). jailbee merge came last and was withheld at first — that bare verb used to name today's jailbee git pull — but the merge target is never inferred, so the old jailbee merge <name> shape asks which container to merge into rather than quietly merging into the host. All bridge commands refuse on mount-mode containers (they share the host tree — use git on the host directly).

Tags

Tag transport is governed by pull.tags (container → host) and push.tags (host → container) — see config.md and config.md for the config keys. --tags / --follow-tags / --no-tags override the config key for one run on jailbee git fetch, checkout, pull and push (including its --merge/--rebase/--force modes); the three are mutually exclusive, and a flag always beats the configured default.

policy container → host (fetch, checkout, pull) host → container (push)
none no tags cross no tags cross
reachable tags reachable from the fetched branch, lightweight and annotated alike — this is what git's automatic tag-following has always done here, flag or no flag tags reachable from the pushed branch, computed with git tag --merged rather than git push --follow-tags (which sends annotated tags only and would silently drop lightweight ones)
all every tag the container has every tag the host has

pull.tags defaults to reachable, push.tags to none: tags have always flowed container → host implicitly (git's own tag-following) and never the other way, and these keys make that existing behaviour explicit and configurable rather than changing it.

No policy ever re-points an existing tag. No refspec carries +, so a tag that already exists at the destination keeps pointing where it does; moving one stays a deliberate manual git push --force.

A tag that already exists at the destination pointing elsewhere is not merely skipped — git rejects the ref, and that failure aborts the whole transfer. The two directions differ under reachable: container → host skips the conflicting tag silently (exit 0) because that is git's own automatic tag-following; host → container raises (a GitError), because each tag there is an explicit per-tag refspec that git can reject on its own. Under all, both directions raise. A raised push aborts before anything else runs, so jailbee git push --merge --tags never reaches the merge, and jailbee git pull --tags fails the whole pull after the branch has already moved. If a tag was deliberately moved on one side, re-point it with git push --force first rather than letting the transfer collide with it.

jailbee git merge (container → container) transports no tags on either leg, regardless of pull.tags/push.tags or the CLI flags: the relay runs source → host → target through the same two transports above, and inheriting the host's tag set would push it into a target container that asked for none of it.

jailbee pr / jailbee git push --pr never send tags to the GitHub origin, and there is no flag to make them — pushing a tag to a shared remote is an outward-facing, effectively irreversible act.

jailbee git retarget <name> <base> --merge does not honour push.tags or push.ff: the merge it performs always behaves as none for tags, and for ff it always writes a merge commit — retarget merges the new base into the container's own branch, which the container is never already checked out on, so the automatic ff-only case can't apply — and there is no flag to change either.

Fast-forward policy

ff (pull.ff / push.ff, or the --ff/--no-ff flags) decides whether a merge fast-forwards or always writes a merge commit. auto, the default for both keys, means something different in each direction:

operation never auto (default) always
jailbee git pull, merging into the currently checked-out branch always a merge commit fast-forward when the host branch is strictly behind, merge commit otherwise — git's own default demand a fast-forward; error on divergence
jailbee git pull --into <other> / --checkout, target not checked out and fast-forwardable fast-forwarded at ref level fast-forwarded at ref level fast-forwarded at ref level
jailbee git pull --checkout, target diverged merge commit merge commit (a fast-forward isn't possible here, so auto behaves like never) error — refuses to merge
jailbee git push --merge always a merge commit fast-forward when the container is already on the pushed branch (true of every --pr push), merge commit otherwise — asks (TTY) or errors (no TTY) if that fast-forward turns out impossible demand a fast-forward; error on divergence

A target branch that is not checked out is fast-forwarded at ref level under every value of ff — that is a ref move (git.fast_forward_branch), not a merge, so never cannot force a merge commit there without checking out a branch nobody asked to check out. --checkout adds a courtesy checkout onto that branch afterwards; it does not change which of the two paths ran.

jailbee git checkout stays fast-forward-only regardless of pull.ff — its contract is "fast-forward, or tell me to jailbee git pull" — and divergence there already points at jailbee git pull.

jailbee git fetch always uses a forced refspec (+) into its own refs/jailbee/<short>/* namespace; ff does not apply to it. Container-local branch placement and submodule placement are always fast-forward-only and never rewind a branch.

push's auto is not pull's auto. push's is the pre-existing branch-condition rule (unchanged by tag/ff support); pull's fast-forward- when-possible behaviour is new — see the CHANGELOG for what that changes against the previous release.

Merging one container into another — jailbee git merge

jailbee git merge                        # pick the sources, then the targets
jailbee git merge c1 --into c4
jailbee git merge c1 c2 c3 --into c4     # one at a time, stop on conflict
jailbee git merge c1 --into c4 --into c5 # both targets take c1
jailbee git merge c1                     # pick the targets only
jailbee git merge c1 --into c4 --plain   # transport only
jailbee git merge c1 --into c4 -b feat/x # read feat/x from c1

Objects travel source → host → target; no host branch, index or superproject working tree is touched (a host sub-repo can still be created, for a submodule born in the source container that the host has never seen). The merge runs inside the target on whatever it has checked out, so conflicts are resolved there, in jailbee shell <target>.

Neither end is ever inferred, but either may be left out on a TTY and is then asked for — the sources first, the targets second. Only running, clone-mode containers are offered: mount mode and a stopped container are refused at both ends anyway. Both prompts are checkboxes, and the sources are merged in the order the rows were listed, not the order they were ticked (the prompt says so) — with -b the source prompt becomes single-select, because one branch cannot describe several sources, while the target prompt stays a checkbox. Off a TTY both ends must be given, and the error names the ones that are missing.

A container cannot be named at both ends. Merging a container into itself would merge a branch into that same container's checked-out branch, which says nothing the target's own git merge does not say better. Each prompt hides the rows the other end already holds, and a collision that was typed instead (jailbee git merge c1 --into c1) is refused before anything is merged.

Several sources are merged into each target one at a time, in the order given, and stop there at the first conflict or failure: the next source would otherwise land on a tree left in merge state. Targets are separate containers and do not share that constraint, so a target that stops does not stop the ones after it. Every target prints what landed, what stopped it, what was not attempted, and the command that resumes where it left off — that report is the reason several sources are allowed at all. With several targets each block is headed by a rule naming its target, and the run closes with a roll-up of the state of every one of them:

Summary: 1 of 2 targets complete
  c4  stopped  merged c1 — stopped at c2: merge conflicts (c3 not attempted)
  c5  ok       merged c1, c2, c3

--plain transports the refs only and runs no merge; its report says "transported", not "merged" — do not read --plain as a kind of merge. -b/--branch reads a specific branch from the source and only applies with exactly one source. It does not carry submodules reliably — the transport enumerates the source container's checked-out state, not the branch being read; see Submodules.

Submodules

A container holds its own clone, which is what makes it disposable — and what makes submodules the hard part, because a sub-repo the peer has never seen has no objects to check out. JailBee moves them with the superproject, in both directions, without a round trip through the submodule's upstream.

On jailbee new. Submodules are initialised recursively and offline: each one's URL is pointed at the matching subdirectory of the read-only /mnt/host-source mount, submodule update --init runs from there, and submodule sync then repoints origin at the real upstream. Nothing is fetched over the network, and every submodule lands on the container's branch. Set new.submodules: false to skip the whole step (see config.md).

On jailbee git fetch / checkout / pull / push / merge. Submodule objects travel over the same ext:: transport the superproject uses. A sub-repo the peer is missing is created there first, so adding a submodule on one side and syncing works without preparing the other side by hand. Failures are loud: a SubmoduleError stops the operation rather than leaving the peer with a superproject whose gitlinks point at objects it doesn't have. fetch additionally points each submodule's branch of the same name at the container's state, exactly as it does for the superproject branch — without switching any working tree.

What you see. jailbee pull prints a delimited ── Submodules block after git's own output — per submodule new → <sha>, <sha> → removed, or a commit count with insertions and deletions — so a gitlink that moved is never buried in the superproject's diff. jailbee git merge prints the same block, once per source (each source is its own merge commit in the target), read from inside the target container: the merge commit exists nowhere else, so the host cannot resolve either end of that diff.

Limitation: the transported submodules come from the sender's checked-out state, not from the commit being sent. Both transports enumerate submodules with git submodule status --recursive in the sender's working tree, and move only what is reachable from each sub-repo's HEAD and its local branches. That is exactly right whenever the commit being sent is the one checked out — which is the default for every command here — and wrong when it is not:

  • -b <branch> / --branch <branch> on jailbee git merge, fetch, pull and checkout names a branch in the container other than the one checked out. A submodule that exists only on that branch is never enumerated, and a gitlink it pins that no local branch of the sub-repo reaches is never transported.
  • jailbee git push picks its source with --source, which defaults to the host repo's default branch rather than the checked-out one (and push.push_from: origin resolves it to refs/remotes/origin/<source>), so the same mismatch is reachable with no flag at all. It only bites when the default branch needs a submodule commit the host's checkout does not have — usually the container already has it from jailbee new.

The superproject merge itself succeeds either way: a gitlink is a tree entry, and git does not check that the commit exists. The failure surfaces one step later, when git submodule update --init --recursive runs as the verify gate — after the merge commit has been written — as A submodule commit is missing or a submodule is uninitialized. It may also not surface at all: --init falls back to cloning from the submodule's upstream, which succeeds in loose network mode for a commit that was pushed there, and never in strict mode or for a commit made inside a container.

So use -b only when you know that branch's submodule set matches the container's checkout — for a repo with no submodules it is unaffected. To move a branch that is not checked out with its submodules, check it out in the container first (jailbee shell <name>, git checkout <branch>), then run the bridge command without -b.

Conflicting gitlinks. When both sides moved the same submodule, git stops at CONFLICT (submodule) and leaves the pointer to you. JailBee merges it instead: inside each conflicted submodule it merges their commit into ours and stages the result, recursing into nested submodules whose own gitlinks conflict in turn. One pass attempts every submodule — a failure never stops the sweep — so a single jailbee pull (or jailbee git push --merge) hands you one report of everything rather than one conflict per run. If that clears the merge, the superproject commit is made for you and the operation succeeds. What is left over is grouped by what it needs:

  • in merge state — git stopped mid-merge here: resolve, git add, git commit
  • skipped, not touched — a dirty sub-repo (commit or stash, then re-run) or a gitlink that exists on one side only (pick a side by hand)

Ordinary file conflicts are never auto-resolved; they are listed alongside so you see the whole picture before starting.

Branch placement. jailbee branch puts the tree — superproject and submodules, recursively — on one branch. It is purely local — it moves nothing between host and container — and works on either side: with no --container it works on the host repo, with --container <name> on that container.

jailbee branch                              # host, align to current branch
jailbee branch master                       # host, whole tree to master
jailbee branch master --submodules-only
jailbee branch --container feat-foo         # container 'feat-foo', its branch
jailbee branch master --container feat-foo

jailbee submodule checkout is a hidden alias kept for compatibility; it prints a pointer to jailbee branch.

On the host, a BRANCH argument checks that branch out in the superproject first and then aligns the submodules to it — one command to jump the whole tree back to master and out again, the counterpart of jailbee git checkout <container> (which does the same thing towards a container's branch). --submodules-only leaves the superproject where it is: a deliberate mismatch, or a detached HEAD you want to keep. A container's branch is its identity, so BRANCH with --container never switches it — there it is pure submodule placement, and --submodules-only combined with --container is rejected (exit 2): there is nothing for it to opt out of.

Placement never rewinds a submodule branch. When a submodule's local branch is ahead of the gitlink recorded in the superproject — a submodule commit published without bumping the pointer — the newer branch stays checked out and the run warns instead: bump the gitlink with git add <sub> && git commit in the superproject.

Before you destroy. jailbee destroy's pre-flight check counts a changed submodule as work at risk — added, removed, committed ahead, or merely dirty — and names it in the summary, so a container is not thrown away because only its sub-repo held the change.

Each submodule also carries its own base anchor, seeded from the gitlink recorded at the superproject's refs/jailbee/base/<base>, which is what lets per-submodule comparisons stay meaningful on a stacked branch.

Submodule pull requests. jailbee submodule pr [<name>] [<path>] opens or updates a PR in a submodule's own GitHub repository — a separate repo from the superproject, so a separate PR from jailbee pr. Its signal is that same base anchor, never the superproject's gitlink diff jailbee ls uses: when you've committed inside a submodule but not yet committed the gitlink bump in the superproject, the gitlink diff reads zero while the anchor sees exactly the commits the PR is for. That gap is reported as information, not an error.

jailbee submodule pr feat-foo              # auto-target, draft PR
jailbee submodule pr feat-foo libs/foo     # explicit submodule
jailbee submodule pr feat-foo --ready      # mark ready for review

Base and head come from the submodule's own data, not the superproject's: base is --base > submodule.<name>.branch declared in .gitmodules (found by descending from repo root, unless .) > the sub-repo's <remote>/HEAD > main; head is --as > Claude's proposal > the branch the commits were read from. The remote is resolved per submodule, since a submodule may name its upstream something the superproject doesn't. Merge order is stated, never enforced: merge the submodule PR first, so the superproject PR's gitlink bump then points at a merged commit.

Stacked PRs

When PR1 (feat/a) is waiting for review and PR2 builds on top of it, base the second container on the first one's branch. The host is the hub: containers never push to GitHub directly, and chain maintenance is merge-based (never rebase a branch with work stacked on it).

jailbee new feat/a                       # work → jailbee git checkout feat-a → git push → PR1
jailbee new feat/b feat/a                # work → jailbee git checkout feat-b → git push → PR2 (base: feat/a)

# Review fix lands in container A:
jailbee git checkout feat-a && git push  # PR1 updates
jailbee git push feat-b --merge          # fix flows into B (push source = B's base = feat/a)

# PR1 merges on GitHub:
git checkout main && git pull
jailbee git retarget feat-b main --merge # B's base flips to main, main merged in
jailbee destroy feat-a --force

For longer chains, repeat the propagation per link: jailbee git checkout feat-b && git push, then jailbee git push feat-c --merge.

Merging several containers through one

Three features built in parallel in three containers eventually have to become one branch. The obvious way is three merges on the host — but the host is the one place with no test suite running, no lint gate and no agent. Send each branch into one of the containers instead and resolve every conflict there, where those things already are. The host stays what it is everywhere else in this document: a transport hub that resolves nothing.

Since all three sources are themselves containers here, jailbee git merge does this directly, without a host checkout for each source:

jailbee new feat/a
jailbee new feat/b
jailbee new feat/c
#   ... work in each container ...

jailbee git merge feat-a feat-b --into feat-c   # one at a time, stop on conflict
#   conflict? resolve inside feat-c, run the gates there, commit the merge,
#   then re-run from the resume command the summary prints

git checkout main
jailbee git pull feat-c --current    # all three features land on main

The long way — through push --current

The equivalent using only commands that predate jailbee git merge, and still what to reach for when a source is a plain host branch with no container of its own:

jailbee git checkout feat-a          # host HEAD → feat/a, ff-only, from the container
jailbee git push feat-c --current    # feat/a into container c, merged into its branch
jailbee shell feat-c                 # resolve, run the gates, commit the merge

jailbee git checkout feat-b
jailbee git push feat-c --current
jailbee shell feat-c

git checkout main
jailbee git pull feat-c --current    # all three features land on main

--current is what makes this work. push's default source is default_source: base — the container's base branch — so a bare jailbee git push feat-c would send main into container c, not feat/a. --current (like --pr) also resolves the source locally and skips the host fetch, which matters here because feat/a may exist nowhere but the host and its own container.

The action comes from push.default_action. Its built-in default is ask, so the commands above open a picker and you choose merge; with default_action: merge configured they merge with nothing extra typed. ask needs a terminal, though — run this recipe from a script and it exits with "push.default_action is 'ask' but no TTY is available", so a scripted version has to spell the flag out. Either way it must be a merge or a rebase — plain only transports refs/jailbee/host/<branch> into the container and never attempts to apply it, so no conflict ever surfaces to resolve. Spelling the flag out (jailbee git push feat-c --current --merge, as ## Stacked PRs above does) is always unambiguous.

A conflict leaves container c in merge state, exactly as it would on the host. Resolve it in jailbee shell feat-c or jailbee tmux feat-c and commit there.

Two things the last pull does not cover:

  • Cleanup of container c and the merged feat/c branch follows pull.destroy_container and pull.delete_branch (prompt | always | never each), so it may ask, act, or do nothing depending on config.
  • Containers a and b are untouched — their commits reached main through c, not through their own pull. Destroy them yourself when the branch is merged: jailbee destroy feat-a, then jailbee destroy feat-b (one name per invocation; with no name and a TTY you get a picker).

Choosing the starting point for jailbee new

<base> always names the container's base branch — the user.jailbee.base_branch label and the refs/jailbee/base/<base> anchor that jailbee ls AHEAD/MERGE and jailbee git pull are measured against. Whether jailbee new forks off it depends on whether <branch> already exists in the source repo:

Invocation <branch> in source? <base> in source? Result
jailbee new X no clone default branch, checkout -b X; base = default branch
jailbee new X yes clone X directly (review/test); base = default branch
jailbee new X Y no yes clone Y, then checkout -b X; base = Y
jailbee new X Y yes yes clone X directly; base = Y (confirmed first, -y skips)
jailbee new X Y (any) no error — base Y not in source (run git fetch origin Y)

No auto-fetch: missing refs fail fast with the exact command to run. A base that exists only as refs/remotes/origin/<base> is accepted; the anchor is seeded from that tip.

The last row is how you put an existing branch on the right base at creation time — jailbee git retarget is for changing a base afterwards.

Shortcut: jailbee new --current resolves <branch> from the host repo's currently checked-out branch (via git symbolic-ref --short HEAD). Cannot be combined with positionals; errors on detached HEAD.

Background creation

jailbee new blocks until the container is fully provisioned (init, clone, autostart) — often a few minutes. Pass --background (-b) to provision detached and get the shell back immediately:

jailbee new feat/foo --background

Track progress with jailbee ls: a JOB column shows the live phase (creatingcloningautostart) and the row drops back to a normal running container once it's ready. A failed background creation shows failed and leaves the container intact for inspection (jailbee shell <name>, then jailbee destroy). The detailed worker log is written under ${XDG_STATE_HOME:-~/.local/state}/jailbee/logs/.

Once you have fixed things by hand (jailbee shell <name> warns about the failed job and asks before letting you in), clear the record with jailbee job clear <name>; the container is not touched.

To make background the default, set it in ~/.config/jailbee/global.yaml (applies to every repo) or a repo's .jailbee/config.yaml:

new:
  background: true

With that default on, --no-background forces a one-off foreground run, and so does an explicit --attach shell/--attach tmux (or the --tmux / --shell shorthands) — a detached creation has no shell to attach to, so asking to attach means asking for the foreground. --attach none / --no-attach don't force foreground and combine fine with --background. Passing --background together with --attach shell/--attach tmux, --tmux, or --shell is a usage error.

jailbee start and jailbee restart take the same --background / -b / --no-background flags, tracked the same way (phases startingautostart). There the slow part is the on_start autostart run, not the boot, and one config key — boot.background: true — makes both detach by default. A detached boot is refused while another background job for that container is still live, since two of them would interleave their autostart steps.

Mount mode vs clone mode

jailbee new <name> is clone mode: the host repo is git clone --shared'd into /home/dev/<repo> inside the container. The container has its own working tree, isolated from the host's. jailbee git fetch / checkout / pull / push transfer commits between the two (see The git bridge).

jailbee new <name> --mount (or -m) is mount mode: the host directory at cfg.repo_root is bind-mounted RW into the container at the same in-container path. The container and the host share one working tree.

When to use mount mode:

  • The host directory contains submodules or nested checkouts that are awkward to clone.
  • You edit on the host (e.g. IDE on the host) and want to run/test in the container without manual sync.
  • The host directory is not a git repo at all — clone mode is unavailable; mount mode does not require .git.

Trade-offs:

  • Autostart steps (npm install, git fetch, ...) mutate the host working tree because the bind is shared.
  • jailbee git fetch / checkout / pull / push do not work on mount-mode containers — they error and tell you to use git on the host directly.
  • Concurrent writes from multiple mount-mode containers to the same file are not coordinated by jailbee; the kernel handles concurrent writes and git's own index lock handles concurrent git ops.

Example:

jailbee new mountfoo --mount
jailbee shell mountfoo
# inside container, /home/dev/<repo> IS the host directory; edits
# show up on the host immediately.

Reviewing a pull request

To spin up a container from a GitHub PR:

jailbee new --pr 1234

This fetches the PR's head into the source repo as refs/jailbee/pr/1234/head and checks the container's clone out at that commit. The head deliberately does not land in a branch: git fetch refuses to update a refs/heads/* ref that is checked out in any worktree, so fetching into one broke jailbee new --pr whenever the host had the PR's own branch checked out — and a stale or diverging local branch of that name must never decide what the container is built from. Your branches are left untouched. The PR number is stored on the container as user.jailbee.pr=1234 for future tooling.

The command requires the gh CLI and an authenticated session (gh auth login). Cross-repository (fork) PRs work without additional configuration.

Round-tripping a PR container

Pull in commits the author pushed after the container was created — the fetch runs on the host, so no jailbee net loose is needed:

jailbee git push <name> --pr --rebase    # or --merge

The container name is required here — --pr reads the container's own user.jailbee.pr label, so there is no picker to fall back on. The action flag is not: drop it and the merge/rebase/plain choice follows push.default_action, which defaults to ask (see Configuration) — a prompt on a TTY, and an error naming the config key off one. Pass the flag in scripts.

Both dashboards offer this as "Refresh from PR head" in the action menu, on review containers only: a PR JailBee opened from the container's own branch has its head downstream of the container, where the refresh could only be a no-op. The Qt dashboard asks the action in a dialog and never asks for a source, because --pr already is one.

Publish your own commits — either into the PR, or as a PR of your own:

jailbee pr <name>                        # asks once (menu), then updates PR #N
jailbee pr <name> --yes                  # non-interactive: push into PR #N's head
jailbee pr <name> --stacked --as fix/x   # non-interactive: open a PR based on PR #N

The first run on a review container asks what to publish, because both answers are legitimate:

Container 'review-1234' was created from PR #1234 by @alice (OPEN);
  head 'alice/work-type' → base 'main'.
? What should jailbee publish?  (Use arrow keys)
 ❯ push these commits to PR #1234's head 'alice/work-type'
   open a NEW PR based on 'alice/work-type'  (stacked)
   cancel

The confirmation is recorded on the container (user.jailbee.pr_adopted), so later jailbee pr runs push new commits without asking again. Fork PRs are refused: their head lives in another repository, so pushing to origin would create an unrelated branch instead of updating the PR.

Off a TTY the choice must come from a flag — --yes or --stacked — since neither can be guessed; --yes keeps the meaning it had before --stacked existed.

A PR against the PR — --stacked

Reviewing a PR often produces work of its own: a fix on top of the author's branch, a test they asked for. --stacked publishes it as a PR based on PR #N's head branch rather than pushing into it, so the author reviews your commits separately and merges them into their own branch.

jailbee pr review-1234 --stacked --as fix/worktime-review

That PR is JailBee's own, so it needs a head branch of its own: --as, or Claude's proposal (confirmed on a TTY). Publishing under the reviewed PR's head is refused with exit 2 — it would silently update that PR instead. This is what --no-ai --stacked hits, since the proposed name then defaults to the container's branch, which is that head.

It is recorded separately, under user.jailbee.stacked_pr / stacked_pr_branch / stacked_pr_author / stacked_pr_base. user.jailbee.pr goes on naming the reviewed PR, which is the point: jailbee ls's PR column and jailbee git push --pr ("refresh from PR head", above) keep tracking the parent, so the author's new commits still come to you over the bridge. Later jailbee pr runs read the stacked labels and update your PR with no question asked, and --open prefers it over the parent.

Two more things follow from the container's own base anchor being the reviewed PR's base (e.g. main), not its head:

  • Opening a stacked PR offers to retarget the container onto the PR head (--retarget / --no-retarget; the default asks on a TTY and otherwise skips, printing the command). Only then do jailbee ls's AHEAD and jailbee git diff count this container's own commits alone instead of folding in the whole reviewed PR. The anchor comes from JailBee's own refs/jailbee/pr/<N>/head — the PR head deliberately lives in no branch on the host, so a same-named local branch cannot decide it.
  • When PR #N merges, jailbee git retarget <name> main moves your stacked PR's container onto the merged base as usual.

A fork PR cannot be stacked on: its head is not a branch in your origin, so it cannot be the base of a PR opened there. JailBee says so and names the fork branch to open the stacked PR against instead. --stacked is also refused on a container that already publishes to a PR's head (adopted or JailBee-authored) — that head is fixed, and a stacked PR needs its own — and on a container that was never created from a PR, where jailbee new <branch> <base> is the way to base work on another branch.

The PR is still not JailBee's own, so on every run it stays hands-off in ways a jailbee-authored PR does not:

  • The description is never regenerated unless you ask for it (--description, --title, --body). The interactive "Update the PR description with Claude?" offer is suppressed — it would replace the PR author's text.
  • --force asks a second time, naming the head branch it would overwrite; --yes skips that too, and without a TTY it is an error.
  • --as is rejected (exit 2). That holds for any container with a PR, jailbee-authored ones included: the PR's head branch is fixed, so pushing to a different name would leave the PR untouched. On a review container that has not decided yet, the rejection waits for the choice: --as is legal with --stacked (it names the new PR's head) and refused — before any push or label write — when the run adopts.

A branch that already has a PR

The same treatment applies to a container that was never created from a PR at all — jailbee new <existing-branch>, where the branch happens to have an open PR already. Before opening anything, jailbee pr asks GitHub whether the container's branch has a PR (gh pr view <branch>) and, if it does, offers to push to that PR instead:

Branch 'alice/work-type' already has PR #77 by @alice (OPEN);
  head 'alice/work-type' → base 'main'.
Push this container's commits to PR #77 instead of opening a new one? [Y/n]

Confirming records user.jailbee.pr / user.jailbee.pr_branch / user.jailbee.pr_adopted — not user.jailbee.pr_author, because JailBee found this PR rather than opening it, so the hands-off rules above stay in force. Declining exits without publishing and points at --as <other-branch> for opening a separate PR; --yes skips the question, and without a TTY it is an error.

Two cases fall through to opening a new PR, each with a printed reason: a closed or merged PR (no longer a target for further work) and a fork PR (its head lives in the fork, so a same-named local branch is a different branch). Passing --as skips the lookup entirely — it already says you want a separate PR. The lookup is best-effort: no gh, no network or an origin that is not on GitHub simply means the ordinary create path runs.

Without this, the AI-proposed head branch name (claude.ai_pr_branch) would publish the work under a new branch and gh pr create would happily open a duplicate PR for it.

GitHub CLI (gh) inside containers

The gh binary is baked into every container's golden image. To make it authenticate (for AI agents like Claude that call gh pr view, gh issue create, etc.), opt in via ~/.config/jailbee/global.yaml:

github:
  enabled: true
  api_tokens:
    sampleapp:     github_pat_AAA...   # one entry per GitHub owner
    personal-tool: github_pat_BBB...

The dict keys are container_prefix values from each repo's .jailbee/config.yaml. Each container picks exactly one token — the one matching its repo's prefix.

GitHub fine-grained PATs are scoped per resource owner (one user or one org), which is why this is a map and not a single string: a user working across multiple orgs maintains one entry per owner.

Recommended PAT shape:

  1. github.com → Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token.
  2. Resource owner: pick the org (or your account) whose repos the agents will touch from this container.
  3. Repository access: Only select repositories → pick your work repos for that owner.
  4. Repository permissions:
  5. Contents: Read
  6. Issues: Read and write
  7. Pull requests: Read and write
  8. Metadata: Read
  9. Copy the token (github_pat_...), paste into api_tokens.

After editing, run chmod 600 ~/.config/jailbee/global.yaml. jailbee doctor warns if perms are loose, if the token is empty, or if it's a classic PAT (ghp_*) — those can't be scoped to specific repos.

PR merge, PR close, and existing-issue editing are intentionally left out of the recommended scope: keep agent write access narrow.

The github block must live in ~/.config/jailbee/global.yaml, never a repo's .jailbee/config.yamljailbee rejects it at the repo layer so tokens can't leak via git commits. See config.md for the full field reference, the 0600 permission requirement, and the jailbee doctor checks.