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— pushrefs/heads/<source>as-is, skipping the fetch. Use when the host has commits not yet on origin.--from-origin— force the origin ref (overridespush.push_from: localand the--currentdefault).--no-fetch— push the origin ref without refreshing it first.--currentalways resolves locally: the checked-out branch is the work in progress.--prbypasses the choice: the head is fetched intorefs/jailbee/pr/<N>/headand that exact ref is pushed, so--from-local/--from-originare 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
originmeans the upstream copy, not necessarily a remote literally calledorigin. jailbee resolves which remote that is (see Which remote is the upstream?); the flag names--from-origin/--from-localand thepush_from: originvalue 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.denyCurrentBranchrefuses a push into it for the same reason. This is the case for a container forked from the base branch itself, and for every--prpush (a PR container is checked out on the head ref).--mergeand--rebaseadvance that branch themselves. - An absent branch is created.
git clonegives the container only the host's HEAD branch, so a container created offdevfrom a host sitting onmainhas no localdevat 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 againstrefs/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>onjailbee git merge,fetch,pullandcheckoutnames 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 pushpicks its source with--source, which defaults to the host repo's default branch rather than the checked-out one (andpush.push_from: originresolves it torefs/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 fromjailbee 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/cbranch followspull.destroy_containerandpull.delete_branch(prompt | always | nevereach), so it may ask, act, or do nothing depending on config. - Containers a and b are untouched — their commits reached
mainthrough c, not through their own pull. Destroy them yourself when the branch is merged:jailbee destroy feat-a, thenjailbee 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
(creating → cloning → autostart) 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 starting →
autostart). 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 / pushdo 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 dojailbee ls's AHEAD andjailbee git diffcount this container's own commits alone instead of folding in the whole reviewed PR. The anchor comes from JailBee's ownrefs/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> mainmoves 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. --forceasks a second time, naming the head branch it would overwrite;--yesskips that too, and without a TTY it is an error.--asis 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:--asis 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:
- github.com → Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token.
- Resource owner: pick the org (or your account) whose repos the agents will touch from this container.
- Repository access: Only select repositories → pick your work repos for that owner.
- Repository permissions:
- Contents: Read
- Issues: Read and write
- Pull requests: Read and write
- Metadata: Read
- Copy the token (
github_pat_...), paste intoapi_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.yaml — jailbee 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.