The reconcile is the whole risk. It plans a fate per member — keep, round,
create, delete, relink — and writes them as one applyWrites, because a chain
built a record at a time passes through a state the appview refuses. Two
adversarial rounds against injected damage and against ordinary git shapes are
under Done, and they are the reason the refusals are as loud as they are.
stack create --patch-only writes the chain without a push. The
default create publishes the branch and records source, which is how
the stack commands find the chain; a target the acting account cannot
push to had no stacked shape at all. The flag skips the push, records
no source, and writes the same dependentOn chain of patch-based
pulls. What it gives up is discovery: stack view, stack resubmit
and stack merge name members by at-uri, and Tangled's web stack
compose remains branch-based. Adoption of a sourced pull is refused
rather than silently converted.
An unrecognised state is no longer dropped. A vanished member's
state decided its fate three ways in three places, and the three
disagreed about a state this build has not heard of:
select_merge_range refused it, refuse_orphaned_by_rewrite counted
it as live, and reconcile put it in the bucket --prune deletes .
Reachable: State::Known carries whatever string the index returned —
sources.rs puts item["state"] straight into it — so a state Tangled
adds after this build ships arrives intact, which
PullState::from_token documents as expected rather than exceptional.
A member whose commits had left the branch and whose new terminal state
atgc could not read would have had its record removed.
"open" is now named as the only droppable state and anything else
refuses, with "?"'s status and reasoning: a state added since this
build may well be one that means landed
Six hand-rolled copies of "no PDS endpoint in the DID document"
answered the unclassified 1, while pds_or_fail — whose doc comment
already recorded consolidating eighteen copies of that same sentence —
answers NotFound. So atgc stack merge and atgc issue close
reported the same condition with byte-identical text and different
statuses. All six now call it; the pds_from_did_doc callers that
remain are the ones treating a missing endpoint as an answer rather
than a failure, which is why the Option still exists
Two more from the scenario sweep, both about a command doing something
other than what it said.
A rebase git stopped in the middle of was reported as a detached
HEAD. A conflicted rebase leaves HEAD on no branch, so every stack
verb answered "detached HEAD … git checkout <branch> first" — true,
useless, and advice that would throw away the resolution sitting in the
working tree. Somebody who has just hit a conflict is the likeliest
reader of any message here, and it was the one message that did not
mention the rebase. NoCheckout::MidRebase names it and points at
--continue, --abort and stack sync.
A mark outside the range is silently not a cut. A stack recorded as
grouped members reconciles as one pull request per commit when the mark
that grouped them sits on a commit the branch no longer has — which a
plain git rebase produces, exactly when nobody is thinking about
marks. stack mark reported it when asked; create and resubmit,
which are where the shape is actually decided, said nothing.
positions_and_stranded hands back both halves and they warn.
Also: I wrote a second rebase_in_progress before noticing the one
that has been there all along, used by stack rebase. The compiler
caught it. That is the fourth time this session a rule already existed
and I wrote it again, which is the thing docs/testing.md closes on
A sweep of every stacking scenario, asking of each whether it works or
says clearly why not. Six answered badly and are fixed.
Two exit statuses disagreed with themselves. stack view answered
a branch that is not a stack with Usage, and stack up/down/top/
bottom answered the same condition with the unclassified 1 — a
caller testing for a status got a different answer depending on which
verb it ran, which is what exit_status.rs exists to stop. --through
out of range joined them: a number the command line got wrong is
Usage, not "something went wrong, try again".
Two validations were missing from stack mark. A mark outside
base..HEAD was recorded and cut nothing — the cut is decided by where
a mark's commit sits among the range, one outside has no position, and
the stack that came out was the unmarked one with a branch left behind
claiming otherwise. Two marks on one commit end two pull requests at
the same place, leaving one with no commits in it. place takes the
base now, which mark had already computed and was not passing on.
git reported a missing branch as a missing repository.
git fetch origin nonexistent answers "fatal: repository '' not
found", because a knot 404s the ref advertisement for a ref it does not
serve. Passing that through told somebody their remote was wrong when
their branch name was. fetch probes ls-remote on failure to
separate the two, and is unit-tested against a real local remote —
the integration harness serves pushes through pushInsteadOf and
cannot answer a fetch, so there both halves look alike and a test would
pass without the code
stack merge attributes the merge commit to whose patch it is, not to
whoever is merging. author_did became authorName/authorEmail on
the knot's merge call, and was the acting account — correct only for as
long as own_chain refused anybody else's stack. Letting the repo
owner merge a contributor's stack made it wrong, and wrong in the worst
register available: git attribution is permanent, shows in every log,
and no record here would have shown it. pr merge has always sent the
pull's author for exactly this reason.
Found by reading the branch's own diff as a reviewer rather than by any
test — the comment beside the field still said "a stack is the acting
account's own chain, own_chain above refuses anybody else's", which
had stopped being true three commits earlier. A comment that
justifies a value is a claim, and changing what makes it true breaks it
as surely as changing the code.
stack create leases its batch when it adopts, and not otherwise. A
stack built from nothing writes only creates: no record can be
clobbered and an unleased batch is the right answer, which is why this
passed None and was correct to. Adoption changed the shape without
changing that — the bottom member became an Op::Update against a
record that already exists, and an unleased update lands whatever
happened to that pull since it was read, so another session appending a
round in between was overwritten with no sign of it.
resubmit, link and unlink have always leased. This is the one
that grew into needing it, two days after the lease decision was made
and correct. Found by sweeping the "stale and current" pair in
docs/testing.md — read a thing, mutate, write back without checking
it has not moved — and the asymmetry is asserted both ways, since a
lease on every create would refuse stacks for no reason
A knot that refuses and a knot that dies no longer read alike.
Exit::Unreachable means "retry later; a host did not answer", which
is right for a mergeCheck that never ran and wrong for a merge
whose outcome is unknown: the branch may already carry the patch, and
retrying is what somebody does when they believe nothing happened.
A merge is the one place atgc cannot avoid a two-phase commit — the
knot moves the branch, then the PDS records a merged status per pull —
and the failure between them was already handled and named. The failure
of the knot call was not: a 502 was reported exactly as a 401 from
the push-access guard is.
The distinction is whether the knot composed the refusal. A tagged 4xx
means it got far enough to decide; anything else leaves the question
open, and the answer is to look at the branch before retrying.
knot_decided has to run before explain_refusal, which rebuilds
the error into an exit::fail carrying no Refused to downcast — the
first version had them the other way round and put the ambiguity
warning on a 401 the knot had plainly decided. The third test caught it
World::knot_fails makes the knot fail per XRPC method, which is the
granularity the question needs: a merge calls it three times and which
call dies decides what is left behind. One switch for all of them could
not tell a check that never ran from a merge that may have landed
The two-author chain is a documented, tested boundary rather than an
undocumented one. No atgc command builds one — stack create cuts a
single branch and stack link refuses a pull that is not yours — but
Tangled's web UI can and chain_containing reads it, so what each verb
does with one was a real question with no answer written down.
It found one thing wrong, and it is this session's recurring shape: a
pull somebody else had stacked on was reported as "not stacked" ,
which is a claim about records the acting account does not hold. Off an
index atgc is not in a position to make it, and the author of a bottom
member was told their pull stood alone while somebody's work sat on
top. unstacked_caveat now says what was read — the PDS alone, or the
index too and its ingest stalls — at both sites that produce that
answer.
The rest was already right and is now pinned: the member above names
the one below through dependentOn and reports it missing rather than
claiming a stack of one, and neither account can reconcile a chain they
half own, since resubmit writes every member's record. That
asymmetry — detectable downward, invisible upward — is the reason the
two halves of this behave differently and is written down where the
next reader meets it
The repo owner can merge a contributor's stack , which the command's
own help has always promised — "the knot takes it from anyone it lets
push: the repo's owner" — and which stack merge refused outright with
"no pull request of yours found for branch". It entered through
own_chain, whose ownership rule belongs to the verbs that write
member records: resubmit, link and unlink edit the pulls
themselves, so every member has to be the acting account's. A merge
writes no member — it asks the knot to land the patch and records the
outcome as status records in the acting account's own repository,
exactly as pr merge does for one pull, which is why pr merge has
supported the maintainer since it shipped and this did not.
A maintainer's only path was pr merge on each member by hand,
bottom-up, in the right order — the thing stacks exist to stop being
manual. any_chain is the entry now, and own pulls still win it: a
branch name is not unique across accounts, and landing a stranger's
stack because it shares a name with yours would merge the wrong work.
Two other accounts with a pull on that branch is refused by name
rather than guessed at
Multi-contributor coverage for stacks, which was five tests of 113 and
all of them the contributor's side. The maintainer's side now has
three: the owner merging a contributor's stack and the status record
landing in the owner's PDS while the author's pulls go untouched, the
owner without push access refused by the knot rather than by atgc, and
the ambiguous branch. The middle one is worth keeping apart from the
first: owning the repo and being allowed to push are different
questions, and passing the first is not passing the second
The chain gate is mutation-tested rather than merely covered. Reversing
the order project applies ops in, ignoring its deletes, and walking
damage from one member instead of all three break two or three tests
each, which is what says the twelve adversarial cases around it bite:
ops applied in order to one record, a record created and deleted in the
same batch, a status record not projected as a pull, a chain spanning
two accounts read as ordinary rather than damaged, a second fork
elsewhere still refused while the first stands, and an empty batch
never refused.
Widening a fork that already exists is allowed, and that is a
decision: the parent is already unorderable, a third dependent does not
make it less readable, and refusing would block the repair — unlink
relinks a dependent before dropping a member and the state it projects
is exactly this shape
adopting_and_unlinking_leave_a_chain_both_readers_agree_on puts the
two newest verbs into a sequence with both readers checked after every
step: a plain pr create, grown into a stack by adopting it, a member
added on top, the middle taken out with unlink, and a reconcile over
what is left.
It caught its own weakness first. Mutating unlink to skip the
relink — the original fork shape — left the test passing, because
assert_stack_reads_alike asserts only that atgc and the appview read
the same chain, and two readers agree perfectly well on a chain that
is wrong. The shape is now named outright: the top inherits the bottom,
the unlinked member keeps its record with no parent, and nothing
depends on it. Under the same mutation it fails
Damage is identified by what it is, not by where the walk happened to
see it — found by writing the adversarial cases for the gate itself,
and it would have been the gate's own worst bug. Damage carries the
entry point the reader walked from, so comparing whole values made an
already-dangling chain look freshly broken the moment a member was
added above it: the walk from the new member reported the same missing
record under a different entry, after.difference(&before) called it
new, and the write was refused. Every stack that pages deep, or that
has a member in a contributor's PDS — a case the reader explicitly
supports and reports as Chain::missing_below rather than an error —
would have had its next write refused.
DamageKey is the identity: a fork is the parent two pulls share, a
dangle is the record nobody holds, and a cycle is nothing at all, since
a loop makes its whole component unreadable and the node the walk
notices it at varies with the entry as well. Three more adversarial
cases came with it: a batch that mints a member depending on one it
deletes in the same batch (atomic, so no single op looks wrong), a
relink that closes the chain into a loop, and a member depending on
itself
A change-id rewrite is undone when the command around it fails , so
"settle everything able to refuse before mutating anything" is enforced
rather than remembered. That rule was written down, applied to
stack create on 2026-08-15, and broken again in stack resubmit ten
days later, because "every step that can refuse" is a list nobody keeps
current and a comment cannot check one. Three of this epic's open items
were instances of it.
note_rewrite already wrapped both commands and already held the
Rewrite; it now calls Rewrite::undo before adding its context.
Blunt on purpose and safe for a specific reason: the rewrite is
commit-tree reusing every tree byte-for-byte, so was and now
differ only in messages and parentage — moving the ref back cannot
conflict, cannot touch the working tree or the index, and cannot lose
work, unlike the git reset --hard this used to tell people to run
themselves. It declines when the branch is no longer where the rewrite
left it, because then the commits on it are somebody else's, and naming
the reflog is what is honestly on offer.
Two refusals were hoisted above the rewrite anyway, one of them a real
defect rather than an ordering wart: stack create's adoption check
compares the adopted pull's patch shas against the branch's commits,
and --add-change-ids had just changed every one of them, so adopting
on a rewritten branch would have refused itself. The unmarked-branch
question moved up too, since asking it after rewriting eight commits is
asking too late. The batch and the blob upload are the two failures
that genuinely cannot be hoisted — only sending them finds out — and
they are what the undo is for
Every stack write is checked against the reader's rules before
anything is sent. This epic has 37 finished entries and 14 fix
commits, and the expensive ones are one bug wearing different clothes:
a stack is a linked list distributed across records, its invariants —
one live dependent per parent, no cycle, no dangling dependentOn —
lived only in chain_containing, and none of the five places that
write a chain ever asked it anything. write.rs is 5,178 lines and
called it exactly once, as create's "is there already a stack here"
precondition. So each verb re-derived by hand whichever invariants its
author had in mind, and the fix commits are the list of the ones that
were missed: retire left a fork, link took overlapping patches,
resubmit read past closed members. Every one of those fixes was correct
and local, and none made the next verb safe — adding stack unlink
was a fresh chance to break the chain, and so was adding adoption to
stack create.
The walk is now total: shape_containing returns Shape::Chain,
Alone, Absent or Broken(Damage), and chain_containing is a thin
adapter that turns the last back into the refusals it always printed,
so every existing caller and message is unchanged. project applies a
pending op list to the listing already in hand — every stack write
builds its ops in memory before one atomic applyWrites, so the
records that will exist are knowable before the first byte goes out —
and refuse_new_damage reads the result and refuses if the reader
would find damage that is not there already.
Worse, not merely broken , because a stack can be forked before a
command starts and that is exactly when somebody reaches for unlink;
a repair must not be refused for the damage it repairs.
It is a backstop and has no flow test, which is the honest shape: every
verb that can currently damage a chain still refuses by hand, so no CLI
path reaches it today. Its proof is unit tests that hand it the ops a
careless verb would build. The value is the verb nobody has written yet
stack create adopts the branch's existing pull request as the new
stack's bottom member instead of refusing it. A stack almost never
starts as one: it starts as pr create, and the second feature arrives
on top of it later — and that shape was one no command could produce.
stack create said "branch already has a pull request", stack resubmit said "not stacked", and stack link refuses two pulls whose
patches share a commit, which is exactly what pr create records for a
branch sitting on top of another. Three refusals arranged around the
ordinary case, and the workflow doc named the one of them that could
never work.
The adopted pull keeps its record key, and with it its number, its
comments and its rounds; it gains a round holding the bottom cut alone,
because the patch pr create recorded spans the whole branch. Its
identity is its commits , not its change-ids: reconcile matches
members by the Change-Id: headers in their patches, which every stack
member has because stack create injects them, and which a pr create
pull never has — the knot writes that header only from a jj change-id
on the commit object and never from a message trailer, so handing one
to reconcile refused it as a stack that "predates change-id
correlation". Adoption is refused for a pull that is not open, and for
one whose patch has no commit left on the branch, which would repoint
somebody's review at work it was not opened for.
relink_op and append_round_op came out of resubmit's write loop
so both commands share one copy of the record mutation; the round is
the half that cannot be undone
pr resubmit counts only the members of a chain that have not landed.
The objection to a whole-branch round on a stack member is the one the
message makes — the round would carry every other member's commits —
and a merged member's commits are in the target branch already, so they
are ancestors of the base the round is cut against and it cannot carry
them. Counting them anyway left the last member of a landed stack with
no verb that would take a round at all: stack resubmit reconciles by
change-id and cannot adopt a pull whose patches carry none, which is
every pull pr create writes. A chain in this repo reached exactly
that state and needed a hand-written com.atproto.repo.putRecord to
escape it
atgc stack unlink takes pulls out of a chain, closing the gap behind
them: each named pull's dependent inherits its parent, all in one
applyWrites. Naming every member dissolves a chain, so there is no
second verb for that.
It is the half link never had. Nothing removed a dependentOn:
stack resubmit clears one only as a side effect of retiring a closed
member, and it cannot touch a chain whose rounds carry no change-id
header — which is every pull pr create ever wrote. That leaves a
deadlock with no exit inside the tool: stack resubmit refuses because
it cannot correlate the members, and pr resubmit refuses because the
pull is a member, each pointing at the other. Two separate agents
reached it within a day, and the first needed a hand-written
com.atproto.repo.putRecord to escape. pr resubmit no longer counts
merged members, which removes one way in; this removes the rest
stack link refuses members whose patches share a commit, which is
the shape it used to require . A merge takes a member plus everything
unmerged below it and applies them as one series, so two members
carrying the same commit apply it twice — and a pull opened by
pr create records a patch of <target>..<its own branch>, so two of
those overlap whenever one branch contains the other. The only checks
here were about order , and the ancestry they demand is exactly what
produces the overlap; the refusal for the reverse order even advised
"rebase {b} onto {a}", which steered people into it.
Found by walking into it: a three-pull chain linked this way was opened
against this repo, and the knot refused the merge naming six files that
had nothing to do with the cause. The check is on the patches rather
than the branches, because the patch is what the merge applies — one
From boundary in common is the whole test. gitpatch::commit_shas
reads them, skipping anything that is not a well-formed sha, which errs
toward allowing a link rather than refusing over bytes nobody can read.
The order check stays; it answers a different question, and its advice
no longer points at the broken arrangement.
What this leaves undone is a way out : pulls already linked into an
overlapping chain cannot be serviced by either resubmit verb —
pr resubmit refuses a stacked member, and stack resubmit cannot
adopt pulls whose patches carry no change-id header, which is every
pull pr create ever wrote. Clearing a dependentOn currently needs
atgc api
Foundations: dependentOn named on the pull record (was preserved
opaquely in Pull::extra), per-commit git helpers (commits_since,
format_patch_one, change_id — jj commit header first, else
Gerrit-style trailer), and record::batch over
com.atproto.repo.applyWrites — atomic so a reconcile cannot
half-land, and one firehose event so a reorder never passes through
the transient two-pulls-on-one-parent state the appview rejects
stack create — one pull per commit of base..HEAD, single-commit
patches each carrying a Change-Id: mail header atgc injects (checked
empirically: git format-patch does not surface jj's commit header,
so injection is the only path), chained bottom-up with TID rkeys
minted client-side, one applyWrites. Refuses a single-commit range
(that is pr create), a branch whose pulls already exist, and commits
with no change-id. --add-change-ids is the announced rewrite: pure
commit-tree (trees and authorship byte-for-byte, no conflict
possible), untouched prefix keeps its shas and jj headers, existing
ids are restated as trailers, and the reflog records the old tip.
Proven live 2026-08-09 against the atgc-stack-lab scratch repo: three
records in one applyWrites, ids minted by the rewrite, appview
indexed and numbered the chain
--add-change-ids refuses when HEAD is not built on the base any
more. The rewrite is commit-tree, which reuses each commit's tree and
only changes its parent, so reparenting onto a base the tree never saw
turned every patch into a deletion of whatever that base had gained.
Observed 2026-08-13 on 0.15.0: a branch based on 55c4df0 was
submitted after origin/main had moved to 248281f, and the rewrite
produced commits parented on 248281f but carrying the pre-merge
trees, so every patch in the stack carried src/exit.rs | 298 ---- and
the stack would have reverted a merged PR on landing. A real rebase
cannot do this — none of the commits touched that file, so replaying
them on a parent that has it keeps it — so this is tree-preserving
reparenting rather than a replay. Silent: the only tell was one patch
being five times its own dry-run size. Reported by an agent that hit it
for real (pull 183). Fixed in d04a596: rewrite_with_change_ids
(src/clients/git/patch.rs) bails unless base is an ancestor of HEAD,
and names the rebase that fixes it. Of the two options, refusing is
settled — rebasing on the caller's behalf was rejected because
--add-change-ids is announced as a rewrite that cannot conflict and a
rebase can, so the step that might stop and ask stays the caller's to
run. Covered by
adding_change_ids_on_a_stale_base_never_produces_a_revert
stack create's summary marks the change-id column as abbreviated. The
Change-Id trailer is a full 40-hex string and the summary printed only
the first 8, which reads like the whole id: nine characters of a
forty-one character value in a fixed column reads as the whole value,
and somebody rebuilding a branch by hand retyped what they saw. The
commits then matched no pull, stack resubmit reported "N open pull(s)
match no commit on the branch any more" and correctly offered
--prune, which would have deleted four pull records and every review
comment on them. Of the two options — print the whole id, or say it is
abbreviated — the second shipped: ellipsize_change_id
(src/cmd/stack/write.rs) appends … when it cuts, and its doc comment
says the ellipsis is the whole point of the function. --json carries
it whole, for a caller that needs the value rather than recognizes it
A member holds as many commits as its change needs. One pull per commit
was read off jj's workflow and written into the docs as though the
protocol required it; it does not, and the cost was agents filing a
pull request per commit for changes that were one change. A member's
patch is now a mailbox with a Change-Id: header per commit
(with_change_id_headers), and the reconcile matches a record to a run
when any of the run's ids is one the record already claimed, so an
amend anywhere inside a member stays inside it. Every stack written
before this reconciles unchanged
Where the members end is marks : local branches recorded per branch in
branch.<branch>.atgcMark, placed by atgc stack mark <rev> (named
after the commit it lands on) and read back by every stack write. The
first cut inferred them — any branch whose tip fell in the range — and
that is wrong in a working repository: git branch backup before a
rebase, or an abandoned worktree's branch, silently re-cut the stack.
gh stack reaches the same shape from the other side: a stack is
inited and its layers added, never guessed at. With no marks left, the
cuts come back out of the records' change-id headers, so a fresh clone
still reconciles
Not done, and it should not be: naming a mark after the member's
bottom commit, to match the pull title stack create takes from
there. The mark is named after the commit it lands on, which is the
member's top, so for a grouped member the branch name and the pull
title come from different commits. The fix does not exist: a member's
bottom commit is whatever the mark below it ends at, and that mark may
not have been placed yet — stack mark part2 HEAD~1 after
stack mark part1 HEAD~3 changes what part2's bottom is, retroactively.
Naming from the bottom would be wrong as often as it is right, and the
listings already print the subject next to the name, so nothing is
being hidden
Seams the stack work left untested, now pinned: stack view's ? for
a member whose patch blob is gone, a mark whose branch was deleted,
--add-change-ids carrying the marks through the rewrite that moves
every commit under them, and pr diff on a member holding more than
one commit
atgc stack up, down, top, bottom and checkout — move between
a stack's members. The marks already say where each one ends, and the
branches are already there; what was missing was the reverse index, so
standing on a mark could not say which stack it cut. owners_of reads
it back out of the config that recorded it, which is the second reason
marks are recorded rather than inferred. Walking past an end stops
there rather than refusing: up run until it stops is how a script
finds the top
atgc stack link — chain pull requests that already exist, bottom
first, without reopening them. dependentOn is one field per record,
but until now the only way to write it was to build a chain from a
branch, so making three separately-opened pulls into a stack meant
cherry-picking them onto one branch, opening new pulls and closing the
originals: their numbers, rounds and review comments thrown away to
express an ordering. Written as one applyWrites because a chain built
a record at a time passes through a two-pulls-on-one-parent state the
appview refuses at ingest, and the order is checked against git wherever
both source branches are in the checkout
atgc stack rebase and atgc stack sync. Marks live in git, so they
have to travel when the branch does: rebase replays with
--update-refs, which a plain git rebase does not, and sync is that
followed by the reconcile — the pairing gh stack sync and Graphite's
gt sync both ship. Sync stops after the rebase if the rebase stops,
because a reconcile planned against a half-rebased branch would write
rounds for commits that do not exist yet. The halves stay for the cases
that want one: an amend needs no fetch, a conflict needs no reconcile
stack create refuses a cut that leaves one member, and asks before
opening a pull per commit on an unmarked branch of more than three.
The first was reachable with a mark on the top commit and wrote a pull
with no dependentOn — a pull request wearing a stack's command. The
second is the original complaint: eight commits became eight pull
requests with nothing said, and closing them is eight more acts
stack resubmit — reconcile the records with the rewritten branch by
change-id (read off each member's latest round patch, where alone it
exists): matched commits with changed bytes append a round, identical
bytes append nothing — one deliberate divergence from Tangled, whose
resubmit rounds every member; it is what makes a rerun a no-op, which
is also the partial-failure recovery. New commits create records,
vanished ones delete records only under --prune (a vanished merged
member becomes the chain's anchor instead — the new bottom keeps
depending on it), the chain is relinked to the new order, and it all
goes in one applyWrites. Refuses: members whose state cannot be pinned
as unmerged while Bobbin lags (for any destructive fate), updates to
merged pulls, stacks not authored by the acting account, and members
whose rounds carry no Change-Id header. The reconcile planner is pure
and unit-tested, and the write path is proven live against the
atgc-stack-lab scratch repo: no-op rerun, amend (rounds only where
bytes changed), insert + drop + relink + --prune in one batch, and
the prune refusal — all against real records
stack resubmit treated a freshly created stack as untouchable for
hours: a pull nobody has acted on has no status record anywhere, so
every member showed ? until Bobbin indexed, and the unknown-state
refusal fired on the stack's own author. Found live, first resubmit
after the first live create. When the acting account owns the target
repo, both authorities Bobbin honours for status records are that
account, so its own PDS is complete and ? resolves to open;
without ownership the conservative refusal stands
stack create put the Change-Id trailer into pull bodies: a commit
whose body was only its added trailer produced a pull whose entire
description was Change-Id: I…. Found live on the first created
stack. Bodies now drop Change-Id lines from the final trailer
paragraph — other trailers stay, and a Change-Id quoted mid-body is
prose and stays too
stack view — the whole chain with numbers, states and rounds, top
first; bails on an unstacked pull, pointing at pr view. The chain is
assembled in stack::chain_containing by following dependentOn both
ways, refusing forks (which the appview rejects at ingest, so one in a
listing means damaged data) and loops rather than smoothing them over;
a link naming a record outside the listing is reported as "continues
below", not treated as the bottom
pr view shows stack context implicitly. Every member shares
source.branch, so the branch match on a stacked branch used to pick
whichever member was created last and say nothing about the others — a
wrong answer in the shape of a right one. It now shows the top pull
as the detail and prints the chain under it
pr resubmit refuses a stack member outright — the one refusal in
"pr commands work on stack members": a whole-branch round is one the
appview can read no change-id from, which breaks the stack's
correlation for every later reconcile, permanently. The check reads
only the author's own PDS, on which no index can be behind
pr create warns (never blocks — the check is best-effort) when the
branch's pulls already form a stack, naming stack resubmit as the
way to update the stack itself
pr checkout of a stacked pull applies the pulls beneath it first,
bottom up — walked record by record along dependentOn (public reads,
no index consulted), latest round each, --only for the bare patch.
The stacked apply runs git am --empty=drop, so a member already
merged into the target three-way-resolves to nothing and drops.
Proven live against atgc-stack-lab: mid-stack, top and --only all
produce the right trees
The fresh-stack ? resolution moved from stack resubmit into
repo_rows, so stack view and the guards see it too: an authored
pull in an owned repo with no status record anywhere is open, not ?.
Scoped to authored-and-owned on purpose — anyone else's pull can have
state sitting in a PDS the read never saw
stack merge — the sub-stack from the bottom --through a position
(default: all of it) as one combined patch, the way Tangled's own
button lands a stack: mergeCheck first (also what --dry-run
stops after), one merge, then a merged status record per landed
pull, bottom up. Already-merged members contribute nothing; anything
not cleanly open is refused. Proven live on atgc-stack-lab, both
halves: --through 1 landed the bottom alone, and after a rebase the
resubmit reconciled the survivors around the merged member as the
chain's anchor — the one fate that had only unit tests — then a full
stack merge landed the rest. The complete lifecycle (create, view,
amend, resubmit, checkout, partial merge, anchor reconcile, final
merge) has now run CLI-only against real records
Adversarial round against injected damage (fork: two pulls on one
parent; cycle; dangling dependentOn; duplicate change-ids). Found and
fixed: a damaged chain — records the appview refuses at ingest but a
PDS happily holds — bricked pr view and pr create for the whole
branch. Both now degrade: pr view warns and shows the newest match
flat, pr create's stack warning is best-effort all the way down.
stack view/stack resubmit keep their loud, precise refusals — the
stack commands are where damage should stop someone. Duplicate
change-ids (a cherry-pick keeps the trailer, so this is an ordinary
accident) are now refused at stack create and on both sides of the
reconcile, naming every claimant; a HashMap was silently keeping the
last one, which would have routed rounds to the wrong pull. A deleted
mid-chain record shows as "continues below", named as a possibility
Adversarial round two, against ordinary git shapes. A merge commit
in the range produced a silent nonsense stack: rev-list interleaved
both parents' histories, the merge itself has no single patch, and
the change-id rewrite would have flattened it with wrong intermediate
diffs — now refused by every stack write (git rebase <base>
linearizes). An empty commit created a stack the knot refuses to
merge (its check answers "conflicted" with no files) — now refused at
create and resubmit, and the empty-conflict report prints the knot's
message instead of a blank list. pr checkout's --empty=drop
silently vanished an empty member from the checked-out branch — now
--empty=keep everywhere, so every patch in the input is visible in
the result and a flat empty pull checks out instead of stopping with
advice about a moved base. Hostile titles (backticks, quotes, emoji,
HTML, long) survived create, view, numbering and checkout untouched
Image-upload interplay pass, after body images landed mid-refactor.
The consolidation held (create_pull_op carries image handling once
for create and the reconcile's Add; the Update arm merges blobs the
same way flat resubmit does), but the strict image scan ran at
planning time over every commit — so a Keep member whose embedded
image left the disk (deleted by a later commit, never committed, or a
different worktree) bricked every resubmit, no-op reruns included.
The scan's refusal is now deferred to the fate that publishes:
proven live — image-bearing bottom kept while its file was gone,
no-op rerun clean, and the amended case still refused by name with
both remedies
Safety batch from the first full code review of the merged stack:
mergeCheck's verdict must be present (a truncated 2xx body read as
"clean" and merged); target.branch is read strictly (the "main"
fallback could land a merge on a branch nobody named); truncated
listings and dangling missing_below chains refuse the write
commands instead of re-minting or false-bottom-merging (repo_rows now
returns the flag gather always had); the changed-bytes compare strips
format-patch's version signature (raw equality broke rerun-is-a-no-op
across git versions and web rounds); forks are refused from every
chain entry point, not just the parent's side; resubmit's applyWrites
sends swapCommit read before the member re-reads (pds::latest_commit);
stack create's collision check reads the PDS alone so a Bobbin
stall cannot block it; the entry branch-match is scoped to the acting
account's own pulls; CRLF bodies normalize before trailer stripping;
latest_round_patch bounds the wire's Content-Length like fetch_patch
Concurrent member reads, the last of that review batch. resubmit and
merge both read every member with one .await per loop iteration,
and each read is a getRecord plus a blob download — the latest
round's patch, where the change-id lives and whose bytes decide whether
anything changed — so a ten-member stack was ten round trips end to end
before either command could plan anything. Both go through
read_members now, buffered at MEMBER_READ_CONCURRENCY. Ordered
rather than unordered, because both callers build a Vec indexed by
position in the chain and the chain's order is the whole subject;
reordering_the_branch_relinks_the_chain_onto_the_new_order is what
holds that, its three members all reading at once at this concurrency
The rest of that batch had already landed — the
rest landed: the listing-chain-states preamble is one loader now
(complete_rows/own_chain in stack/mod.rs), the blob-fetch path
is shared (pds::blob_bounded, used by review and the stack),
resubmit's Add slot reuses create's record builder
(create_pull_op), and of the performance batch, patches gzip once
and merged statuses write as one batch. Resubmit and merge still
read every member with one .await per loop iteration — no
join_all/try_join_all anywhere in the file
pr view <pull> details the pull named, instead of substituting its
stack's top for it. Every member of a stack used to answer with the
top's title, body, rounds and state — under the member's own number,
rkey, at:// URI or URL, with only --json's url disagreeing with
the question. The top promotion was right for the branch form, where
every member matches source.branch, and was being applied to both.
The chain listing now marks the member and says which of how many it
is, and --json's stack gained position
A closed stack member whose commits left the branch no longer needs
--prune. It is kept as-is and left out of the chain, with the
members around it relinked — closing a pull is how you take work out
of a stack deliberately, usually with a comment saying why, and
deletion was the only exit the reconcile offered. --prune is now
open pulls only, and the refusal says closing is the alternative
pr close warns when open pulls sit above the target in a stack, and
names them rather than counting them: "3 pulls depend on this" says
there is something to check and not what to check. Transitive
dependents are included, chain_containing already walking the whole
chain. Not a refusal — closing a member is the documented way to take
work out of a stack, and the note says stack resubmit relinks the
chain around it. pr reopen says nothing: restoring a member's open
state repairs the dependency a close broke. Best effort throughout, and
silent when the listing will not load: this is a note attached to a
write that is going to happen anyway, and a read that fails is a reason
to say nothing rather than to refuse a close somebody asked for
pr view/pr list printed ? for the same fresh pulls that stack view called open, because the promotion lived in repo_rows and they
read through gather directly. Done since, and the other way round
from what this entry proposed: settle_own_open moved into gather,
so every listing gets it and repo_rows gets it by going through the
same door. Its comment there says so
--add-change-ids rewrites the branch before atgc has a session.
ensure_change_ids moves refs/heads/<branch> at the top of both
create and resubmit, and agent_for_did is not called until a
hundred lines later. So an expired session — the ordinary case —
rewrites the commits and then fails with "could not refresh the
session", leaving new shas and no pulls. It is worse in resubmit: the
rewrite mints fresh I<sha> ids for the commits that lacked one,
reconcile then refuses because N open pulls match no commit on the
branch any more, and the remedy it offers is --prune, which deletes
those pulls and their review comments. The session is a pure
prerequisite and can simply be taken first. git reflog is named only
in the success message and wants naming in every failure below the
rewrite, with the pre-rewrite sha the rewrite already holds
Multi-contributor stacking, audited scenario by scenario rather than
feature by feature. stack merge exists so a maintainer can land a
contributor's stack and could not: it read every member's patch blob
from the merging account's PDS, where a contributor's blob is a 404,
and reported it as the contributor's patch being missing. The mock
served every blob to every account, so the flow's own test passed —
that is fixed too, and with the fix reverted the test now fails with
the message a maintainer would really have seen. stack unlink
planned a relink of whatever sat on the member being removed even when
that record was somebody else's, sending a stranger's record key to
our own repository; it is left attached now, and said out loud.
pr checkout of a stack member refused its own branch as "stale or
force-pushed" when the branch was in perfect order — a member's branch
is the stack's branch, so it can never corroborate one member's
patch, and the records are taken instead