//! Creating a stack: `stack create`.
//!
//! The write half of [`crate::cmd::stack`], on the `pr` write half's terms: it
//! settles which account it acts as before reading anything, announces it,
//! and honours `--dry-run`. What it writes is one `sh.tangled.repo.pull`
//! record per *member* of `base..HEAD` — the runs the local branches
//! pointing into the range cut it into, or one commit each when nothing
//! points into it — bottom first, each `dependentOn` the record before it, in a single `com.atproto.repo.applyWrites` — atomic,
//! so no partial stack can exist on the wire, and record keys are minted
//! here (monotonic TIDs) so every record can name its parent's at-uri
//! before anything is sent. This is the same shape Tangled's own web
//! compose writes.
//!
//! The change-id story is the part worth reading twice. A stacked round's
//! patch must carry a `Change-Id:` **mail header** — that is what the
//! appview correlates by — and `git format-patch` emits no such header, not
//! even for jj commits whose change-id lives in the commit object (checked
//! empirically; git knows nothing of that header). So the id is read off
//! the commit ([`crate::clients::git::patch::change_id`]: jj header first, trailer second)
//! and injected into the patch text by [`with_change_id_header`]. Commits
//! with no id at all are refused, with `--add-change-ids` offering the
//! rewrite ([`crate::clients::git::patch::rewrite_with_change_ids`]) that adds trailers.
//!
//! **The branch is published first, and that is what makes `source` true.**
//! Every member carries `source: {branch}`, which is not a hint: the appview
//! tells the three shapes of pull apart by that field alone and never checks
//! it (`appview/models/pull.go`, `IsPatchBased`/`IsBranchBased`). Writing it
//! for a branch nobody pushed is the untruth `pr create` stopped telling in
//! PR #282, and a stack cannot buy its way out the way a flat pull did:
//! `sh.tangled.repo.compare` answers about a *range*, and a member's patch is
//! one commit's, so the bytes still have to be formatted here. So the push is
//! kept and the compare is demoted to a proof, asked twice. Before anything
//! is rewritten or pushed, the knot is asked for `target.. `: a
//! tiny answer that says the knot is up and holds the target branch. After
//! the push it is asked once *per member*, from the commit below the member
//! to its tip ([`publish_branch`]), and a knot that has nothing there refuses
//! the create exactly as `handleBranchBasedPull` does. Never for the whole
//! range: that answer carries every commit's patch several times over and
//! outgrows [`crate::clients::http::MAX_BODY`] on a stack of twenty, while a
//! member's answer is one change's size whatever the stack around it.
//!
//! Dropping `source` instead was the other option, and it is not open. Three
//! separate things read it: `resubmitCheck` in `appview/pulls/single.go`
//! compares the *top* member's sha against the live branch head, the web's
//! resubmit routes a branch-based member through `repo.compare` and re-splits
//! the range by change-id (`appview/pulls/resubmit.go`), and
//! `appview/pulls/compose.go` will not compose a stacked pull that is
//! patch-based at all (`isStacked := mode == "stack" && !isPatchBased`).
//! atgc's own reads enter the same way — [`crate::cmd::pr::read::for_branch`]
//! matches on `source.branch`, which is how `stack view`, `stack resubmit`
//! and `stack merge` find a chain — so a sourceless stack would be one no
//! command here could ever pick up again. There is therefore no
//! `--patch-only` for a stack: a target that cannot be pushed to gets a
//! refusal, not a shape nothing can read.
//!
//! One thing the push does *not* buy, said here because the knot's source
//! settles it: `knotserver/git/diff.go` adds the `Change-Id:` header to its
//! format-patch only from a commit object's jj `change-id` extra header, and
//! never from a `Change-Id:` trailer in the message. atgc reads either
//! (trailers are what `--add-change-ids` writes), so a trailer-only branch
//! makes a stack whose *web* resubmit refuses for want of change-ids. The
//! per-member compare notices and says so; `atgc stack resubmit` is
//! unaffected, because it formats and injects the headers itself.
use crate::clients::git::patch as gitpatch;
use anyhow::{Context, Result, bail};
use jacquard::types::string::{AtUri, Datetime};
use jacquard::types::tid::Ticker;
use std::path::Path;
use crate::clients::atproto::record::{Key, Op, gzip, upload_patch_blob};
use crate::clients::git::run as git;
use crate::clients::tangled::resolve;
use crate::cmd::auth;
use crate::cmd::pr::read as listing;
use crate::lexicon::tangled::PULL_NSID;
use tangled_lexicon::LexiconSchema;
use tangled_lexicon::sh_tangled::repo::pull::{Pull, Round, Source};
#[derive(clap::Args, Debug)]
pub(crate) struct CreateArgs {
/// Target branch on the destination repo (defaults to the remote's default branch)
#[arg(long)]
pub target: Option,
/// Git remote pointing at the repo
#[arg(long, default_value = "origin")]
pub remote: String,
/// Rewrite base..HEAD to add Change-Id trailers to commits lacking one
#[arg(long)]
pub add_change_ids: bool,
/// Ignore the branches pointing into the range and open one pull
/// request per commit
#[arg(long)]
pub per_commit: bool,
/// Describe the stack without rewriting or sending anything
#[arg(long)]
pub dry_run: bool,
/// Print one JSON object describing what was written (or, with
/// --dry-run, what would be) instead of the summary lines
#[arg(long)]
pub json: bool,
}
/// One member of `stack create --json`.
#[derive(serde::Serialize, Debug, PartialEq)]
pub(in crate::cmd) struct CreatedMemberJson {
/// 1-based from the bottom, the order these are written and merged in —
/// and, unlike `stack view --json`, the order this array is in too,
/// because a create is a plan for a series of commits rather than a
/// picture of a chain.
pub position: usize,
/// The full commit sha this member was planned from — its bottom
/// commit, when it carries several. It does not survive
/// `--add-change-ids`, which rewrites the branch; the change-id does,
/// which is the whole reason both are here.
pub sha: String,
/// Every commit this member carries, bottom first. One entry, equal to
/// `sha`, for the ordinary member; a branch mark is what makes it longer.
pub shas: Vec,
/// `shas.len()`, spelled out so a caller counting members' commits does
/// not have to.
pub commits: usize,
/// The member's identity across rewrites: its bottom commit's change-id.
pub change_id: String,
/// True for the branch's existing pull request, taken as this stack's
/// bottom member rather than minted. Its `uri` is filled in on a dry run
/// too, because unlike every other member it is already a record.
pub adopted: bool,
/// Every change-id this member carries, parallel to `shas`.
pub change_ids: Vec,
/// The local branch whose tip ends this member, when one does. `null`
/// for the top member — which ends at HEAD, on the branch being stacked
/// — and for every member of a stack cut one commit at a time.
pub branch: Option,
pub title: String,
pub patch_bytes: usize,
pub patch_gzip_bytes: usize,
/// The pull record written for this commit, `null` on a dry run.
pub uri: Option,
pub images: Vec,
}
/// What `stack create` did, or would have done.
#[derive(serde::Serialize, Debug, PartialEq)]
pub(in crate::cmd) struct StackCreatedJson {
pub dry_run: bool,
pub repo_did: String,
pub target_branch: String,
pub source_branch: String,
pub total: usize,
/// Bottom first, as they are written and chained.
pub members: Vec,
/// True on a dry run that would have rewritten the branch to add
/// Change-Id trailers — in which case every `sha` above, and every
/// patch size, is what the rewrite would produce rather than what is on
/// the branch now. A real run has already rewritten by this point, so it
/// is false there.
pub rewrite_pending: bool,
/// Whether the branch was pushed to `remote` before the records were
/// written. The `source` every member carries is only a true claim
/// because of this, so it is reported next to them. False on a dry run,
/// which publishes nothing.
pub pushed: bool,
pub url: String,
}
/// One member's worth of stack, planned before anything is uploaded.
///
/// A member is a *run* of commits, not a commit: `shas` and `change_ids` are
/// parallel, bottom first, and hold one entry each for the ordinary
/// one-commit member. The bottom commit is the member's identity ([`Planned::change_id`])
/// and the source of its title and body; every commit in the run contributes
/// a message to the patch, carrying its own `Change-Id:` header, which is
/// what lets a reconcile still recognize the member after any of them is
/// rewritten.
struct Planned {
/// Bottom first, never empty.
shas: Vec,
/// Parallel to `shas`.
change_ids: Vec,
subject: String,
body: Option,
/// The member's patch: one message per commit, each with its own
/// `Change-Id:` header injected.
patch: String,
/// Local images the commit message embeds — or the scan's refusal,
/// deferred. `stack create` publishes every commit, so it fails fast on
/// any error; the reconcile plans over *all* commits before fates are
/// known, and a Keep member whose image left the disk (deleted by a
/// later commit, or never committed at all) must not brick a resubmit
/// that would never publish it. The error fires only when the member's
/// fate actually uploads.
images: Result,
}
impl Planned {
/// The bottom commit: what the member is named, sized and reported by.
fn sha(&self) -> &str {
&self.shas[0]
}
/// The member's identity across rewrites: its bottom commit's change-id.
/// A member that gains or loses commits above this one is still the same
/// member, which is what makes regrouping a branch survivable.
fn change_id(&self) -> &str {
&self.change_ids[0]
}
/// The bottom sha, cut to the width every listing prints shas at.
fn short(&self) -> &str {
&self.sha()[..7.min(self.sha().len())]
}
/// How many commits this member carries. `1` for the ordinary member,
/// and the reason several of the listings below have a plural form.
fn commits(&self) -> usize {
self.shas.len()
}
}
/// Scan a member's body for local images, with the commit named when it
/// refuses — "the body embeds …" alone would not say which message to fix.
fn scan_member_body(sha: &str, body: Option<&str>) -> Result {
match body {
Some(text) => crate::cmd::images::scan(text)
.with_context(|| format!("in the message of commit {}", &sha[..7.min(sha.len())])),
None => Ok(crate::cmd::images::Images::none()),
}
}
/// A member's body and patch in their published form: every local image
/// destination already reads as the `blob+at://` URI its bytes will mint.
///
/// Done at planning time, and both reasons fall out of prediction. The
/// reconcile decides "changed" by comparing patch bytes, and a patch that
/// only took its final text at upload time would compare different-forever
/// against its own stored rounds — every resubmit an Update, every Update a
/// spurious round. And the message travels with the patch into git history
/// when the stack merges, where a machine-local path is a dead reference
/// and a leak of the submitting machine's layout; the merged log should
/// carry the same content-addressed URIs the record does.
fn predict_member_rewrites(
did: &str,
body: Option,
patch: String,
images: &Result,
) -> Result<(Option, String)> {
let Ok(images) = images else {
// A deferred refusal: the member may never publish, and the bail
// that names the commit fires in `upload_member_images` if it does.
return Ok((body, patch));
};
if images.is_empty() {
return Ok((body, patch));
}
let body = match body {
Some(text) => Some(images.rewrite_with_predictions(&text, did)?),
None => None,
};
// Only the message section — everything above the first scissors line —
// is rewritten; the diff below it must never be touched. A commit
// message containing its own `---` line cuts the rewrite short, which
// misses a later image at worst and corrupts nothing.
let patch = match patch.split_once("\n---\n") {
Some((message, rest)) => format!(
"{}\n---\n{rest}",
crate::cmd::images::rewrite_message(message, &images.predicted_pairs(did))
),
None => patch,
};
Ok((body, patch))
}
/// A member's image blobs, uploaded and verified against the CIDs its body
/// and patch already spell; `None` when the commit message has none.
async fn upload_member_images(
agent: &jacquard::client::Agent,
p: &Planned,
) -> Result>> {
let images = match &p.images {
Ok(images) => images,
Err(e) => bail!(
"commit {} is being published, and {e:#}\n\
restore the file, or amend the message to drop the reference",
p.short(),
),
};
if images.is_empty() {
return Ok(None);
}
let blobs = crate::cmd::images::upload_predicted(agent, images).await?;
Ok(crate::cmd::images::merge_blobs(None, blobs))
}
/// Plan one member from a run of commits, ready to be sized, printed and
/// uploaded.
///
/// Shared by `create` and `resubmit` so the two cannot drift on what a
/// member *is*: the same patch, the same headers, the same title and the
/// same image handling, whether the run is one commit or five.
///
/// `mint_missing` is `create --dry-run`'s allowance: a commit whose
/// change-id the pending rewrite has not written yet is planned under the
/// id that rewrite will mint for it, so the dry run can describe a stack
/// the branch cannot yet produce. Everywhere else a missing id is a bug by
/// this point — [`ensure_change_ids`] has already refused it — and says so.
fn plan_group(
did: &str,
commits: &[String],
group: &[usize],
mint_missing: bool,
) -> Result {
let shas: Vec = group.iter().map(|&i| commits[i].clone()).collect();
let mut change_ids = Vec::with_capacity(shas.len());
for sha in &shas {
match gitpatch::change_id(sha)? {
Some(id) => change_ids.push(id),
None if mint_missing => change_ids.push(format!("I{sha}")),
None => bail!("commit {sha} still has no change-id"),
}
}
let bottom = &shas[0];
let (subject, body) = subject_and_body(bottom)?;
// The title and body come from the bottom commit even when the member
// carries several: a member is one pull request, and the commit its
// reviewer reads first is the one that names it. `atgc pr edit` is how
// that gets a better title without rewriting git history for it.
let raw = match shas.len() {
1 => gitpatch::format_patch_one(bottom)?,
_ => gitpatch::format_patch_range(bottom, shas.last().expect("non-empty"))?,
};
let patch = with_change_id_headers(&raw, &change_ids)?;
let images = scan_member_body(bottom, body.as_deref());
let (body, patch) = predict_member_rewrites(did, body, patch, &images)?;
Ok(Planned {
shas,
change_ids,
subject,
body,
patch,
images,
})
}
/// Plan every member of a cut branch, bottom first.
fn plan_groups(
did: &str,
commits: &[String],
groups: &Groups,
mint_missing: bool,
) -> Result> {
groups
.iter()
.map(|group| plan_group(did, commits, group, mint_missing))
.collect()
}
/// **A change-id rewrite is undone when the command around it fails.**
///
/// The rewrite is the only step in either of these commands that changes
/// something outside the process before the process has finished deciding.
/// The rule that follows from that — settle everything able to refuse before
/// mutating anything — was written down, fixed in `stack create` on
/// 2026-08-15, and broken again in `stack resubmit` ten days later, because
/// "everything able to refuse" is a list nobody keeps current and a comment
/// cannot check. Two of this epic's open items were instances of it.
///
/// So the rule is enforced instead of remembered: on any failure the branch
/// goes back to where it was, and a refusal arriving after the rewrite costs
/// nothing but time. [`gitpatch::Rewrite::undo`] is safe to do bluntly —
/// `commit-tree` reuses every tree, so only messages and parentage differ —
/// and declines when something else has moved the branch since, which is the
/// one case where the old shas are not this command's to restore.
///
/// Applied by wrapping the whole command rather than each fallible step,
/// because "each fallible step" is the list that went stale twice.
fn note_rewrite(outcome: Result, rewritten: Option<&gitpatch::Rewrite>) -> Result {
match (outcome, rewritten) {
(Err(e), Some(rewrite)) => Err(match rewrite.undo() {
Ok(true) => e.context(rewrite.restored()),
// The branch moved under this run, so the shas on it now are not
// this command's to discard. Naming the reflog is all that is
// honestly on offer.
Ok(false) => e.context(rewrite.recovery()),
Err(undo) => {
crate::logging::debug::dump_err("could not put the branch back", &undo);
e.context(rewrite.recovery())
}
}),
(outcome, _) => outcome,
}
}
/// Split the current branch into a chain of dependent pull requests: one
/// per commit, or the runs the branch marks cut. A branch that is one
/// change from bottom to top belongs in a single pull request instead, via
/// `pr create`.
pub(crate) async fn create(args: CreateArgs) -> Result<()> {
let mut rewritten = None;
note_rewrite(create_inner(args, &mut rewritten).await, rewritten.as_ref())
}
async fn create_inner(args: CreateArgs, rewritten: &mut Option) -> Result<()> {
crate::term::jsonout::init(args.json);
let selection = crate::config::account::select().await?;
selection.announce();
let branch = git::current_branch()?;
let remote_url = git::remote_url(&args.remote)?;
let target_branch = match args.target {
Some(t) => t,
None => git::remote_default_branch(&args.remote).unwrap_or_else(|| "main".to_string()),
};
let base = format!("{}/{}", args.remote, target_branch);
if !git::ref_exists(&base) {
git::fetch(&args.remote, &target_branch)?;
}
let commits = gitpatch::commits_since(&base)?;
match commits.len() {
0 => bail!("no commits between {base} and HEAD; nothing to stack"),
1 => bail!(
"one commit between {base} and HEAD; a stack of one is a pull request\n\
`atgc pr create` is the command for it"
),
_ => {}
}
refuse_merges(&base)?;
let repo = resolve::repo_ref(&remote_url).await?;
// A branch that already has records is not this command's to write to.
// The check needs only the acting account's own pulls — a stack this
// command would collide with is one it (or the web) wrote as this
// account — so it reads the PDS alone: complete for own records, and
// standing when Bobbin is not, which is chronic. Auto here meant a
// Bobbin stall could block a first-ever stack it had nothing to say
// about. Truncation still refuses: a cap that hides the existing stack
// would let this mint a duplicate.
let listing = listing::repo_rows(listing::Source::PDS, &repo.did).await?;
if !listing.complete() {
bail!(
"the pull listing hit its page cap, so an existing stack on {branch} could \
be invisible\n\
refusing to create one that might collide with it"
);
}
let rows = listing.rows;
let items: Vec<&serde_json::Value> = rows.iter().map(|r| &r.item).collect();
// **The branch's existing pull, if it has one, becomes this stack's
// bottom member rather than blocking it.**
//
// A stack almost never starts as one: it starts as `pr create`, and the
// second feature arrives later. Refusing here left that person nowhere
// to go — `stack create` said "already has a pull request", `stack
// resubmit` said "not stacked", and `stack link` refuses two pulls whose
// patches overlap, which is exactly what `pr create` produces for a
// branch sitting on top of another. Three commands, three refusals, and
// the shape they all described is the ordinary one.
//
// Adopting keeps the number, the rounds and the comments — the same
// promise `link` makes — and the reconcile below decides what the record
// needs: its patch spans the whole branch and a member's spans one cut,
// so in practice that is a new round saying so.
let adopt = match listing::for_branch(items.iter().copied(), &branch) {
None => None,
Some(existing) => {
let uri = existing["uri"].as_str().unwrap_or_default().to_string();
let title = existing["value"]["title"].as_str().unwrap_or("(untitled)");
if let Some(chain) = super::chain_containing(&items, &uri, &super::closed_uris(&rows))?
{
bail!(
"branch {branch} already has a stack of {}: `atgc stack view` shows it\n\
`atgc stack resubmit` reconciles it with the branch as it stands now",
chain.size()
);
}
// Only an open pull is a member to build on. A merged or closed
// one is history, and `?` is a state no listing could settle —
// adopting either would hang a new stack off a record that is
// not going to be reviewed.
let state = super::state_of(&rows, &uri);
if state != "open" {
bail!(
"branch {branch}'s pull request is {state}: {title}\n\
a stack builds on an open pull; start this one from a branch of \
its own, or reopen that pull first"
);
}
Some(existing.clone())
}
};
// The session is settled before the branch is. `--add-change-ids` moves
// `refs/heads/` and this used to run a hundred lines below it,
// so the ordinary case — a session that expired an hour ago, which is
// what `oauth::client::client_metadata` documents — rewrote four commits and then
// failed with "could not refresh the session", leaving new shas and no
// pull requests. Resuming a session depends on nothing the rewrite
// touches, so it is a prerequisite and is taken as one.
//
// A dry run keeps needing no session at all: it rewrites nothing, sends
// nothing, and is the thing to reach for when there is any doubt.
let agent = match args.dry_run {
true => None,
false => Some(auth::agent_for_did(&selection.did).await?),
};
// The knot likewise: which one it is, and that it is up and holds the
// target, are both decidable now and both used to be found out after
// the push — that is, after the rewrite too.
let knot = match args.dry_run {
true => None,
false => Some(knot_holding_target(&repo, &target_branch, &base).await?),
};
// Cut and plan the branch as it stands, *before* anything rewrites it:
// every refusal below is decidable from these commits, and deciding them
// after the rewrite is how a refusal used to arrive with the branch
// already moved. Planning writes nothing anywhere, so doing it twice
// costs only the second pass.
let cut_and_plan = |commits: &[String]| -> Result<(Cut, Vec)> {
let (marks, stranded) = super::marks::positions_and_stranded(&branch, commits);
warn_stranded_marks(&stranded);
let cut = match args.per_commit {
true => Cut::per_commit(commits.len()),
false => cut_at_marks(commits.len(), &marks),
};
let planned = plan_groups(&selection.did, commits, &cut.groups, true)?;
Ok((cut, planned))
};
let (cut, planned) = cut_and_plan(&commits)?;
refuse_unplannable(&planned, commits.len(), &base)?;
let unmarked = cut.labels.iter().flatten().next().is_none();
// The adopted pull is the bottom member, and its identity is its
// commits rather than its change-ids.
//
// `reconcile` matches members by the `Change-Id:` headers in their
// patches, which every stack member has because `stack create` injects
// them. A pull opened by `pr create` does not: its patch is the knot's
// own `compare` output, and `knotserver/git/diff.go` writes that header
// only from a commit object's jj change-id, never from a `Change-Id:`
// message trailer. Handing one to `reconcile` therefore refuses it as a
// stack that "predates change-id correlation", which is the wrong thing
// to say about a pull that was opened yesterday.
//
// What it does have is commits, and they are the branch's own — so the
// check is that its patch and this branch still describe some of the
// same work. A pull whose commits have all left is one this branch was
// replaced under, and rewriting it to a cut of the new history would
// quietly repoint somebody's review at unrelated work.
let adopted: Option = match &adopt {
None => None,
Some(item) => {
let m = old_member(item, "open").await?;
let its = gitpatch::commit_shas(&m.latest_patch);
if !its.is_empty() && !its.iter().any(|sha| commits.contains(sha)) {
bail!(
"{} ({}) is this branch's pull request, but none of the commits in \
its latest round is still on the branch\n\
adopting it as this stack's bottom would repoint its review at \
work it was not opened for\n\
`atgc pr resubmit` brings it up to the branch first, or start the \
stack from a branch of its own",
m.title,
m.rkey,
);
}
Some(m)
}
};
// An unmarked branch of any length opens a pull request per commit, and
// that is a fine default for two or three — it is the jj-shaped
// workflow. Past that it is almost never what somebody meant: the usual
// cause is not knowing marks exist, and the result is eight pull
// requests nobody can review, each one commit of a change. Eight records
// are also eight records to close by hand, since a stack is not
// something `create` can undo.
//
// So the wide case says what it is about to do and asks to be told
// again, either by marking the cuts or by saying `--per-commit` out
// loud. `stack.askWhenUnmarked false` is how a checkout stops being
// asked.
if !args.per_commit && unmarked && commits.len() > UNMARKED_LIMIT && asks_about_unmarked() {
bail!(
"{} commits over {base}, and no marks: that is {} pull requests of one commit \
each\n\
cut it where the changes are — `atgc stack mark `, once per pull request \
— or say `--per-commit` if a pull per commit is what you want\n\
`git config stack.askWhenUnmarked false` stops this checkout asking",
commits.len(),
commits.len(),
);
}
let rewrite_pending = ensure_change_ids(
&base,
&commits,
args.add_change_ids,
args.dry_run,
rewritten,
)?;
// Re-read and re-plan only when a rewrite really ran: it moved every sha
// from the first missing id onward, and the marks with them.
//
// The commits themselves are no longer wanted past this point — every
// refusal that reads them now happens above the rewrite, which is the
// whole of the ordering fix — so only the cut and the plan come back
// out. `planned` carries the new shas it was built from.
let (cut, planned) = match rewritten.is_some() {
false => (cut, planned),
true => cut_and_plan(&gitpatch::commits_since(&base)?)?,
};
// A commit whose id the pending rewrite has not minted yet is planned
// under the id that rewrite will give it, which only a dry run can see.
// Compressed once, for the plan's sizes and the uploads both — every
// patch was being gzipped twice, and the second run could only ever
// agree with the first.
let mut gzipped: Vec> = Vec::with_capacity(planned.len());
for p in &planned {
gzipped.push(gzip(&p.patch)?);
}
let mut report = StackCreatedJson {
dry_run: args.dry_run,
repo_did: repo.did.clone(),
target_branch: target_branch.clone(),
source_branch: branch.clone(),
total: planned.len(),
members: planned
.iter()
.enumerate()
.map(|(i, p)| CreatedMemberJson {
position: i + 1,
sha: p.sha().to_string(),
shas: p.shas.clone(),
commits: p.commits(),
change_id: p.change_id().to_string(),
change_ids: p.change_ids.clone(),
adopted: false,
branch: cut.labels.get(i).cloned().flatten(),
title: p.subject.clone(),
patch_bytes: p.patch.len(),
patch_gzip_bytes: gzipped[i].len(),
uri: None,
images: p.images.as_ref().map(|i| i.listed()).unwrap_or_default(),
})
.collect(),
rewrite_pending,
pushed: false,
url: format!("{}/pulls", repo.web_url),
};
// Said before the plan is printed, and filled into the report, so a dry
// run shows which pull is being built on rather than describing a stack
// of records that do not all need creating.
if let Some(m) = &adopted
&& let Some(member) = report.members.first_mut()
{
member.adopted = true;
member.uri = Some(m.uri.clone());
}
if !args.json {
println!("target: {} branch {}", repo.linked(), target_branch);
// Where it is going, not just where it is: the branch is published
// to `remote` before any record claims it, and the git progress that
// follows this line is that push.
println!("source: branch {branch} -> {}", args.remote);
match cut.labels.iter().flatten().count() {
0 => println!(
"cut: no marks on this branch: a pull per commit \
(`atgc stack mark ` cuts it)"
),
n => println!("cut: {n} mark(s), each ending a pull"),
}
println!("stack: {} pulls, bottom first:", planned.len());
for (i, p) in planned.iter().enumerate() {
let gzipped = &gzipped[i];
// The change-id is the one identifier that will survive every
// rewrite, so the plan names it next to the sha that will not.
//
// Marked when it is cut, and that mark earns its column. A
// change-id is 40 hex characters after the `I`; nine of them in
// a fixed column reads like a whole value, and somebody
// rebuilding a branch by hand copied what they saw into fresh
// `Change-Id:` trailers. Those commits then matched *no* pull,
// and `stack resubmit` correctly offered `--prune` — which
// would have deleted four pull records and every review comment
// on them. `--json` carries the id whole, and is the thing to
// read when the value is needed rather than recognized.
let id_short = ellipsize_change_id(p.change_id());
let carried = match (cut.labels.get(i).and_then(Option::as_deref), p.commits()) {
(None, 1) => String::new(),
(None, n) => format!("{n} commits, "),
(Some(name), 1) => format!("{name}, "),
(Some(name), n) => format!("{name}, {n} commits, "),
};
println!(
" {}/{} {} {id_short} {} ({carried}{} bytes, {} gzipped)",
i + 1,
planned.len(),
p.short(),
p.subject,
p.patch.len(),
gzipped.len(),
);
// A member of several commits says which, in order: the count
// alone leaves the reader checking the marks against `git log`
// by hand, which is the moment a miscut is cheapest to catch and
// the most expensive to miss.
if p.commits() > 1 {
for (sha, subject) in p.shas.iter().zip(subjects_of(&p.shas)?) {
println!(" commit {} {subject}", &sha[..7.min(sha.len())]);
}
}
for line in p.images.as_ref().map(|i| i.describe()).unwrap_or_default() {
println!(" image: {line}");
}
}
}
// A stack cut at branch tips only stays cut if those tips travel with
// their commits, and a plain `git rebase` leaves them behind — on
// commits the branch no longer has, where the next reconcile sees a
// stack with no cuts in it at all. git has moved them since 2.38, but
// only when asked, so the run that first depends on it says so.
if !args.json
&& cut.labels.iter().flatten().next().is_some()
&& !gitpatch::rebase_updates_refs()
{
crate::term::say::note!(
Git,
"keep this stack in step with `atgc stack sync`, which rebases and reconciles \
together; a plain `git rebase` leaves these marks behind unless it is given \
--update-refs"
);
}
if !args.json
&& let Some(m) = &adopted
{
// The at-uri rather than a link: a pull's number is the appview's
// and no record holds it, and this line is printed before anything
// is written, so there is nothing yet to look up.
println!("adopt: {} keeps its rounds as the bottom member", m.uri);
}
if args.dry_run {
if args.json {
return crate::term::jsonout::emit(&report);
}
if rewrite_pending {
println!(
"would rewrite the branch first to add the missing Change-Id trailers, \
so shas and patch sizes above are approximate"
);
}
println!("dry run; nothing sent");
return Ok(());
}
// The claim every record below makes, made true first. Nothing before
// this point left the machine; from here the branch is on the target's
// knot and the knot has confirmed what it holds.
// A create has no prior round to lease against: an unleased push is
// fast-forward-only, which is the right answer for a branch nothing has
// recorded yet.
let knot = knot.expect("a run that is not a dry run found its knot above");
report.pushed = publish_branch(&args.remote, &branch, &knot, &repo, &planned, None).await?;
let agent = agent.expect("a run that is not a dry run resumed its session above");
// A stack with nothing to adopt is a walk of nothing but `Add`s, which
// is what this was before it could adopt anything. Going through the
// reconcile's own vocabulary either way is what lets `chain_ops` be the
// one walk both commands use.
let chain: Vec = match &adopted {
// The adopted pull is position zero, and its record needs a round
// unless its patch already says exactly what the bottom cut says.
Some(m) => {
let bottom = match m.latest_patch == planned[0].patch {
true => Slot::Keep {
member: 0,
relink: false,
},
false => Slot::Update {
index: 0,
member: 0,
},
};
std::iter::once(bottom)
.chain((1..planned.len()).map(|index| Slot::Add { index }))
.collect()
}
None => (0..planned.len())
.map(|index| Slot::Add { index })
.collect(),
};
let old: Vec = adopted.into_iter().collect();
let pds = match old.is_empty() {
true => None,
false => Some(crate::clients::atproto::did::pds_or_fail(&selection.did).await?),
};
let adopted_note = old.first().map(|m| {
(
m.uri.clone(),
m.rounds + usize::from(chain[0].writes_a_round()),
)
});
let (ops, uris) = chain_ops(
&agent,
pds.as_deref(),
&selection.did,
&repo.did,
&target_branch,
&branch,
&chain,
&old,
&planned,
&mut gzipped,
// Nothing to hang from: `create` is the command for a branch that
// has no chain yet, and an adopted member is the bottom rather than
// something below it.
None,
)
.await?;
// The last thing before anything leaves the machine: read the chain
// these ops would produce, and refuse it if the reader could not.
// `super::refuse_new_damage` owns every rule about a well-formed chain,
// so a verb added later gets them without knowing they exist.
super::refuse_new_damage(&items, &selection.did, &ops, &super::closed_uris(&rows))?;
// **A lease, but only when there is something to lose.** A stack built
// from nothing writes only creates: no record can be clobbered, and an
// unleased batch is the right answer. Adopting changed that — the bottom
// member is an `Op::Update` against a record that already exists, and
// without a lease it lands whatever happened to that pull since it was
// read. Another session appending a round in between would be
// overwritten silently, which is the failure `resubmit` has always
// leased against and this inherited the need for the moment it learned
// to adopt.
let swap_commit = match pds.as_deref() {
// Nothing adopted, so nothing to overwrite.
None => None,
Some(pds) => Some(crate::clients::atproto::pds::latest_commit(pds, &selection.did).await?),
};
crate::clients::atproto::record::batch(
&agent,
"stack",
&selection.did,
ops,
swap_commit.as_deref(),
)
.await?;
for (member, uri) in report.members.iter_mut().zip(&uris) {
member.uri = uri.clone();
}
if args.json {
return crate::term::jsonout::emit(&report);
}
if let Some((uri, round)) = &adopted_note {
println!("adopted {uri} as the bottom (round {round})");
}
for (member, uri) in report.members.iter().zip(&uris) {
if let Some(uri) = uri
&& Some(uri.as_str()) != adopted_note.as_ref().map(|(u, _)| u.as_str())
{
let _ = member;
println!("created {uri}");
}
}
println!(
"view: {}",
crate::term::hyperlink::url(&format!("{}/pulls", repo.web_url))
);
Ok(())
}
/// The target's knot, once it has said it holds the target branch.
///
/// Asked before the branch is rewritten or pushed, because both answers are
/// available then: the knot comes out of the repo's DID document, and
/// `sh.tangled.repo.compare` for `target.. ` — an empty range, a
/// few hundred bytes — is refused with `RevisionNotFound` by a knot that
/// has no such branch, and not at all by one that is down. Either used to
/// be found out by the compare after the push.
async fn knot_holding_target(
repo: &resolve::RepoRef,
target_branch: &str,
base: &str,
) -> Result {
let knot = crate::clients::atproto::did::knot_from_did_doc(&repo.did)
.await
.ok_or_else(|| {
anyhow::anyhow!(
"no knot in {}'s DID document, so there is nothing to confirm the push with",
repo.did
)
})?;
let base_sha = git::git_in(
Path::new("."),
&["rev-parse", &format!("{base}^{{commit}}")],
)?;
crate::term::say::step!(Knot, "checking {target_branch} on {knot}...");
let comparison =
crate::clients::tangled::compare::compare(&knot, &repo.did, target_branch, base_sha.trim())
.await
.with_context(|| format!("{knot} could not confirm it holds {target_branch}"))?;
crate::logging::debug::log(format!(
"{knot} has {target_branch} at {}; {base} is {}",
comparison.rev1,
base_sha.trim(),
));
Ok(knot)
}
/// Push the branch, then have the target's knot say what it now holds for
/// each member.
///
/// The push is what makes `source: {branch}` a fact rather than a claim —
/// and, exiting 0, is git's own word that the remote ref is at the tip. The
/// compare is the proof the knot *serves* what landed: unlike `pr create`,
/// a stack cannot take its patch bytes from the answer — a member's patch is
/// its own commits' `format-patch` carrying injected `Change-Id:` headers —
/// so the answer is read for what it says rather than for what it contains.
///
/// One compare per member, from the commit below its bottom to its tip, and
/// never one for `target..branch`. The knot's answer carries every commit's
/// patch several times over — `patch`, `combined_patch_raw`, and a
/// `format_patch` entry each — so a whole range grows with the stack: a
/// 20-commit branch of 1.6 MB answered with 12.4 MB on 2026-09-16, past
/// [`crate::clients::http::MAX_BODY`], with the branch already rewritten and
/// pushed. A member's answer is one change's size whatever the stack around
/// it.
///
/// Refusals and notes:
///
/// * an empty comparison for any member stops the create, which is
/// `handleBranchBasedPull`'s own refusal ("No commits between target and
/// source") in its own words;
/// * a mailbox missing the member's change-ids means the *web* resubmit of
/// this stack will refuse, because `knotserver/git/diff.go` writes that
/// header only from a commit object's jj `change-id` and never from a
/// message trailer. Counted across the stack and said once.
async fn publish_branch(
remote: &str,
branch: &str,
knot: &str,
repo: &resolve::RepoRef,
planned: &[Planned],
expected: Option<&str>,
) -> Result {
// The top of the plan is the top of the branch, which is what the remote
// has to end up holding.
let head = planned
.last()
.and_then(|p| p.shas.last())
.map(String::as_str)
.unwrap_or_default();
// The one failure this is expected to hit is no push access, and this is
// the place to say that a stack has no `--patch-only` to fall back on.
// Naming `pr create --patch-only` is not a consolation prize: it is the
// only shape atgc can write for a knot it cannot push to, and it is a
// single pull by construction.
let pushed = crate::cmd::publish_branch(
remote,
branch,
head,
expected,
&format!(
"A stack cannot be published without the branch: every member records \n\
`source: {branch}`, and `stack view`, `stack resubmit` and `stack merge` \n\
all find the chain through it.\n\
`atgc pr create --patch-only` opens one patch-based pull instead."
),
)?;
crate::term::say::step!(
Knot,
"confirming {} member(s) of {branch} on {knot}...",
planned.len()
);
let mut binary_omitted = false;
let mut missing = 0;
for (i, member) in planned.iter().enumerate() {
let tip = member.shas.last().expect("a member is never empty");
let below = git::git_in(
Path::new("."),
&["rev-parse", &format!("{}^", member.sha())],
)?;
let below = below.trim();
let comparison =
crate::clients::tangled::compare::compare(knot, &repo.did, below, tip).await?;
if comparison.commits == 0 || comparison.patch.is_empty() {
bail!(
"{knot} finds no commits between {} and {} for member {}/{}\n\
The branch was pushed; the knot has nothing of {branch} above that commit.",
&below[..12.min(below.len())],
&tip[..12.min(tip.len())],
i + 1,
planned.len(),
);
}
binary_omitted |= comparison.binary_omitted;
missing += missing_from_knot(&comparison.patch, member);
crate::logging::debug::log(format!(
"compare {}/{} {}..{} merge-base {}: {} commit(s), {} bytes",
i + 1,
planned.len(),
comparison.rev1,
comparison.rev2,
comparison.merge_base,
comparison.commits,
comparison.patch.len()
));
}
if binary_omitted {
crate::term::say::warning!(
Knot,
"{knot} left binary payloads out of its patch; the stack's own patches are \
formatted here and are unaffected"
);
}
if missing > 0 {
crate::term::say::note!(
Knot,
"{knot} formats {missing} of these commits with no `Change-Id:` header, so \
resubmitting this stack from the web will refuse.\n\
The knot reads that header from a jj change-id in the commit object and \
not from a `Change-Id:` trailer. `atgc stack resubmit` is unaffected."
);
}
Ok(pushed)
}
/// How many of `member`'s change-ids the knot's mailbox for it does not
/// carry as a `Change-Id:` header.
///
/// Every id of the member, not one per member: a member is a run of commits
/// and the knot formats each of them, so counting members would cap the
/// answer at one missing id per pull request and report "1 of these
/// commits" for a member that lost three.
///
/// Matched against the member's whole mailbox rather than per patch: this
/// is a yes/no about whether the knot can see these ids at all, and
/// splitting the mailbox to say which commit lost one would be a second
/// patch parser for an answer that is the same for every commit on a branch.
fn missing_from_knot(mailbox: &str, member: &Planned) -> usize {
member
.change_ids
.iter()
.filter(|id| !mailbox.contains(&format!("Change-Id: {id}")))
.count()
}
/// Refuse, rewrite, or (on a dry run) defer: every commit of `base..HEAD`
/// must carry a change-id before a stack write goes anywhere. Returns
/// whether a rewrite is still pending — true only on a dry run that would
/// have rewritten.
///
/// `rewritten` is an out-parameter rather than part of the return, because
/// what the callers need from it is not a result they branch on but a fact
/// that outlives the call: [`note_rewrite`] reads it after the command has
/// failed, from a frame this function has long since left.
fn ensure_change_ids(
base: &str,
commits: &[String],
add_change_ids: bool,
dry_run: bool,
rewritten: &mut Option,
) -> Result {
let mut missing: Vec = Vec::new();
for sha in commits {
if gitpatch::change_id(sha)?.is_none() {
missing.push(sha.clone());
}
}
if missing.is_empty() {
return Ok(false);
}
if !add_change_ids {
let mut rows = Vec::with_capacity(missing.len());
for sha in &missing {
rows.push((&sha[..7.min(sha.len())], subject_of(sha)?));
}
let lines = two_column_listing(rows);
// The commits already made are this run's problem and only a rewrite
// fixes them — but the *next* ones are fixable for free, and this is
// the moment it is obvious the hook is missing. Installed quietly
// and reported only when something was written, because a refusal
// with two remedies in it reads as one remedy nobody can find.
let hook = crate::clients::git::hooks::install_commit_msg(Path::new("."));
let hooked = match &hook {
Ok(installed) => installed.describe().map(|line| format!("\n{line}")),
Err(e) => {
crate::logging::debug::dump_err("commit-msg hook install failed", e);
None
}
};
bail!(
"{} of {} commit(s) carry no change-id:\n{lines}\
a change-id is what matches each pull to its commit across rewrites\n\
rerun with --add-change-ids to rewrite {base}..HEAD with Change-Id trailers \
(shas change; `git reflog` records the old tip), or let jj >= 0.29 write \
them via git.write-change-id-header{}",
missing.len(),
commits.len(),
hooked.unwrap_or_default(),
);
}
if !gitpatch::working_tree_clean()? {
bail!(
"the working tree has uncommitted changes\n\
commit or stash them first; --add-change-ids rewrites the branch"
);
}
if dry_run {
// A dry run must not rewrite, so the ids printed for these commits
// are the ones the rewrite would mint.
return Ok(true);
}
*rewritten = gitpatch::rewrite_with_change_ids(base)?;
if let Some(rewrite) = rewritten {
// A step this command took on the checkout rather than a fact about
// the stack, so it goes where the other steps go. The old tip is
// spelled out here as well as in every failure below: a person
// reading a successful run should not have to go to the reflog to
// learn what the sha in their scrollback used to be.
crate::term::say::step!(
Git,
"rewrote the branch: {} commit(s) gained a Change-Id trailer (it was at {}, \
which `git reflog {}` also records)",
rewrite.added,
rewrite.was,
rewrite.branch,
);
}
Ok(false)
}
/// Upload one planned commit's patch and mint its create op — the one
/// definition of "a new stacked pull". `stack create` and the reconcile's
/// Add slot had each grown a copy, and the blobs/body/source fields were
/// one lexicon change away from disagreeing.
#[allow(clippy::too_many_arguments)]
async fn create_pull_op(
agent: &jacquard::client::Agent,
ticker: &mut Ticker,
me: &str,
repo_did: &str,
target_branch: &str,
branch: &str,
parent: &Option,
p: &Planned,
gzipped: Vec,
) -> Result<(Op, String)> {
let gzip_len = gzipped.len();
let blob = upload_patch_blob(
agent,
gzipped,
format!(
"uploading patch blob for {} ({gzip_len} bytes gzip)",
p.short()
),
Some(p.short()),
)
.await?;
// Minted here, monotonic, so each record can name its parent before
// anything is sent.
let rkey = ticker.next(None).to_string();
let uri = format!("at://{me}/{PULL_NSID}/{rkey}");
let image_blobs = upload_member_images(agent, p).await?;
let pull = Pull {
title: p.subject.clone().into(),
// Already in published form: predictions were rewritten in at
// planning time.
body: p.body.clone().map(Into::into),
target: crate::lexicon::tangled::pull_target(repo_did, target_branch)?,
source: Some(Source {
branch: branch.to_string().into(),
repo: None,
extra_data: None,
}),
dependent_on: parent.clone().map(|s| AtUri::new(s.into())).transpose()?,
rounds: vec![Round {
patch_blob: blob.into(),
created_at: Datetime::now(),
extra_data: None,
}],
blobs: image_blobs.map(|bs| bs.into_iter().map(Into::into).collect()),
created_at: Datetime::now(),
mentions: None,
references: None,
extra_data: None,
};
pull.validate()
.map_err(|e| anyhow::anyhow!("{e}"))
.context(format!(
"pull record for {} would be refused by the lexicon",
p.short()
))?;
let op = Op::Create {
nsid: PULL_NSID,
rkey: Key::any_owned(&rkey).map_err(|e| anyhow::anyhow!("bad TID {rkey}: {e}"))?,
value: serde_json::to_value(&pull)?,
};
Ok((op, uri))
}
/// Validate an edited member and turn it into the op that writes it back.
///
/// Every write path that opens a member's record ends this same way, and
/// each one used to end it in its own hand-written copy — check the lexicon,
/// spell the record key, serialize. Four copies of the last step before a
/// record leaves the machine is four chances for one of them to skip the
/// check.
fn update_member_op(rkey: &str, pull: &Pull) -> Result {
pull.validate()
.map_err(|e| anyhow::anyhow!("{e}"))
.context(format!(
"pull record for {rkey} would be refused by the lexicon"
))?;
Ok(Op::Update {
nsid: PULL_NSID,
rkey: Key::any_owned(rkey).map_err(|e| anyhow::anyhow!("bad record key {rkey}: {e}"))?,
value: serde_json::to_value(pull)?,
})
}
/// Point an existing member at a new parent, touching nothing else.
///
/// Its rounds, title, body and blobs are all left exactly as they are: the
/// chain link is the only field that has stopped being true.
///
/// This existed four times — here, in `resubmit`'s retire loop, in `unlink`
/// and in `link` — because it is one line of intent wrapped in six lines of
/// ceremony, and rewriting the ceremony reads as cheaper than finding it.
/// The copies took a `None` parent, an `Option` and a
/// `&Option` respectively, which is why none of them looked like the
/// others.
async fn relink_op(pds: &str, me: &str, rkey: &str, parent: Option<&str>) -> Result {
let (mut pull, _cid) = fetch_member(pds, me, rkey).await?;
pull.dependent_on = parent
.map(|s| AtUri::new(s.to_string().into()))
.transpose()?;
update_member_op(rkey, &pull)
}
/// Append a round to an existing member, and relink it while the record is
/// open anyway.
///
/// Shared by `stack resubmit`, which does this to a member of a chain whose
/// commits moved, and by `stack create`, which does it to the one unstacked
/// pull it adopts as a new stack's bottom — a pull whose patch came from
/// `pr create` and so spans the whole branch, where the member's patch is
/// only its own cut. Two copies of a record mutation this shaped is two
/// chances to relink one and not the other, and the round is the half that
/// cannot be undone.
async fn append_round_op(
agent: &jacquard::client::Agent,
pds: &str,
me: &str,
m: &OldMember,
p: &Planned,
parent: &Option,
) -> Result {
let gzipped = gzip(&p.patch)?;
let gzip_len = gzipped.len();
let blob = upload_patch_blob(
agent,
gzipped,
format!(
"uploading round blob for {} ({gzip_len} bytes gzip)",
m.rkey
),
Some(p.short()),
)
.await?;
let (mut pull, _cid) = fetch_member(pds, me, &m.rkey).await?;
pull.rounds.push(Round {
patch_blob: blob.into(),
created_at: Datetime::now(),
extra_data: None,
});
pull.dependent_on = parent.clone().map(|s| AtUri::new(s.into())).transpose()?;
// Titles follow the commit, as Tangled's resubmit has them. The body
// does too, but only while it is still the one the last round generated:
// see `body_is_its_commits`, which is what keeps a hand-written
// description from being reverted to `%b` by every round. It was
// rewritten at planning time, so what goes on the wire here is final
// either way.
pull.title = p.subject.clone().into();
if m.body_is_its_commits() {
pull.body = p.body.clone().map(Into::into);
}
if let Some(new_blobs) = upload_member_images(agent, p).await? {
let existing: Option> = pull
.blobs
.take()
.map(|bs| bs.into_iter().map(Into::into).collect());
pull.blobs = crate::cmd::images::merge_blobs(existing, new_blobs)
.map(|bs| bs.into_iter().map(Into::into).collect());
}
update_member_op(&m.rkey, &pull)
}
/// **Walk a reconcile's chain and build the ops that write it.**
///
/// One walk, two callers. `stack create` and `stack resubmit` differ in what
/// they start from — create's `old` is the branch's one unstacked pull, or
/// nothing at all, and resubmit's is the chain already recorded — and in
/// nothing else: a member is kept, relinked, given a round or minted, and
/// each one becomes the parent of the next. Two copies of that loop is two
/// chances to thread `dependentOn` correctly in one and not the other, which
/// is the single field this whole epic has been about.
///
/// `gzipped` is taken by index rather than consumed, because the slots do not
/// visit the planned members in order and a `Keep` visits none.
///
/// Returns the ops in the order they must be applied, and the at-uri each
/// planned member ended up at — minted for an `Add`, the existing record's
/// for everything else. Read off the walk rather than back off the batch,
/// since an updated record's uri is one the batch never answers with.
#[allow(clippy::too_many_arguments)]
async fn chain_ops(
agent: &jacquard::client::Agent,
pds: Option<&str>,
me: &str,
repo_did: &str,
target_branch: &str,
branch: &str,
chain: &[Slot],
old: &[OldMember],
planned: &[Planned],
gzipped: &mut [Vec],
anchor: Option,
) -> Result<(Vec, Vec>)> {
let mut ticker = Ticker::new();
let mut ops: Vec = Vec::new();
let mut uris: Vec> = vec![None; planned.len()];
// Where the bottom member hangs from. `None` for a stack that starts at
// the target branch; a merged member's at-uri after a bottom-merge and a
// rebase, which is how Tangled's own resubmit leaves the chain connected
// to its merged history. Seeding this wrongly is invisible until
// somebody looks at the stack a merge later, so it is a parameter rather
// than a default.
let mut parent: Option = anchor;
for slot in chain {
match slot {
Slot::Add { index } => {
let (op, uri) = create_pull_op(
agent,
&mut ticker,
me,
repo_did,
target_branch,
branch,
&parent,
&planned[*index],
std::mem::take(&mut gzipped[*index]),
)
.await?;
ops.push(op);
uris[*index] = Some(uri.clone());
parent = Some(uri);
}
Slot::Update { index, member } => {
let m = &old[*member];
let pds = pds.expect("a member to update means a chain was read");
ops.push(append_round_op(agent, pds, me, m, &planned[*index], &parent).await?);
uris[*index] = Some(m.uri.clone());
parent = Some(m.uri.clone());
}
Slot::Keep { member, relink } => {
let m = &old[*member];
if *relink {
let pds = pds.expect("a member to relink means a chain was read");
ops.push(relink_op(pds, me, &m.rkey, parent.as_deref()).await?);
}
parent = Some(m.uri.clone());
}
// Merged, and never touched: it holds its place and the members
// above it hang off it.
Slot::Frozen { member } => {
parent = Some(old[*member].uri.clone());
}
}
}
Ok((ops, uris))
}
/// Render an indented two-column listing, one row per line, for embedding
/// above a refusal's explanation in a `bail!`. Named apart from
/// `read as listing` above, which is a module and lives in a different
/// namespace but would still confuse a reader hunting for what `listing`
/// means here. Every place that refuses a whole *set* of commits or pulls —
/// rather than one, which fits in a sentence — builds one of these first so
/// the reader can see which ones without cross-referencing shas by hand.
fn two_column_listing(
rows: impl IntoIterator- ,
) -> String {
let mut out = String::new();
for (left, right) in rows {
out.push_str(&format!(" {left} {right}\n"));
}
out
}
/// How wide a change-id column is: nine characters, the `I` and eight of
/// the forty after it. Enough to recognize one member from another in a
/// list, and never enough to retype.
const CHANGE_ID_SHOWN: usize = 9;
/// A change-id shortened for a column, with an ellipsis when it was cut.
///
/// The ellipsis is the whole function. Without it the column is nine
/// characters of a forty-one character value with nothing saying so, which
/// reads as the value itself — and a change-id that is retyped short matches
/// no pull at all. See the call site for what that cost once.
fn ellipsize_change_id(id: &str) -> String {
match id.char_indices().nth(CHANGE_ID_SHOWN) {
Some((at, _)) => format!("{}…", &id[..at]),
None => id.to_string(),
}
}
/// The branch that ends a member, in the shape a plan line wants it: `""` when
/// nothing marks it, so the caller can interpolate it unconditionally.
fn label_of(cut: &Cut, index: usize) -> String {
match cut.labels.get(index).and_then(Option::as_deref) {
Some(name) => format!(" [{name}]"),
None => String::new(),
}
}
/// How a plan line names the commits behind a member: the sha for the
/// ordinary one, and the run's ends plus a count when it carries several,
/// since one sha alone reads as a member of one commit.
fn describe_commits(p: &Planned) -> String {
match p.commits() {
1 => format!("commit {}", p.short()),
n => format!(
"{n} commits, {}..{}",
p.short(),
&p.shas[n - 1][..7.min(p.shas[n - 1].len())],
),
}
}
/// The subjects of a member's commits, in order — one `git log` per commit,
/// asked only when a member carries more than one and the plan is about to
/// list them.
fn subjects_of(shas: &[String]) -> Result
> {
shas.iter().map(|sha| subject_of(sha)).collect()
}
fn subject_of(sha: &str) -> Result {
git::git_in(Path::new("."), &["log", "-1", "--format=%s", sha])
}
/// A commit's subject and body, the pull's title and body. `%B` would hand
/// back the two joined; asking separately spares re-splitting them on the
/// blank line the format already knows about.
fn subject_and_body(sha: &str) -> Result<(String, Option)> {
let subject = subject_of(sha)?;
let body = git::git_in(Path::new("."), &["log", "-1", "--format=%b", sha])?;
let body = strip_change_id_trailers(&body);
Ok((subject, (!body.is_empty()).then_some(body)))
}
/// The commit body minus its `Change-Id:` trailer, which is stack
/// bookkeeping the patch carries in its header — not prose for the pull's
/// description. The first live stack proved the point: a commit with no
/// body beyond its added trailer produced a pull whose entire description
/// was `Change-Id: I…`.
///
/// Only the final paragraph is treated as a trailer block, and only the
/// Change-Id lines leave it — a `Signed-off-by:` stays, and a Change-Id
/// quoted mid-body is prose.
fn strip_change_id_trailers(body: &str) -> String {
// CRLF first: a body committed verbatim from Windows tooling has
// `\r\n\r\n` between paragraphs, which the `\n\n` split below cannot
// see — the whole body then read as one trailer block and mid-body
// prose mentioning Change-Id was deleted from the pull description.
let body = body.replace("\r\n", "\n");
let trimmed = body.trim();
let (head, tail) = match trimmed.rsplit_once("\n\n") {
Some((head, tail)) => (Some(head), tail),
None => (None, trimmed),
};
let kept: Vec<&str> = tail
.lines()
.filter(|line| !line.trim_start().starts_with("Change-Id:"))
.collect();
let tail = kept.join("\n");
match (head, tail.trim().is_empty()) {
(Some(head), true) => head.trim().to_string(),
(Some(head), false) => format!("{}\n\n{}", head.trim(), tail.trim()),
(None, true) => String::new(),
(None, false) => tail.trim().to_string(),
}
}
/// Inject the `Change-Id:` mail header the appview correlates rounds by.
///
/// The header block of a format-patch ends at the first blank line;
/// anything after that is the commit message and the diff, where a
/// `Change-Id:` line would just be text. A patch that already carries the
/// header — none of atgc's own do, but a re-run should not double it — is
/// returned untouched.
fn with_change_id_header(patch: &str, change_id: &str) -> Result {
let Some(split) = patch.find("\n\n") else {
bail!("malformed patch: no header block to add Change-Id to");
};
let headers = &patch[..split];
if headers.lines().any(|l| l.starts_with("Change-Id:")) {
return Ok(patch.to_string());
}
Ok(format!(
"{headers}\nChange-Id: {change_id}{}",
&patch[split..]
))
}
/// Inject one `Change-Id:` header per message of a member's mailbox, in
/// order: message *i* gets `change_ids[i]`.
///
/// A one-commit member is the one-message case of this and comes out byte
/// for byte what [`with_change_id_header`] alone produced, which is what
/// keeps every stack written before members could hold several commits
/// comparing equal on its next reconcile.
fn with_change_id_headers(patch: &str, change_ids: &[String]) -> Result {
let offsets = gitpatch::message_offsets(patch);
if offsets.len() != change_ids.len() {
bail!(
"the patch for this member holds {} message(s) but {} commit(s) went into it; \
refusing to guess which change-id belongs to which",
offsets.len(),
change_ids.len(),
);
}
let mut out = String::with_capacity(patch.len() + change_ids.len() * 56);
for (i, &start) in offsets.iter().enumerate() {
let end = offsets.get(i + 1).copied().unwrap_or(patch.len());
out.push_str(&with_change_id_header(&patch[start..end], &change_ids[i])?);
}
Ok(out)
}
/// How the commits of `base..HEAD` are cut into members.
///
/// A group is a *contiguous run* of commit indexes, bottom first, and the
/// groups tile the range in order. One commit per group — the whole range as
/// `[[0], [1], [2]]` — is the default and what every stack written before
/// this existed looks like.
type Groups = Vec>;
/// Every commit its own member: the cut for an unmarked branch, and the one `--per-commit`
/// exists to override.
fn one_group_per_commit(total: usize) -> Groups {
(0..total).map(|i| vec![i]).collect()
}
/// How many commits an unmarked branch may stack one-per-commit before
/// `create` stops to ask.
///
/// Three is a judgement call, not a measurement: two or three single-commit
/// pulls is an ordinary jj-shaped stack, and past that the cost of guessing
/// wrong — a pile of records to close by hand — outweighs the cost of
/// asking.
const UNMARKED_LIMIT: usize = 3;
/// Whether this checkout still wants the unmarked-branch question.
///
/// Named for what it does rather than for what it looks like it does. The
/// first spelling was `stack.perCommit`, which reads as "always cut one pull
/// per commit" — and it never meant that: marks still decide the cut, and
/// `--per-commit` is the flag that ignores them. A config whose name
/// promises more than it delivers is the same class of mistake as a stack
/// that silently re-cuts itself.
fn asks_about_unmarked() -> bool {
!crate::clients::git::config::local_config(Path::new("."), "stack.askWhenUnmarked")
.is_some_and(|v| matches!(v.trim(), "false" | "0" | "no" | "off"))
}
/// A cut branch: the groups, and the branch name that ended each one.
///
/// The names are not written to any record — a member's source branch is the
/// branch being stacked, as it has always been — but they are what the plan
/// prints, and the whole reason this is legible: "part1, part2, feature"
/// says where the cuts are in a way that "2,1,3" never did.
struct Cut {
groups: Groups,
labels: Vec>,
}
impl Cut {
/// One commit per member: the cut for a branch nothing points into.
fn per_commit(total: usize) -> Self {
Cut {
groups: one_group_per_commit(total),
labels: vec![None; total],
}
}
}
/// Cut the range at its recorded marks.
///
/// A mark *ends* a member, so `part1` on the second commit of five makes the
/// bottom two one pull request, and the commits above it belong to whatever
/// mark ends next — the top always being the branch being stacked, whose own
/// tip is HEAD.
///
/// No marks means no cuts, which is one member per commit: what every stack
/// looked like before marks existed, and what an unmarked branch still gets.
/// See [`crate::cmd::stack::marks`] for why only recorded branches count.
fn cut_at_marks(total: usize, marks: &[(usize, String)]) -> Cut {
if marks.is_empty() {
return Cut::per_commit(total);
}
let mut groups: Groups = Vec::new();
let mut labels: Vec > = Vec::new();
let mut current: Vec = Vec::new();
for i in 0..total {
current.push(i);
let ends_here = marks.iter().find(|(at, _)| *at == i);
let last = i + 1 == total;
if let Some((_, name)) = ends_here {
groups.push(std::mem::take(&mut current));
labels.push(Some(name.clone()));
} else if last {
groups.push(std::mem::take(&mut current));
// The top member ends at HEAD, which is the branch being
// stacked: named by the caller's own listing, not here.
labels.push(None);
}
}
Cut { groups, labels }
}
// ---------------------------------------------------------------------------
// `stack resubmit`
// ---------------------------------------------------------------------------
/// One member of `stack resubmit --json`, in the fate the reconcile
/// assigned it.
#[derive(serde::Serialize, Debug, PartialEq)]
pub(in crate::cmd) struct ReconciledMemberJson {
/// 1-based from the bottom, as everywhere else a stack is numbered.
pub position: usize,
/// What happens to this member: `update` (a round is appended),
/// `relink` (only its place in the chain moved), `keep` (nothing at
/// all), `merged` (frozen; never touched) or `add` (a new pull for a
/// new commit). Five words, closed — the text view spells each out in
/// a sentence, and a caller should not have to parse one.
pub action: &'static str,
pub title: String,
/// The existing record's key. `null` for an `add`, whose key the PDS
/// has not minted yet.
pub rkey: Option,
/// The commit this member will carry — its bottom one, when it carries
/// several. `null` for a member no commit touches — `keep`, `relink`
/// and `merged`.
pub sha: Option,
/// Every commit the member will carry, bottom first; empty wherever
/// `sha` is null.
#[serde(default)]
pub shas: Vec,
/// Rounds the record will hold once this run is done: one more than it
/// had for an `update`, unchanged otherwise, and 1 for an `add`.
pub rounds: usize,
/// True on an `update` whose stored description has been written over
/// since its last round — by `pr edit` or Tangled's edit box — and so
/// is left exactly as it is instead of being regenerated from the
/// commit message. Always false for every other fate: nothing else
/// touches a body at all.
pub body_kept: bool,
pub images: Vec,
}
/// A member whose commit left the branch, as `--prune` deletes it.
#[derive(serde::Serialize, Debug, PartialEq)]
pub(in crate::cmd) struct DroppedJson {
pub rkey: String,
pub uri: String,
pub title: String,
}
/// What `stack resubmit` did, or would have done.
#[derive(serde::Serialize, Debug, PartialEq)]
pub(in crate::cmd) struct StackResubmittedJson {
pub dry_run: bool,
/// Whether the branch was pushed to `remote` before the records were
/// written. `false` covers both halves of "did not need to": a dry run,
/// and a remote already holding this exact head — the ordinary case for
/// anyone who pushed by hand first.
pub pushed: bool,
/// `false` when the stack already matched the branch — the no-op that
/// makes a rerun safe. Nothing is written and the command exits 0, so
/// this is the field that says which happened.
pub changed: bool,
pub repo_did: String,
pub target_branch: String,
pub source_branch: String,
pub total: usize,
/// Top first, matching the order the text view lists the plan in.
pub members: Vec,
/// Only ever non-empty with `--prune`; without it a vanished *open*
/// member is an error rather than a deletion.
pub drops: Vec,
/// Closed members the branch no longer carries: kept, and unlinked from
/// the chain. No flag authorizes these, because the record survives —
/// the only thing written is the removal of its `dependentOn`, without
/// which the member above it and this one would both hang off the same
/// parent. They are the reason `total` can fall with nothing deleted.
pub retired: Vec,
/// A merged member the new bottom stays chained to, keeping the stack
/// connected to its landed history. `null` when there is none.
pub anchor: Option,
/// Every record the atomic batch wrote, in the order it wrote them.
/// Empty on a dry run and on a no-op.
pub wrote: Vec,
pub url: String,
}
/// One reconcile slot, as `--json` reports it.
///
/// Pure, and split out for the same reason the row builders in
/// [`crate::cmd::pr::read`] are: the five fates are the whole of what a reconcile
/// decides, and a test can hold all five still with no network in sight.
fn reconciled_member_json(
position: usize,
slot: &Slot,
old: &[OldMember],
planned: &[Planned],
) -> ReconciledMemberJson {
let images = |index: usize| {
planned[index]
.images
.as_ref()
.map(|i| i.listed())
.unwrap_or_default()
};
match slot {
Slot::Update { index, member } => ReconciledMemberJson {
position,
action: "update",
title: old[*member].title.clone(),
rkey: Some(old[*member].rkey.clone()),
sha: Some(planned[*index].sha().to_string()),
shas: planned[*index].shas.clone(),
rounds: old[*member].rounds + 1,
body_kept: !old[*member].body_is_its_commits(),
images: images(*index),
},
Slot::Keep { member, relink } => ReconciledMemberJson {
position,
action: if *relink { "relink" } else { "keep" },
title: old[*member].title.clone(),
rkey: Some(old[*member].rkey.clone()),
sha: None,
shas: Vec::new(),
rounds: old[*member].rounds,
body_kept: false,
images: Vec::new(),
},
Slot::Frozen { member } => ReconciledMemberJson {
position,
action: "merged",
title: old[*member].title.clone(),
rkey: Some(old[*member].rkey.clone()),
sha: None,
shas: Vec::new(),
rounds: old[*member].rounds,
body_kept: false,
images: Vec::new(),
},
Slot::Add { index } => ReconciledMemberJson {
position,
action: "add",
title: planned[*index].subject.clone(),
// The PDS mints the key for a record that does not exist yet.
rkey: None,
sha: Some(planned[*index].sha().to_string()),
shas: planned[*index].shas.clone(),
rounds: 1,
body_kept: false,
images: images(*index),
},
}
}
#[derive(clap::Args, Debug)]
pub(crate) struct ResubmitArgs {
/// Git remote pointing at the repo
#[arg(long, default_value = "origin")]
pub remote: String,
/// Rewrite base..HEAD to add Change-Id trailers to commits lacking one
#[arg(long)]
pub add_change_ids: bool,
/// Delete the records of pulls whose commits left the branch
#[arg(long)]
pub prune: bool,
/// Ignore the branches pointing into the range and reconcile the stack
/// as one pull request per commit
#[arg(long)]
pub per_commit: bool,
/// Describe the reconcile without writing anything
#[arg(long)]
pub dry_run: bool,
/// Print one JSON object describing what was written (or, with
/// --dry-run, what would be) instead of the summary lines
#[arg(long)]
pub json: bool,
}
/// One member of the existing stack, read back and readied for the
/// reconcile: everything the planner compares lives here, so the planner
/// itself needs no network.
struct OldMember {
uri: String,
rkey: String,
title: String,
/// State label as the listings print it: open, closed, merged, or `?`.
state: String,
/// The description the record holds now. Paired with `latest_patch`,
/// this is what [`OldMember::body_is_its_commits`] compares: what is
/// stored against what that patch's own message would generate.
body: Option,
/// Every change-id in the latest round's patch, one per commit the
/// member carries, bottom first — the record itself has no change-id
/// field anywhere; the patch headers are the identity. Empty for a
/// round that carries none, which is its own refusal.
change_ids: Vec,
latest_patch: String,
dependent_on: Option,
/// How many rounds the record already carries, for saying which round
/// an update would append.
rounds: usize,
}
impl OldMember {
/// Whether this member claims `id` — any of its commits, not only the
/// bottom one, so a commit rewritten in the middle of a member still
/// finds its way home.
fn owns(&self, id: &str) -> bool {
self.change_ids.iter().any(|mine| mine == id)
}
/// Whether the description this record holds is still the one its
/// latest round's commit message produced — that is, whether anything
/// has been written over it since.
///
/// This is what stands between a round and somebody's typing. A stacked
/// pull's body comes from its commit message, so a resubmit that
/// regenerated it unconditionally was right for the bodies nobody had
/// touched and silently destructive for every other: `atgc pr edit` and
/// Tangled's own edit box both write descriptions — screenshots,
/// context, the whole reason a reviewer reads a pull — that no commit
/// message has, and each round reverted the lot to `%b`. Thirteen pulls
/// across two stacks were rewritten by hand after that happened.
///
/// Comparing against the *stored* patch rather than the new commit is
/// what makes both halves work: an untouched body still follows an
/// amended message, because the round it was generated from is the one
/// being replaced. Anything this cannot parse compares unequal and so
/// keeps what is stored, which is the safe direction — the commit
/// message is recoverable from git and an edited body is not.
fn body_is_its_commits(&self) -> bool {
comparable_body(self.body.as_deref()) == body_of_patch(&self.latest_patch)
}
}
/// A body as it is compared: line endings normalized, blank counted as
/// absent. `stack create` writes no body at all for a commit with none, but
/// `pr edit --body ''` clears one to a field that may be present and empty.
fn comparable_body(body: Option<&str>) -> Option {
let text = body?.replace("\r\n", "\n");
let text = text.trim().to_string();
(!text.is_empty()).then_some(text)
}
/// The pull body a patch's own commit message produces — [`subject_and_body`]'s
/// second half, read out of the patch text instead of out of git.
///
/// A format-patch is a mail message: headers, a blank line, the commit
/// message body, then the `---` scissors and the diff. So `%b` is what sits
/// between the two, and the `Change-Id:` trailer comes off it here for the
/// same reason it comes off there. `None` when there is no body, and also
/// when the text is not a patch at all — the caller reads both as "not
/// generated from this".
fn body_of_patch(patch: &str) -> Option {
let text = patch.replace("\r\n", "\n");
let message = text.split_once("\n---\n").map_or(text.as_str(), |(m, _)| m);
let body = strip_change_id_trailers(message.split_once("\n\n")?.1);
(!body.is_empty()).then_some(body)
}
/// What the reconcile decided for one position of the new chain, bottom
/// first. `Drop`s live outside the chain — they have no position left.
#[derive(Debug, PartialEq, Clone)]
enum Slot {
/// change-id matched and the patch changed: append a round (and set
/// `dependentOn` to the new parent while the record is open anyway).
Update { index: usize, member: usize },
/// Matched, patch byte-identical. `relink` is whether the chain order
/// still moved under it — an update that touches `dependentOn` only.
Keep { member: usize, relink: bool },
/// A merged member whose commit is still on the branch: never touched,
/// but it holds its place and its successors hang off it.
Frozen { member: usize },
/// A commit no record answers to yet.
Add { index: usize },
}
impl Slot {
/// Whether writing this slot appends a round to an existing record.
/// `create` says which round number its adopted member is about to be
/// on, and that is the only thing the answer is used for.
fn writes_a_round(&self) -> bool {
matches!(self, Slot::Update { .. })
}
}
/// The whole reconcile, decided before anything is written.
#[derive(Debug)]
struct Plan {
/// Bottom-up; parallel to the new commit order except that `Frozen`
/// members occupy the positions their commits still hold.
chain: Vec,
/// Open members whose change-id vanished from the branch. Deleting is
/// `--prune`'s to authorize; without it, their presence is an error
/// upstream of here.
drops: Vec,
/// Closed members whose change-id vanished from the branch: kept, and
/// unlinked from the new chain. Everything that makes the record worth
/// keeping stays — its rounds, its description, the comments explaining
/// the close — and its `dependentOn` goes, because the member above it
/// has just been relinked past it and two pulls may not share a parent.
/// Reported as well as acted on, or a stack would quietly shrink between
/// two reconciles.
retired: Vec,
/// Merged members whose commits left the branch — the normal remainder
/// after a bottom-merge and a rebase. The topmost is the anchor the new
/// bottom's `dependentOn` points at, keeping the chain connected to its
/// merged history the way Tangled's own resubmit leaves it.
anchor: Option,
}
/// Decide the reconcile: match old members to new commits by change-id.
///
/// This is Tangled's own stacked-resubmit algorithm with atgc's refusals
/// added: matched means the record survives (a changed patch appends a
/// round; identical bytes append nothing, which is what makes a rerun of
/// `stack resubmit` a no-op instead of a round per pull); new change-ids
/// become new records; vanished ones delete their records — merged members
/// excepted, which are never touched, and members whose state cannot be
/// pinned, which are refused rather than guessed about.
fn reconcile(old: &[OldMember], new: &[Planned], prune: bool) -> Result {
use std::collections::HashMap;
for member in old {
if member.change_ids.is_empty() {
bail!(
"{} ({}) has no Change-Id header in its latest round, so no commit \
can be matched to it\n\
this stack predates change-id correlation; reconcile it through \
Tangled's web resubmit",
member.title,
member.rkey,
);
}
}
// One identity per record and per commit, or matching means nothing:
// a HashMap would silently keep whichever claimant came last, and the
// wrong pull would quietly collect the other's rounds. Every id a member
// carries is registered, not just its bottom one, so a member of several
// commits is found by any of them.
let mut by_id: HashMap<&str, usize> = HashMap::new();
for (i, m) in old.iter().enumerate() {
for id in &m.change_ids {
if let Some(&prev) = by_id.get(id.as_str())
&& prev != i
{
bail!(
"{} ({}) and {} ({}) both answer to change-id {id}\n\
delete or re-round one of them (Tangled's web resubmit can), then rerun",
old[prev].title,
old[prev].rkey,
m.title,
m.rkey,
);
}
by_id.insert(id.as_str(), i);
}
}
if let Some((id, shas)) = duplicate_change_id(new) {
bail!(
"commits {} all carry the change-id {id}\n\
a cherry-pick copying the trailer is the usual cause; amend all but one \
with a fresh Change-Id, then rerun",
shas.join(" and "),
);
}
// Which record each planned member inherits, decided once, bottom-up.
//
// The bottom commit's id is the member's identity, so it is tried first;
// a member whose bottom commit is brand new still recognizes itself by
// any commit it kept. Each record can be claimed once — splitting a
// two-commit pull in half leaves the lower half holding the record and
// the upper half a new pull, rather than both fighting over it.
let mut claimed: std::collections::HashSet = std::collections::HashSet::new();
let mut assigned: Vec> = Vec::with_capacity(new.len());
for planned in new {
let mut pick = by_id
.get(planned.change_id())
.copied()
.filter(|m| !claimed.contains(m));
if pick.is_none() {
pick = planned
.change_ids
.iter()
.find_map(|id| by_id.get(id.as_str()).copied())
.filter(|m| !claimed.contains(m));
}
if let Some(m) = pick {
claimed.insert(m);
}
assigned.push(pick);
}
let matched = &claimed;
// Old members the branch no longer carries.
let mut drops = Vec::new();
let mut retired = Vec::new();
let mut anchor = None;
for (i, member) in old.iter().enumerate() {
if matched.contains(&i) {
continue;
}
match member.state.as_str() {
// The normal remainder of a merge: its commits left the branch
// when the branch was rebased onto the merge. The last one in
// chain order is the topmost, and the anchor.
"merged" => anchor = Some(i),
// Closing a pull is an explicit act, and one people explain: the
// close usually comes with a comment saying why the work moved
// or was abandoned. Its commits leaving the branch is then the
// expected next step, not a discrepancy — so demanding --prune
// here offered deletion as the only way forward, and deleting
// the record takes the explanation with it. The record is kept
// and the new chain routes past it: the same non-destructive
// treatment `merged` gets, minus the anchoring, which a pull
// that never landed has not earned. What the record does lose is
// its `dependentOn` — see the unlinking loop in the batch.
"closed" => retired.push(i),
// `Conflict` is the status whose next move is "re-read and try
// again", which is exactly what the message asks for: nothing is
// wrong with the command line, and the same command works once
// the index catches up.
"?" => {
return Err(crate::exit::fail(
crate::exit::Exit::Conflict,
format!(
"{} ({}) is gone from the branch, and its state cannot be \
settled: a merge would be invisible while the index lags\n\
retry when `atgc stack view` shows a state, or reconcile \
through the web",
member.title, member.rkey,
),
));
}
// `"open"` is the only state a vanished member may be dropped
// for, and it is named rather than left as the fallthrough.
"open" => drops.push(i),
// **A state this build has not heard of is not an open one.**
// `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 here intact,
// and `PullState::from_token` documents that as expected rather
// than exceptional. It used to fall into the drop bucket, which
// `--prune` deletes: a member whose commits had left the branch
// and whose new terminal state this build could not read would
// have had its record removed.
//
// The same answer `"?"` gets, for the same reason and with the
// same status: nothing here is wrong with the command line, and
// a build that knows the state will do the right thing.
// `select_merge_range` already refuses an unknown state and
// `refuse_orphaned_by_rewrite` already counts one as live; this
// was the only site that treated it as disposable.
other => {
return Err(crate::exit::fail(
crate::exit::Exit::Conflict,
format!(
"{} ({}) is gone from the branch and reads as `{other}`, which this \
build does not know\n\
it will not be dropped on a guess: a state added since this build \
may well be one that means landed\n\
upgrade atgc, or reconcile through the web",
member.title, member.rkey,
),
));
}
}
}
if !drops.is_empty() && !prune {
let named: Vec<&OldMember> = drops.iter().map(|&i| &old[i]).collect();
return Err(drop_refusal(&named));
}
// The new chain, bottom first. The parent of position 0 is the anchor.
let mut chain = Vec::with_capacity(new.len());
let mut parent_uri: Option = anchor.map(|i| old[i].uri.clone());
let mut parent_is_new = false;
for (index, planned) in new.iter().enumerate() {
match assigned[index] {
Some(member) => {
let m = &old[member];
let changed = comparable_patch(&m.latest_patch) != comparable_patch(&planned.patch);
let relink = parent_is_new || m.dependent_on != parent_uri;
match (m.state.as_str(), changed, relink) {
("merged", false, _) => {
// Never touched; successors hang off it wherever the
// chain says it now sits.
chain.push(Slot::Frozen { member });
}
("merged", true, _) => bail!(
"{} ({}) is merged, but commit {} would change its patch\n\
rebase the branch past the merge before resubmitting",
m.title,
m.rkey,
planned.short(),
),
// The other half of the same lag, and the same `7`.
("?", true, _) | ("?", _, true) => {
return Err(crate::exit::fail(
crate::exit::Exit::Conflict,
format!(
"{} ({}) needs updating, and its state cannot be \
settled: it may have been merged while the index \
lags\nretry when `atgc stack view` shows a state, or \
reconcile through the web",
m.title, m.rkey,
),
));
}
(_, true, _) => chain.push(Slot::Update { index, member }),
(_, false, relink) => chain.push(Slot::Keep { member, relink }),
}
parent_uri = Some(m.uri.clone());
parent_is_new = false;
}
None => {
chain.push(Slot::Add { index });
// The parent uri of whatever comes next is minted at write
// time; all that matters here is that it will be new.
parent_uri = None;
parent_is_new = true;
}
}
}
Ok(Plan {
chain,
drops,
retired,
anchor,
})
}
/// The refusal for open pulls no commit on the branch answers to any more:
/// what `--prune` authorizes, with every record it would delete named.
///
/// Shared, because there are two moments this has to be said. The reconcile
/// reaches it after planning, which is the general case. `resubmit` asks the
/// same question *before* `--add-change-ids` rewrites anything, where the
/// answer is already knowable and the commits the reader would have to go
/// looking for are still on the branch under the shas they were printed as.
fn drop_refusal(drops: &[&OldMember]) -> anyhow::Error {
let lines = two_column_listing(drops.iter().map(|m| (&m.rkey, &m.title)));
anyhow::anyhow!(
"{} open pull(s) match no commit on the branch any more:\n{lines}\
reconciling would delete their records, and the review comments on them\n\
rerun with --prune to delete them, restore the commits, or close them \
(a closed pull is kept and left out of the chain)",
drops.len(),
)
}
/// The change-id every commit of the range will answer to once
/// `--add-change-ids` has run: the one it already carries, or the `I`
/// the rewrite would mint for one that carries none.
///
/// `None` when nothing would be minted. Every commit already having an id
/// means the rewrite changes no identity at all, and there is then nothing
/// here worth predicting — the reconcile's own answer is the same one.
fn change_ids_after_rewrite(commits: &[String]) -> Result>> {
let mut minted = false;
let mut ids = Vec::with_capacity(commits.len());
for sha in commits {
match gitpatch::change_id(sha)? {
Some(id) => ids.push(id),
None => {
minted = true;
ids.push(format!("I{sha}"));
}
}
}
Ok(minted.then_some(ids))
}
/// Refuse a rewrite that would leave open pulls answering to nothing.
///
/// A minted change-id is `I` followed by the commit's own pre-rewrite sha,
/// so it is new by construction and can match no record that already exists.
/// A branch rebuilt without its `Change-Id:` trailers therefore orphans its
/// entire stack the instant the rewrite runs, and the reconcile that follows
/// can only report what it finds: N open pulls matching no commit, remedied
/// by `--prune`, which deletes those records and every review comment on
/// them. That advice used to arrive with the branch already rewritten, which
/// is the worst possible moment for it — the reader is being asked to choose
/// between two deletions while the shas that would let them check are gone.
///
/// `--prune` skips this for the same reason the reconcile does: somebody who
/// passed it has already authorized the deletion.
fn refuse_orphaned_by_rewrite(old: &[OldMember], after: &[String], prune: bool) -> Result<()> {
if prune {
return Ok(());
}
let orphaned: Vec<&OldMember> = old
.iter()
// The three states the reconcile never drops: a merged member is
// frozen, a closed one is retired, and one whose state cannot be
// settled is its own refusal rather than this one.
.filter(|m| !matches!(m.state.as_str(), "merged" | "closed" | "?"))
.filter(|m| !after.iter().any(|id| m.owns(id)))
.collect();
if orphaned.is_empty() {
return Ok(());
}
Err(anyhow::anyhow!(
"the change-ids --add-change-ids mints come from the commits' own shas, so they \
match no pull that exists: rewriting the branch first would not change this\n{}",
drop_refusal(&orphaned),
))
}
/// A merge in the range means the branch is not the line a stack is made
/// of: `rev-list` hands back both parents' histories interleaved, the merge
/// itself has no single patch, and the change-id rewrite would silently
/// flatten it — found by trying exactly that against a real branch.
fn refuse_merges(base: &str) -> Result<()> {
let merges = gitpatch::merges_since(base)?;
if merges.is_empty() {
return Ok(());
}
let mut rows = Vec::with_capacity(merges.len());
for sha in &merges {
rows.push((&sha[..7.min(sha.len())], subject_of(sha)?));
}
let lines = two_column_listing(rows);
bail!(
"{base}..HEAD contains {} merge commit(s):\n{lines}\
a stack is cut out of a straight line of commits, and a merge has no \
patch of its own\n\
linearize with `git rebase {base}`, then rerun",
merges.len(),
)
}
/// Every refusal that a plan alone decides, in one place so they can run
/// before the branch is rewritten rather than after it.
///
/// An empty patch, a message naming an image that is not on the disk, two
/// commits claiming one change-id, and a cut that leaves a single member:
/// all four are answerable from the commits as they stand. Deciding them
/// after `--add-change-ids` meant refusing with the branch already moved —
/// new shas, no pull requests, and the original mistake now sitting on
/// commits nobody wrote.
fn refuse_unplannable(planned: &[Planned], commits: usize, base: &str) -> Result<()> {
if planned.len() == 1 {
bail!(
"the marks cut {base}..HEAD into one pull request of {commits} commit(s), and \
a stack is two or more\n\
`atgc pr create` files this as the one pull request it is, or \
`atgc stack mark ` cuts it again"
);
}
refuse_empty_patches(planned)?;
// Every commit here publishes, so a bad image reference refuses the
// stack now, exactly as before the scan became deferrable.
for p in planned {
if let Err(e) = &p.images {
bail!("{e:#}");
}
}
if let Some((id, shas)) = duplicate_change_id(planned) {
bail!(
"commits {} all carry the change-id {id}\n\
a change-id names one pull across rewrites; a cherry-pick copying the \
trailer is the usual cause\n\
amend all but one with a fresh Change-Id, then rerun",
shas.join(" and "),
);
}
Ok(())
}
/// Whether a format-patch changes anything at all. An empty commit's patch
/// is headers with no `diff --git` in it.
fn patch_has_diff(patch: &str) -> bool {
patch.contains("\ndiff --git ")
}
/// The knot refuses to merge an empty patch — its merge check reports a
/// conflict with no files in it — so a stack carrying one could never land,
/// and it is refused at the door instead of at the end.
fn refuse_empty_patches(planned: &[Planned]) -> Result<()> {
let empty: Vec<&Planned> = planned
.iter()
.filter(|p| !patch_has_diff(&p.patch))
.collect();
if empty.is_empty() {
return Ok(());
}
let lines = two_column_listing(empty.iter().map(|p| (p.short(), &p.subject)));
bail!(
"{} commit(s) in the range change nothing:\n{lines}\
the knot refuses to merge an empty patch\n\
drop them or give them content, then rerun",
empty.len(),
)
}
/// A patch reduced to what a round means: everything above the trailing
/// signature block `git format-patch` appends (`-- ` then the git version),
/// which travels with whichever git formatted it. Compared raw, a git
/// upgrade, a second machine, or a round appended by Tangled's web resubmit
/// made every member read "changed" — and the documented rerun-is-a-no-op
/// recovery appended a spurious round per pull instead of doing nothing.
fn comparable_patch(patch: &str) -> &str {
match patch.rfind("\n-- \n") {
Some(at) => &patch[..at],
None => patch,
}
}
/// Two or more commits claiming one change-id, if any — with every claimant
/// named, because the fix is amending all but one of them.
fn duplicate_change_id(planned: &[Planned]) -> Option<(String, Vec)> {
let mut by_id: std::collections::HashMap<&str, Vec> = std::collections::HashMap::new();
for p in planned {
for (sha, id) in p.shas.iter().zip(&p.change_ids) {
by_id
.entry(id.as_str())
.or_default()
.push(sha.chars().take(7).collect());
}
}
by_id
.into_iter()
.find(|(_, shas)| shas.len() > 1)
.map(|(id, shas)| (id.to_string(), shas))
}
/// The `Change-Id:` mail header of a round's patch, where stack identity is
/// read from.
fn change_id_header(patch: &str) -> Option {
let headers = patch.split("\n\n").next()?;
headers
.lines()
.find_map(|l| l.strip_prefix("Change-Id: ").map(|v| v.trim().to_string()))
}
/// Every `Change-Id:` header in a round's patch, bottom message first: one
/// per commit the member carries.
///
/// A member of one commit yields one, which is every stack written before
/// members could hold more. A message with no header at all is skipped
/// rather than positioned — the ids are used as a set of claims, and a
/// placeholder in the list would be a claim on nothing.
fn change_id_headers(patch: &str) -> Vec {
let offsets = gitpatch::message_offsets(patch);
if offsets.is_empty() {
return change_id_header(patch).into_iter().collect();
}
offsets
.iter()
.enumerate()
.filter_map(|(i, &start)| {
let end = offsets.get(i + 1).copied().unwrap_or(patch.len());
change_id_header(&patch[start..end])
})
.collect()
}
/// Cut the branch the way the existing records already cut it.
///
/// Each member owns the change-ids its latest round carries; the commits
/// answering to them mark out that member's *span* on the branch. A commit
/// inside a span that no member claims — one written into the middle of a
/// member since the last reconcile — joins the member whose span it fell
/// in, which is the reading that keeps a member of several commits from
/// silently splintering into new pulls. A commit outside every span becomes
/// its own new member, exactly as a new commit always has.
///
/// Two members whose spans overlap is the one shape this cannot represent:
/// their commits are interleaved, and a member is a contiguous run. That is
/// refused rather than guessed at, with the branch marks named as the way to say
/// what was meant.
fn groups_from_members(commits: &[String], old: &[OldMember]) -> Result {
let mut ids = Vec::with_capacity(commits.len());
for sha in commits {
ids.push(gitpatch::change_id(sha)?);
}
groups_from_ids(&ids, old)
}
/// [`groups_from_members`] with git already asked: the branch as a list of
/// change-ids, bottom first, `None` where a commit carries none. Split out
/// because the spans-and-overlaps reasoning is the whole of the rule and a
/// test of it should not need a repository.
fn groups_from_ids(ids: &[Option], old: &[OldMember]) -> Result {
// Bottom-most and top-most commit each member still holds.
let mut spans: Vec<(usize, usize, usize)> = Vec::new();
for (m, member) in old.iter().enumerate() {
let mut hits = ids
.iter()
.enumerate()
.filter(|(_, id)| id.as_deref().is_some_and(|id| member.owns(id)))
.map(|(i, _)| i);
if let Some(first) = hits.next() {
let last = hits.next_back().unwrap_or(first);
spans.push((first, last, m));
}
}
spans.sort_by_key(|&(first, _, _)| first);
for pair in spans.windows(2) {
let ((_, last, a), (first, _, b)) = (pair[0], pair[1]);
if first <= last {
bail!(
"the commits of {} ({}) and {} ({}) are interleaved on the branch, and a \
pull request holds a run of commits, not a scattering\n\
reorder the branch so each pull's commits sit together, or say the cut \
outright by marking the cuts with local branches",
old[a].title,
old[a].rkey,
old[b].title,
old[b].rkey,
);
}
}
let mut groups: Groups = Vec::new();
let mut i = 0;
while i < ids.len() {
match spans
.iter()
.find(|&&(first, last, _)| first <= i && i <= last)
{
Some(&(_, last, _)) => {
groups.push((i..=last).collect());
i = last + 1;
}
None => {
groups.push(vec![i]);
i += 1;
}
}
}
Ok(groups)
}
/// Read one member's record back into an [`OldMember`]: the latest round's
/// patch comes off the author's PDS (a public blob read, bounded the way
/// `pr diff` bounds it) because the patch header is the only place the
/// member's change-id exists.
/// The account a member's record belongs to, and the PDS to read it from.
///
/// **A member is read from its own author's PDS, never from the reader's.**
/// A record lives in the repository its at-uri names, so the account doing
/// the reading is not the account that has the bytes — and the round patches
/// this fetches are blobs, which only that PDS serves.
///
/// This was two rules. `stack view` resolved per member and was right;
/// `stack merge` passed its own PDS and account for every member and was
/// wrong — while being, by design, *the* command a repo owner uses to land a
/// contributor's stack. It asked its own PDS for a contributor's patch blob,
/// got a 404 and reported it as the contributor's patch being gone.
async fn member_home(item: &serde_json::Value) -> Result<(String, String)> {
let uri = item["uri"].as_str().unwrap_or_default();
let did = crate::model::record::authority_of(uri)
.with_context(|| format!("{uri} is not an at-uri, so nothing says whose record it is"))?
.to_string();
let pds = crate::clients::atproto::did::pds_or_fail(&did).await?;
Ok((did, pds))
}
async fn old_member(item: &serde_json::Value, state: &str) -> Result {
let (did, pds) = member_home(item).await?;
let (did, pds) = (did.as_str(), pds.as_str());
let uri = item["uri"].as_str().unwrap_or_default().to_string();
let rkey = uri.rsplit('/').next().unwrap_or_default().to_string();
let value = &item["value"];
let title = value["title"].as_str().unwrap_or("(untitled)").to_string();
let rounds = value["rounds"].as_array().map(|r| r.len()).unwrap_or(0);
let body = value["body"].as_str().map(str::to_string);
let latest_patch = latest_round_patch(pds, did, value, &format!("{title} ({rkey})")).await?;
Ok(OldMember {
change_ids: change_id_headers(&latest_patch),
dependent_on: value["dependentOn"].as_str().map(str::to_string),
body,
latest_patch,
uri,
rkey,
title,
state: state.to_string(),
rounds,
})
}
/// How many members are read back at once.
///
/// Each read is a `getRecord` and a blob download — the latest round's
/// patch, which is where a member's change-id lives and whose bytes decide
/// whether anything changed. `resubmit` and `merge` both did one per loop
/// iteration with an `.await` on it, so a ten-member stack was ten round
/// trips end to end before either could plan anything.
///
/// Bounded, for `images.rs`'s reason: a stack should not open as many
/// simultaneous streams against one PDS as it happens to have members.
/// *Ordered* — `buffered`, not `buffer_unordered` — because both callers
/// build a `Vec` whose index is the member's position in the chain, and the
/// chain's order is the whole subject of these commands.
const MEMBER_READ_CONCURRENCY: usize = 4;
/// Read every member of `members` back, in order.
///
/// `state_of` is a pure lookup against the listing already in hand, so it is
/// evaluated per member here rather than being another thing to thread
/// through.
/// The order this returns is the chain's, and
/// `reordering_the_branch_relinks_the_chain_onto_the_new_order` in
/// `tests/stack_flows.rs` is what holds it: its three members all read at
/// once at this concurrency, and it asserts the exact `dependentOn` chain
/// that comes out.
async fn read_members<'a>(
members: impl Iterator- ,
) -> Result
> {
use futures_util::stream::{self, StreamExt, TryStreamExt};
let members: Vec<_> = members.collect();
crate::logging::debug::log(format!(
"stack: reading {} member(s) back, {MEMBER_READ_CONCURRENCY} at a time",
members.len()
));
stream::iter(
members
.into_iter()
.map(|(member, state)| async move { old_member(member, &state).await }),
)
.buffered(MEMBER_READ_CONCURRENCY)
.try_collect()
.await
}
/// Reconcile the stack's records with the rewritten branch.
///
/// Tangled's own semantics, so a stack is interchangeable between atgc and
/// the web: matched commits keep their records (a changed patch appends a
/// round), new commits create records, vanished commits delete theirs under
/// `--prune`, and the whole `dependentOn` chain is rewritten to the new
/// order — all in one applyWrites. Rerunning it against an unchanged branch
/// is a no-op, which is also the recovery story for a partial failure:
/// whatever landed is recognized as done and only the remainder is sent.
pub(crate) async fn resubmit(args: ResubmitArgs) -> Result<()> {
let json = args.json;
let mut rewritten = None;
let report = note_rewrite(
resubmit_inner(args, &mut rewritten).await,
rewritten.as_ref(),
)?;
match json {
true => crate::term::jsonout::emit(&report),
false => Ok(()),
}
}
/// The reconcile itself, which says everything it has to say on stdout as it
/// goes and hands the report back rather than printing it.
///
/// Returning it is what lets `stack sync` run this as its second half and
/// still obey rule 1 of `--json`: one value on stdout, so a command that
/// rebases *and* reconciles emits one object describing both, not two.
async fn resubmit_inner(
args: ResubmitArgs,
rewritten: &mut Option,
) -> Result {
crate::term::jsonout::init(args.json);
let selection = crate::config::account::select().await?;
selection.announce();
let me = selection.did.clone();
let branch = git::current_branch()?;
let remote_url = git::remote_url(&args.remote)?;
let repo = resolve::repo_ref(&remote_url).await?;
// The stack as recorded: the complete listing, entered through the
// acting account's own pull for the branch, refused unless seen whole
// and wholly owned — the preamble every stack write shares, in one
// place now instead of three drifting copies.
let rows = super::complete_rows(listing::Source::EVERY, &repo.did, "a reconcile").await?;
let items: Vec<&serde_json::Value> = rows.iter().map(|r| &r.item).collect();
let chain = super::chain_for_branch(
&items,
&branch,
&me,
&super::closed_uris(&rows),
listing::Source::EVERY,
super::Whose::Mine,
"a stack starts with `atgc stack create`; resubmit reconciles one that exists",
"`atgc pr resubmit` appends a round to it; `atgc stack create` turns it into \
a stack, adopting it as the bottom member",
)?;
let target_branch = crate::model::pull::target_branch_of_row(chain.top())?;
// The branch as it stands now.
let base = format!("{}/{}", args.remote, target_branch);
if !git::ref_exists(&base) {
git::fetch(&args.remote, &target_branch)?;
}
let commits = gitpatch::commits_since(&base)?;
if commits.is_empty() {
bail!("no commits between {base} and HEAD; nothing to reconcile the stack with");
}
// A reconcile against a target that has moved is not wrong — the records
// describe the branch, and the branch is what it is — but the patches it
// writes are patches against an old base, and every one of them is a
// conflict waiting at merge time. Said once, here, because this is the
// moment somebody is looking at the stack and could fix it in one
// command.
if !args.json && !git::is_ancestor(&base, "HEAD") {
crate::term::say::note!(
Git,
"{base} has moved since this branch left it, so these rounds carry patches \
against the old base\n\
`atgc stack sync` rebases and reconciles in one go"
);
}
refuse_merges(&base)?;
// Everything that does not depend on the branch is settled before the
// branch can move: the session, the PDS, and every member read back with
// its patch. All three used to sit below `ensure_change_ids`, so any of
// them failing — an expired session most ordinarily — left a rewritten
// branch and an untouched stack. None of them has anything to say about
// the commits, so none of them had a reason to be down there.
let agent = match args.dry_run {
true => None,
false => Some(auth::agent_for_did(&me).await?),
};
// Read every member back, latest patch included: the patch header is
// where a member's change-id lives, and the bytes are what decides
// "changed" — identical bytes append no round, which is what makes a
// rerun a no-op.
let pds = crate::clients::atproto::did::pds_or_fail(&me).await?;
// A member showing `?` here is one whose state genuinely cannot be
// settled: `repo_rows` already resolves the fresh-stack case — no
// status record anywhere, but the acting account owns the repo and
// authored the pull, so its own PDS was a complete answer — to open.
let old: Vec = read_members(chain.members.iter().map(|member| {
let uri = member["uri"].as_str().unwrap_or_default();
(*member, super::state_of(&rows, uri))
}))
.await?;
// The trap this pair of commands is most dangerous in, asked before the
// rewrite rather than after it. A commit with no change-id gets
// `I`, which by construction matches no pull that exists —
// so a branch rebuilt without its trailers makes every open member
// unmatched, and the reconcile's honest answer to that is to name
// `--prune`, which deletes those records and the review comments on
// them. Run afterwards, that advice arrived with the old shas already
// gone; run here, nothing has moved and the commits can still be found.
if args.add_change_ids
&& let Some(after) = change_ids_after_rewrite(&commits)?
{
refuse_orphaned_by_rewrite(&old, &after, args.prune)?;
}
let rewrite_pending = ensure_change_ids(
&base,
&commits,
args.add_change_ids,
args.dry_run,
rewritten,
)?;
if rewrite_pending {
bail!(
"cannot plan a reconcile before the rewrite adds those change-ids; rerun \
without --dry-run, or add the ids first"
);
}
let commits = gitpatch::commits_since(&base)?;
// How the branch is cut into members. The branches pointing into the
// range are the authority when there are any: they are what `create`
// cut on, they are what a person moves when they mean to re-cut, and
// `git rebase --update-refs` keeps them on the right commits. With none
// — or with --per-commit — the records themselves say where the cuts
// are, which is what makes a grouped stack reconcile with no flags at
// all even in a checkout that has lost the branches.
let marks = match args.per_commit {
true => Vec::new(),
false => {
let (marks, stranded) = super::marks::positions_and_stranded(&branch, &commits);
warn_stranded_marks(&stranded);
marks
}
};
let cut = match (args.per_commit, marks.is_empty()) {
(true, _) => Cut::per_commit(commits.len()),
(false, false) => cut_at_marks(commits.len(), &marks),
(false, true) => {
let groups = groups_from_members(&commits, &old)?;
Cut {
labels: vec![None; groups.len()],
groups,
}
}
};
let planned = plan_groups(&me, &commits, &cut.groups, false)?;
refuse_empty_patches(&planned)?;
let plan = reconcile(&old, &planned, args.prune)?;
// Say the plan, then do it (or stop).
let total = plan.chain.len();
if !args.json {
println!("target: {} branch {target_branch}", repo.linked());
println!("source: branch {branch}");
for (i, slot) in plan.chain.iter().enumerate().rev() {
let line = match slot {
Slot::Update { index, member } => format!(
"update {}{} (round {}, {})",
old[*member].title,
label_of(&cut, *index),
old[*member].rounds + 1,
describe_commits(&planned[*index]),
),
Slot::Keep {
member,
relink: true,
} => format!("relink {} (chain order moved)", old[*member].title),
Slot::Keep {
member,
relink: false,
} => format!("keep {}", old[*member].title),
Slot::Frozen { member } => {
format!("merged {} (never touched)", old[*member].title)
}
Slot::Add { index } => format!(
"add {}{} ({})",
planned[*index].subject,
label_of(&cut, *index),
describe_commits(&planned[*index]),
),
};
println!(" {}/{total} {line}", i + 1);
// An update is the only fate that would rewrite a description,
// so it is the only one that can decline to.
if let Slot::Update { member, .. } = slot
&& !old[*member].body_is_its_commits()
{
println!(" body kept (edited since its last round)");
}
// The bodies that will be (re)written are the ones whose images
// matter to the plan; a kept member's body is not touched.
if let Slot::Update { index, .. } | Slot::Add { index } = slot {
for img in planned[*index]
.images
.as_ref()
.map(|i| i.describe())
.unwrap_or_default()
{
println!(" image: {img}");
}
}
}
for &i in &plan.drops {
println!(" drop {} ({})", old[i].title, old[i].rkey);
}
for &i in &plan.retired {
println!(
" retire {} ({}; closed, unlinked from the chain)",
old[i].title, old[i].rkey
);
}
if let Some(anchor) = plan.anchor {
println!(
" below {} (merged; stays the chain's base)",
old[anchor].title
);
}
}
let nothing_to_send = plan.drops.is_empty()
// A retired member whose link is still standing is work: see the
// unlinking loop below for why it cannot be left there. Retiring the
// *top* of a stack relinks nothing, so without this the one op the
// batch owed would never be sent.
&& plan.retired.iter().all(|&i| old[i].dependent_on.is_none())
&& plan
.chain
.iter()
.all(|s| matches!(s, Slot::Keep { relink: false, .. } | Slot::Frozen { .. }));
let mut report = StackResubmittedJson {
dry_run: args.dry_run,
pushed: false,
changed: !nothing_to_send,
repo_did: repo.did.clone(),
target_branch: target_branch.clone(),
source_branch: branch.clone(),
total,
// Top first, as the text listing above prints it.
members: plan
.chain
.iter()
.enumerate()
.rev()
.map(|(i, slot)| reconciled_member_json(i + 1, slot, &old, &planned))
.collect(),
drops: plan
.drops
.iter()
.map(|&i| DroppedJson {
rkey: old[i].rkey.clone(),
uri: old[i].uri.clone(),
title: old[i].title.clone(),
})
.collect(),
retired: plan
.retired
.iter()
.map(|&i| DroppedJson {
rkey: old[i].rkey.clone(),
uri: old[i].uri.clone(),
title: old[i].title.clone(),
})
.collect(),
anchor: plan.anchor.map(|i| DroppedJson {
rkey: old[i].rkey.clone(),
uri: old[i].uri.clone(),
title: old[i].title.clone(),
}),
wrote: Vec::new(),
url: format!("{}/pulls", repo.web_url),
};
// The claim every member of this stack already makes, kept true. A
// reconcile is a rewritten branch by definition, and one that leaves the
// branch behind writes rounds describing commits the knot does not have:
// `source: {branch}` becomes the untruth `stack create` refuses to tell
// in the first place, and the appview's own resubmit check compares the
// top member against a branch head that never moved.
//
// Before the no-op return rather than after it, because a stack whose
// records already match an unpushed branch is precisely the state this
// has to be able to repair — and when the remote is already there, this
// costs one `ls-remote` and sends nothing.
if !args.dry_run {
// What the last round published, read off its own patch: the sha to
// lease against, and never the local remote-tracking ref, which a
// fetch can advance onto the commits worth protecting.
let expected = old.last().and_then(|m| gitpatch::head_sha(&m.latest_patch));
report.pushed = crate::cmd::publish_branch(
&args.remote,
&branch,
commits.last().map(String::as_str).unwrap_or_default(),
expected.as_deref(),
&format!(
"This stack records `source: {branch}`, so a reconcile has to republish \n\
it; the records would otherwise describe commits the knot does not have."
),
)?;
}
if nothing_to_send {
if !args.json {
println!("the stack already matches the branch; nothing to send");
}
return Ok(report);
}
if args.dry_run {
if !args.json {
println!("dry run; nothing sent");
}
return Ok(report);
}
let agent = agent.expect("a run that is not a dry run resumed its session above");
// The commit CID is read *before* the member records are, so the
// precondition covers everything about to be re-read and rewritten:
// any write that lands between here and the batch — a web resubmit,
// a second atgc — turns the batch into InvalidSwap instead of a silent
// clobber. pr resubmit has guarded exactly this with swapRecord since
// it existed; the batch path was sending None.
let swap_commit = crate::clients::atproto::pds::latest_commit(&pds, &me).await?;
let mut ops: Vec = Vec::new();
// Only the members being minted need their patch bytes up front:
// `append_round_op` compresses its own, and a kept or frozen member has
// nothing to send. Building the whole vector here would compress every
// unchanged member of the stack on every reconcile.
let mut gzipped: Vec> = vec![Vec::new(); planned.len()];
for slot in &plan.chain {
if let Slot::Add { index } = slot {
gzipped[*index] = gzip(&planned[*index].patch)?;
}
}
let (walked, _uris) = chain_ops(
&agent,
Some(&pds),
&me,
&repo.did,
&target_branch,
&branch,
&plan.chain,
&old,
&planned,
&mut gzipped,
plan.anchor.map(|i| old[i].uri.clone()),
)
.await?;
ops.extend(walked);
for &i in &plan.drops {
ops.push(Op::Delete {
nsid: PULL_NSID,
rkey: Key::any_owned(&old[i].rkey)
.map_err(|e| anyhow::anyhow!("bad record key {}: {e}", old[i].rkey))?,
});
}
// A retired member keeps everything about itself except its place in the
// chain. The link is the one part of the record that has just stopped
// being true: the member above it was relinked past it a few lines up,
// so leaving it in place gives one parent two dependents.
//
// That is a fork, and a fork is not a cosmetic untidiness in somebody
// else's copy. `chain_containing` refuses to order one, which takes
// `stack view`, the next `resubmit`, `sync` and `merge` with it — the
// stack this command just wrote becomes one no stack command will read.
// And the appview does not refuse: its own walk (`appview/db/pulls.go`,
// `GetStack`) asks for *the* pull depending on a given one and skips
// only abandoned records, so on tangled.org a fork silently resolves to
// whichever branch the query happens to return, and the live member
// above can disappear out of the stack view.
//
// Unlinking is a whole write of the record, so it costs one op in a
// batch that is already atomic — no extra round trip, and nothing about
// the close survives less well for it. The rounds, the title, the body
// and the comments hanging off the record are all untouched.
for &i in &plan.retired {
let m = &old[i];
if m.dependent_on.is_none() {
continue;
}
ops.push(relink_op(&pds, &me, &m.rkey, None).await?);
}
// The last thing before anything leaves the machine: read the chain
// these ops would produce, and refuse it if the reader could not.
// `super::refuse_new_damage` owns every rule about a well-formed chain,
// so a verb added later gets them without knowing they exist.
super::refuse_new_damage(&items, &me, &ops, &super::closed_uris(&rows))?;
let written =
crate::clients::atproto::record::batch(&agent, "stack", &me, ops, Some(&swap_commit))
.await?;
if args.json {
report.wrote = written;
return Ok(report);
}
for uri in &written {
println!("wrote {uri}");
}
println!(
"view: {}",
crate::term::hyperlink::url(&format!("{}/pulls", repo.web_url))
);
Ok(report)
}
// ---------------------------------------------------------------------------
// `stack rebase`
// ---------------------------------------------------------------------------
#[derive(clap::Args, Debug)]
pub(crate) struct RebaseArgs {
/// Target branch to rebase onto (defaults to the remote's default branch)
#[arg(long)]
pub target: Option,
/// Git remote pointing at the repo
#[arg(long, default_value = "origin")]
pub remote: String,
/// Say what would be replayed without touching the branch
#[arg(long)]
pub dry_run: bool,
/// Print one JSON object instead of the summary lines
#[arg(long)]
pub json: bool,
}
#[derive(serde::Serialize, Debug, PartialEq)]
pub(in crate::cmd) struct RebasedJson {
pub dry_run: bool,
/// `false` when the branch was already on top of its target: nothing ran.
pub rebased: bool,
pub branch: String,
pub base: String,
/// The tip before, and after. Equal on a no-op, and `was` is the sha to
/// `git reset --hard` back to.
pub was: String,
pub now: String,
pub commits: usize,
/// The marks that travel with this replay, by branch name. Populated on
/// a dry run too, since naming what would move is most of what a dry run
/// is for; `atgc stack mark` is where their new positions are read back.
pub marks: Vec,
}
/// Rebase the stack onto its target, carrying the marks.
///
/// The one command here that runs `git rebase`, and it exists for one reason:
/// `--update-refs`. A stack is cut at branch marks, and a plain rebase leaves
/// every one of them on a commit the branch no longer has — so the cut
/// silently disappears and the next reconcile sees an uncut branch. Telling
/// people to remember a flag is not a design; running the rebase is.
///
/// Nothing is sent. This moves the branch and stops, because a reconcile
/// wants a branch that is finished moving, and a rebase that stops on a
/// conflict is a branch that is not.
pub(crate) async fn rebase(args: RebaseArgs) -> Result<()> {
crate::term::jsonout::init(args.json);
let json = args.json;
let report = rebase_run(&args).await?;
match json {
true => crate::term::jsonout::emit(&report),
false => Ok(()),
}
}
/// The rebase itself, printing as it goes and handing the report back — the
/// same split as [`resubmit_inner`], and for the same reason: `stack sync`
/// runs this as its first half and emits one object for both.
async fn rebase_run(args: &RebaseArgs) -> Result {
// Asked before anything else, because a rebase in progress is a detached
// HEAD: every other question here — which branch, is the tree clean —
// answers with something misleading first. "check out a branch" is the
// one piece of advice that would throw away the conflict work somebody
// is in the middle of.
if git::rebase_in_progress() {
bail!(
"a rebase is already in progress here\n\
finish it with `git rebase --continue`, or drop it with `git rebase --abort`"
);
}
let branch = git::current_branch()?;
let target = match &args.target {
Some(t) => t.clone(),
None => git::remote_default_branch(&args.remote).unwrap_or_else(|| "main".to_string()),
};
let base = format!("{}/{}", args.remote, target);
// Refused before the fetch, so a dirty tree costs nothing and says the
// same thing whether or not the network is there.
if !gitpatch::working_tree_clean()? {
bail!(
"the working tree has uncommitted changes\n\
commit or stash them first; a rebase needs a clean tree"
);
}
// Fetched, but not required. Rebasing onto a target that is a minute
// stale is the ordinary case and still worth doing; refusing to replay
// anything because a host is down would make the one command that
// rewrites the branch the one command that needs the network most. A
// fetch that fails with no local copy of the target is a different
// thing, and does stop it.
if let Err(e) = git::fetch(&args.remote, &target) {
if !git::ref_exists(&base) {
return Err(e).context(format!(
"{base} is not in this checkout, so there is nothing to rebase onto"
));
}
crate::logging::debug::dump_err("stack rebase: fetch failed", &e);
crate::term::say::warning!(
Git,
"could not fetch {} {target}: replaying onto the {base} already here, which \
may be behind",
args.remote,
);
}
let was = git::git_in(Path::new("."), &["rev-parse", "HEAD"])?
.trim()
.to_string();
let commits = gitpatch::commits_since(&base)?;
let marks = super::marks::positions(&branch, &commits);
let up_to_date = git::is_ancestor(&base, "HEAD")
&& git::git_in(Path::new("."), &["rev-parse", &base])?.trim()
== git::git_in(Path::new("."), &["merge-base", &base, "HEAD"])?.trim();
if !args.json {
println!("branch: {branch}");
println!("onto: {base}");
println!(
"replay: {} commit(s), {} mark(s) travelling with them",
commits.len(),
marks.len()
);
for (at, name) in &marks {
println!(" {}/{} {name}", at + 1, commits.len());
}
}
let mut report = RebasedJson {
dry_run: args.dry_run,
rebased: false,
branch: branch.clone(),
base: base.clone(),
was: was.clone(),
now: was.clone(),
commits: commits.len(),
marks: marks.iter().map(|(_, name)| name.clone()).collect(),
};
if up_to_date {
if !args.json {
println!("already on top of {base}; nothing to replay");
}
return Ok(report);
}
if args.dry_run {
if !args.json {
println!("dry run; the branch has not moved");
}
return Ok(report);
}
// `--update-refs` is the whole point; `--no-autostash` because the clean
// tree was already required above, and an autostash that fails to reapply
// after a conflict is a worse mess than the refusal.
git::rebase_update_refs(&base)?;
report.now = git::git_in(Path::new("."), &["rev-parse", "HEAD"])?
.trim()
.to_string();
report.rebased = true;
if !args.json {
println!(
"rebased: {} → {} (`git reset --hard {}` puts it back)",
&was[..7.min(was.len())],
&report.now[..7.min(report.now.len())],
&was[..7.min(was.len())],
);
println!("next: atgc stack resubmit");
}
Ok(report)
}
#[derive(clap::Args, Debug)]
pub(crate) struct SyncArgs {
/// Target branch to rebase onto (defaults to the remote's default branch)
#[arg(long)]
pub target: Option,
/// Git remote pointing at the repo
#[arg(long, default_value = "origin")]
pub remote: String,
/// Delete the records of pulls whose commits left the branch
#[arg(long)]
pub prune: bool,
/// Rewrite base..HEAD to add Change-Id trailers to commits lacking one
#[arg(long)]
pub add_change_ids: bool,
/// Say what both halves would do without moving the branch or writing
#[arg(long)]
pub dry_run: bool,
/// Print one JSON object describing both halves instead of the summary lines
#[arg(long)]
pub json: bool,
}
/// What `stack sync` did: the rebase, then the reconcile.
#[derive(serde::Serialize, Debug, PartialEq)]
pub(in crate::cmd) struct SyncedJson {
pub rebase: RebasedJson,
pub reconcile: StackResubmittedJson,
}
/// Catch the branch up with its target and reconcile the stack: the two
/// halves of a review cycle, in the order they have to happen.
///
/// The halves stay available on their own — a conflict during the rebase
/// leaves work to do before any reconcile makes sense, and an amend that
/// needs no rebase needs no fetch either — but neither is the verb to reach
/// for by default. `gh stack sync` and Graphite's `gt sync` pair the same
/// two behind one word.
///
/// Stops after the rebase if the rebase stopped. A reconcile planned against
/// a half-rebased branch would be a plan for a branch that does not exist
/// yet, and the records it wrote would have to be undone by hand.
pub(crate) async fn sync(args: SyncArgs) -> Result<()> {
crate::term::jsonout::init(args.json);
let rebase = rebase_run(&RebaseArgs {
target: args.target.clone(),
remote: args.remote.clone(),
dry_run: args.dry_run,
json: args.json,
})
.await?;
if !args.json {
println!();
}
let mut rewritten = None;
let reconcile = note_rewrite(
resubmit_inner(
ResubmitArgs {
remote: args.remote,
add_change_ids: args.add_change_ids,
prune: args.prune,
per_commit: false,
dry_run: args.dry_run,
json: args.json,
},
&mut rewritten,
)
.await,
rewritten.as_ref(),
)?;
match args.json {
true => crate::term::jsonout::emit(&SyncedJson { rebase, reconcile }),
false => Ok(()),
}
}
// ---------------------------------------------------------------------------
// `stack link`
// ---------------------------------------------------------------------------
#[derive(clap::Args, Debug)]
pub(crate) struct LinkArgs {
/// The pulls to chain, bottom first: numbers, record keys or at-uris
#[arg(required = true, num_args = 2..)]
pub pulls: Vec,
/// Git remote pointing at the repo
#[arg(long, default_value = "origin")]
pub remote: String,
/// Say what would be chained without writing anything
#[arg(long)]
pub dry_run: bool,
/// Print one JSON object describing the chain instead of the summary lines
#[arg(long)]
pub json: bool,
}
/// One member of `stack link --json`, bottom first.
#[derive(serde::Serialize, Debug, PartialEq)]
pub(in crate::cmd) struct LinkedJson {
/// 1-based from the bottom, the order they will be reviewed and merged in.
pub position: usize,
pub uri: String,
pub rkey: String,
pub title: String,
/// The at-uri this member is made to depend on, `null` for the bottom.
pub dependent_on: Option,
/// False when the record already said exactly this, so nothing was
/// written for it.
pub changed: bool,
}
#[derive(serde::Serialize, Debug, PartialEq)]
pub(in crate::cmd) struct StackLinkedJson {
pub dry_run: bool,
/// `false` when every record already named the right parent.
pub changed: bool,
pub repo_did: String,
pub target_branch: String,
/// Bottom first, the order the chain is written in.
pub members: Vec,
pub wrote: Vec,
pub url: String,
}
#[derive(clap::Args, Debug)]
pub(crate) struct UnlinkArgs {
/// The pulls to take out of their chain: numbers, record keys or at-uris
#[arg(required = true)]
pub pulls: Vec,
/// Git remote pointing at the repo
#[arg(long, default_value = "origin")]
pub remote: String,
/// Say what would be unlinked without writing anything
#[arg(long)]
pub dry_run: bool,
/// Print one JSON object describing what was unlinked
#[arg(long)]
pub json: bool,
}
/// One pull `stack unlink` took out of a chain.
#[derive(serde::Serialize, Debug, PartialEq)]
pub(in crate::cmd) struct UnlinkedJson {
pub uri: String,
pub rkey: String,
pub title: String,
/// What it used to depend on, or `null` if it was the bottom.
pub was_on: Option,
/// The pull that depended on it and now depends on `was_on`, or `null`
/// if nothing did.
pub relinked: Option,
/// A pull that depended on it and belongs to somebody else, so it was
/// left where it is: `null` when there is none.
///
/// It cannot be relinked from here — the record is in that account's
/// repository — and the unlink still happens, which leaves their pull
/// depending on a member that no longer depends on anything. That is a
/// well-formed chain, just a shorter one than they had.
#[serde(skip_serializing_if = "Option::is_none")]
pub left_attached: Option,
}
#[derive(serde::Serialize, Debug, PartialEq)]
pub(in crate::cmd) struct StackUnlinkedJson {
pub dry_run: bool,
/// `false` when nothing named was in a chain to begin with.
pub changed: bool,
pub repo_did: String,
pub unlinked: Vec,
pub wrote: Vec,
pub url: String,
}
/// Take pulls out of a chain, closing the gap behind them.
///
/// The inverse of [`link`], and the half that was missing. `link` writes
/// `dependentOn` and nothing removed it: `stack resubmit` clears one only as
/// a side effect of retiring a *closed* member, and it cannot touch a chain
/// whose patches carry no change-id — which is every pull `pr create` ever
/// wrote. So a chain linked in the wrong order, or onto the wrong pull, had
/// exactly one exit: a hand-written `com.atproto.repo.putRecord`. That is
/// not an escape hatch, it is the absence of one.
///
/// **Closing the gap is the whole operation.** Clearing one field would
/// leave whatever depended on the named pull pointing at a record that is no
/// longer in the chain, which is a break rather than a removal — so each
/// pull's dependent inherits its parent, exactly as retiring a member does.
/// Naming every member is how a chain is dissolved; there is no separate
/// verb for it, because it is the same operation applied throughout.
///
/// One `applyWrites`, for [`link`]'s reason: written a record at a time, a
/// chain passes through a state the appview refuses at ingest.
pub(crate) async fn unlink(args: UnlinkArgs) -> Result<()> {
crate::term::jsonout::init(args.json);
let selection = crate::config::account::select().await?;
selection.announce();
let me = selection.did.clone();
let remote_url = git::remote_url(&args.remote)?;
let repo = resolve::repo_ref(&remote_url).await?;
let mut named = Vec::with_capacity(args.pulls.len());
for reference in &args.pulls {
named
.push(crate::cmd::pr::review::resolve_pull(Some(reference), None, &args.remote).await?);
}
let mut seen = std::collections::HashSet::new();
for m in &named {
if !seen.insert(m.uri.clone()) {
bail!("{} is named twice", m.rkey);
}
// Same rule as `link`: the chain is a field on each record, and only
// its author may write it. A pull of somebody else's can sit in a
// stack, and taking it out is their write to make.
if m.did != me {
bail!(
"{} belongs to {}, and this would rewrite it\n\
only the account that authored a pull can unchain it",
m.rkey,
m.did
);
}
}
let rows = super::complete_rows(listing::Source::EVERY, &repo.did, "an unlink").await?;
let items: Vec<&serde_json::Value> = rows.iter().map(|r| &r.item).collect();
let parent_of = |uri: &str| -> Option {
items
.iter()
.find(|i| i["uri"].as_str() == Some(uri))
.and_then(|i| i["value"]["dependentOn"].as_str())
.map(str::to_string)
};
// What to write, and what it should say. A pull that depends on one being
// unlinked inherits that pull's own parent, so the chain closes rather
// than breaking. Resolved against the records as they are *now*, before
// anything is written, so unlinking several members of one chain at once
// is a single consistent answer rather than a sequence of edits.
let mut new_parent: std::collections::BTreeMap> =
std::collections::BTreeMap::new();
let named_uris: std::collections::HashSet<&str> =
named.iter().map(|m| m.uri.as_str()).collect();
let mut report_rows = Vec::new();
for m in &named {
let was_on = parent_of(&m.uri);
// Walk down past anything else being unlinked in the same call, so a
// dependent inherits the first member that is staying.
let mut inherits = was_on.clone();
while let Some(p) = inherits.clone() {
if !named_uris.contains(p.as_str()) {
break;
}
inherits = parent_of(&p);
}
let dependent = items
.iter()
.filter(|i| i["value"]["dependentOn"].as_str() == Some(m.uri.as_str()))
.filter_map(|i| i["uri"].as_str())
.find(|uri| !named_uris.contains(uri))
.map(str::to_string);
// **Only a record in this account's repository can be relinked.**
// The listing is `Source::EVERY`, so the pull sitting on this one may
// be somebody else's — and the relink used to be planned for it
// anyway, which sent a `getRecord` for a stranger's record key
// against *our* DID and died with "no pull record 3x in did:plc:me".
// The unlink still stands; what cannot happen is closing the chain
// over a record we may not write.
let (dependent, left_attached) = match dependent {
Some(uri) if crate::model::record::is_authored_by(&uri, &me) => (Some(uri), None),
Some(uri) => (None, Some(uri)),
None => (None, None),
};
if let Some(dependent) = &dependent {
new_parent.insert(dependent.clone(), inherits.clone());
}
new_parent.insert(m.uri.clone(), None);
report_rows.push(UnlinkedJson {
uri: m.uri.clone(),
rkey: m.rkey.clone(),
title: m.value["title"]
.as_str()
.unwrap_or("(untitled)")
.to_string(),
was_on: was_on.clone(),
relinked: dependent,
left_attached,
});
}
// Only the records whose field actually changes.
new_parent.retain(|uri, parent| parent_of(uri) != *parent);
let changed = !new_parent.is_empty();
let mut report = StackUnlinkedJson {
dry_run: args.dry_run,
changed,
repo_did: repo.did.clone(),
unlinked: report_rows,
wrote: Vec::new(),
url: format!("{}/pulls", repo.web_url),
};
if !args.json {
for row in &report.unlinked {
let was = match &row.was_on {
Some(uri) => uri.rsplit('/').next().unwrap_or("?").to_string(),
None => "nothing (it was the bottom)".to_string(),
};
println!(" unlink {} ({}; was on {was})", row.title, row.rkey);
if let Some(dependent) = &row.relinked {
println!(
" {} now depends on what it did",
dependent.rsplit('/').next().unwrap_or("?")
);
}
if let Some(theirs) = &row.left_attached {
// Said rather than silently done: their pull is still stacked
// on this one, and only they can move it.
crate::term::say::warning!(
Pds,
"{theirs} depends on this and is not yours, so it stays where it is\n\
their pull is left stacked on a member that now depends on nothing; \
ask them to resubmit, or relink it through the web"
);
}
}
if !changed {
println!("nothing named is in a chain; nothing to write");
}
}
if !changed || args.dry_run {
if args.json {
return crate::term::jsonout::emit(&report);
}
if args.dry_run {
println!("dry run; nothing sent");
}
return Ok(());
}
let agent = auth::agent_for_did(&me).await?;
let pds = crate::clients::atproto::did::pds_or_fail(&me).await?;
let swap_commit = crate::clients::atproto::pds::latest_commit(&pds, &me).await?;
let mut ops: Vec = Vec::new();
for (uri, parent) in &new_parent {
let rkey = uri.rsplit('/').next().unwrap_or_default().to_string();
ops.push(relink_op(&pds, &me, &rkey, parent.as_deref()).await?);
}
// The last thing before anything leaves the machine: read the chain
// these ops would produce, and refuse it if the reader could not.
// `super::refuse_new_damage` owns every rule about a well-formed chain,
// so a verb added later gets them without knowing they exist.
super::refuse_new_damage(&items, &me, &ops, &super::closed_uris(&rows))?;
let written =
crate::clients::atproto::record::batch(&agent, "stack", &me, ops, Some(&swap_commit))
.await?;
if args.json {
report.wrote = written;
return crate::term::jsonout::emit(&report);
}
for uri in &written {
println!("wrote {uri}");
}
println!(
"view: {}",
crate::term::hyperlink::url(&format!("{}/pulls", repo.web_url))
);
Ok(())
}
/// Chain pull requests that already exist into a stack.
///
/// The gap this fills is the one every other stack verb assumes away: `stack
/// create` opens a chain from a branch, and until now nothing could say "these
/// pulls, in this order, are a stack". Rebuilding them was the only route —
/// cherry-pick onto one branch, open new pulls, close the originals — which
/// throws away each pull's number, its rounds and its review comments to
/// express an ordering that is one field per record.
///
/// That field is `dependentOn`, and the whole of this command is writing it,
/// bottom-up, in a single `applyWrites`. Atomic because the appview rejects a
/// would-be DAG at ingest: written one at a time, a chain passes through a
/// state where two pulls name the same parent, and the ingest that sees it
/// refuses the lot.
pub(crate) async fn link(args: LinkArgs) -> Result<()> {
crate::term::jsonout::init(args.json);
let selection = crate::config::account::select().await?;
selection.announce();
let me = selection.did.clone();
let remote_url = git::remote_url(&args.remote)?;
let repo = resolve::repo_ref(&remote_url).await?;
// Resolved before anything else is read: a mistyped number should fail
// while nothing has been asked of the network on its behalf.
let mut members = Vec::with_capacity(args.pulls.len());
for reference in &args.pulls {
members
.push(crate::cmd::pr::review::resolve_pull(Some(reference), None, &args.remote).await?);
}
let mut seen = std::collections::HashSet::new();
for m in &members {
if !seen.insert(m.uri.clone()) {
bail!(
"{} is named twice; a pull can hold one place in a chain",
m.rkey
);
}
// Only your own records are yours to write. A stranger's pull can sit
// *in* a stack — the chain is a field on each record, not a shared
// object — but somebody else has to write that field.
if m.did != me {
bail!(
"{} belongs to {}, and this would rewrite it\n\
only the account that authored a pull can chain it",
m.rkey,
m.did
);
}
}
// One repo and one target, because a stack lands bottom-up onto a single
// branch: two targets is two stacks, and the merge would take the wrong
// half of one of them.
let target_branch = crate::model::pull::target_branch(&members[0].value, &members[0].uri)?;
for m in &members {
let repo_did = m.value["target"]["repo"]
.as_str()
.or_else(|| m.value["target"]["repoDid"].as_str())
.unwrap_or_default();
if repo_did != repo.did {
bail!(
"{} targets {repo_did}, not the repo this checkout points at ({})",
m.rkey,
repo.did
);
}
let branch = crate::model::pull::target_branch(&m.value, &m.uri)?;
if branch != target_branch {
bail!(
"{} targets branch {branch} and {} targets {target_branch}\n\
a stack lands bottom-up onto one branch",
m.rkey,
members[0].rkey,
);
}
}
// A merged or closed member has no place left in a chain: the chain is
// the order the *remaining* ones land in, and a stack merges bottom-up
// through open pulls, so one of these in the middle stops everything
// above it.
//
// `?` is not refused. It is what `pr read` says when no status record it
// can reach settles the pull, which is the ordinary answer for a pull on
// somebody else's repo — refusing it would rule out the case this
// command is most wanted for. Said out loud instead, below, because a
// `dependentOn` written onto a pull that turns out to be merged is one
// more `stack link` to undo, not a patch landed on the wrong tree.
let rows = super::complete_rows(listing::Source::EVERY, &repo.did, "a link").await?;
let mut unsettled = Vec::new();
for m in &members {
match super::state_of(&rows, &m.uri).as_str() {
state @ ("merged" | "closed") => bail!(
"{} is {state}, so it has no place left in a chain\n\
link the ones that have not landed",
m.rkey
),
"?" => unsettled.push(m.rkey.clone()),
_ => {}
}
}
// Already in some other chain is a fork waiting to happen: this would
// point it somewhere new while whatever depends on it still points here.
let items: Vec<&serde_json::Value> = rows.iter().map(|r| &r.item).collect();
let named: std::collections::HashSet<&str> = members.iter().map(|m| m.uri.as_str()).collect();
for m in &members {
let dependents: Vec<&str> = items
.iter()
.filter(|i| i["value"]["dependentOn"].as_str() == Some(m.uri.as_str()))
.filter_map(|i| i["uri"].as_str())
.filter(|uri| !named.contains(uri))
.collect();
if let Some(other) = dependents.first() {
bail!(
"{} already has {} depending on it, which this chain does not name\n\
linking it here would fork the chain, which the appview refuses at ingest",
m.rkey,
other.rsplit('/').next().unwrap_or(other),
);
}
}
// **Every member's patch has to hold its own commits and nobody else's.**
//
// 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: the knot answers "patch doesn't apply" and "file already
// exists", which names a file rather than the cause and sends people
// looking for a conflict that is not there. `stack create` gets this for
// free by cutting one branch into ranges. `link` takes pulls that
// already exist, and a pull opened by `pr create` records a patch of
// `..` — so two of those overlap whenever one
// branch contains the other, *or* whenever they are the same branch.
//
// This is checked on the patches rather than on the branches because the
// patch is what the merge applies. The branch check below is about
// *order* and answers a different question; it used to be the only one,
// and it accepts — indeed requires — the ancestry that guarantees the
// overlap. Its own refusal advises "rebase {b} onto {a}", which is to
// say it steered people into the shape that cannot merge.
let pds = crate::clients::atproto::did::pds_or_fail(&me).await?;
let mut patches: Vec> = Vec::new();
for m in &members {
let patch = latest_round_patch(&pds, &me, &m.value, &m.rkey).await?;
patches.push(gitpatch::commit_shas(&patch));
}
for (i, (a, b)) in members.iter().zip(&patches).enumerate() {
for (other, others) in members.iter().zip(&patches).skip(i + 1) {
if let Some(shared) = b.iter().find(|sha| others.contains(sha)) {
bail!(
"{} and {} both carry commit {}, so merging them as a series \
would apply it twice\n\
a stack's members hold separate commits: `atgc stack create` cuts \
one branch into members that do, and `link` can only chain pulls \
that already have them\n\
to stack this work, put the upper commits on the lower pull's own \
branch and run `atgc stack create` there: it adopts that pull as \
the bottom member, so its number, comments and rounds survive",
a.rkey,
other.rkey,
&shared[..12.min(shared.len())],
);
}
}
}
// Order, checked against git where git can answer it. A stack merges
// bottom-up, so each member's commits have to sit on top of the one
// below: a chain written in the wrong order lands a patch onto a tree
// that has not had its parent applied. Only branch-based pulls whose
// branches are in this checkout can be checked, and the rest are said
// out loud rather than assumed.
let mut unchecked = Vec::new();
for pair in members.windows(2) {
let (lower, upper) = (&pair[0], &pair[1]);
match (
source_branch_of(&lower.value),
source_branch_of(&upper.value),
) {
(Some(a), Some(b)) if git::ref_exists(&a) && git::ref_exists(&b) => {
if a != b && !git::is_ancestor(&a, &b) {
bail!(
"{} ({a}) is not an ancestor of {} ({b}), so this order does \
not stack\n\
name them the other way round",
lower.rkey,
upper.rkey,
);
}
}
_ => unchecked.push(upper.rkey.clone()),
}
}
let mut report = StackLinkedJson {
dry_run: args.dry_run,
changed: false,
repo_did: repo.did.clone(),
target_branch: target_branch.clone(),
members: Vec::new(),
wrote: Vec::new(),
url: format!("{}/pulls", repo.web_url),
};
let mut parent: Option = None;
for (i, m) in members.iter().enumerate() {
let current = m.value["dependentOn"].as_str().map(str::to_string);
let changed = current != parent;
report.members.push(LinkedJson {
position: i + 1,
uri: m.uri.clone(),
rkey: m.rkey.clone(),
title: m.value["title"]
.as_str()
.unwrap_or("(untitled)")
.to_string(),
dependent_on: parent.clone(),
changed,
});
report.changed |= changed;
parent = Some(m.uri.clone());
}
if !args.json {
println!("target: {} branch {target_branch}", repo.linked());
let total = report.members.len();
for member in report.members.iter().rev() {
let below = match (member.position, member.changed) {
(1, _) => "the bottom, depending on nothing".to_string(),
(_, true) => format!("on {}", report.members[member.position - 2].rkey),
(_, false) => format!("on {} already", report.members[member.position - 2].rkey),
};
println!(
" {}/{total} {} {} — {below}",
member.position, member.rkey, member.title
);
}
if !unchecked.is_empty() {
crate::term::say::note!(
Git,
"the order of {} was taken on trust: no local branch to check it against",
unchecked.join(", "),
);
}
if !unsettled.is_empty() {
crate::term::say::note!(
Index,
"no reachable status settles {}; if one of them is already merged, \
link the rest instead",
unsettled.join(", "),
);
}
}
if !report.changed {
if args.json {
return crate::term::jsonout::emit(&report);
}
println!("already chained in that order; nothing to send");
return Ok(());
}
if args.dry_run {
if args.json {
return crate::term::jsonout::emit(&report);
}
println!("dry run; nothing sent");
return Ok(());
}
let agent = auth::agent_for_did(&me).await?;
let pds = crate::clients::atproto::did::pds_or_fail(&me).await?;
let swap_commit = crate::clients::atproto::pds::latest_commit(&pds, &me).await?;
let mut ops: Vec = Vec::new();
for (member, planned) in members.iter().zip(&report.members) {
if !planned.changed {
continue;
}
ops.push(relink_op(&pds, &me, &member.rkey, planned.dependent_on.as_deref()).await?);
}
// The last thing before anything leaves the machine: read the chain
// these ops would produce, and refuse it if the reader could not.
// `super::refuse_new_damage` owns every rule about a well-formed chain,
// so a verb added later gets them without knowing they exist.
super::refuse_new_damage(&items, &me, &ops, &super::closed_uris(&rows))?;
let written =
crate::clients::atproto::record::batch(&agent, "link", &me, ops, Some(&swap_commit))
.await?;
if args.json {
report.wrote = written;
return crate::term::jsonout::emit(&report);
}
for uri in &written {
println!("wrote {uri}");
}
println!(
"view: {}",
crate::term::hyperlink::url(&format!("{}/pulls", repo.web_url))
);
Ok(())
}
/// The branch a pull says it came from, as a local ref name.
fn source_branch_of(value: &serde_json::Value) -> Option {
value["source"]["branch"]
.as_str()
.filter(|b| !b.is_empty())
.map(str::to_string)
}
// ---------------------------------------------------------------------------
// `stack merge`
// ---------------------------------------------------------------------------
pub(super) const MERGE_NSID: &str = "sh.tangled.repo.merge";
pub(super) const MERGE_CHECK_NSID: &str = "sh.tangled.repo.mergeCheck";
#[derive(clap::Args, Debug)]
pub(crate) struct MergeArgs {
/// Merge only positions 1..=N, counted from the bottom
#[arg(long, value_name = "N")]
pub through: Option,
/// Git remote pointing at the repo
#[arg(long, default_value = "origin")]
pub remote: String,
/// Run the knot's merge check and stop; nothing is merged
#[arg(long)]
pub dry_run: bool,
/// Print one JSON object describing what was written (or, with
/// --dry-run, what would be) instead of the summary lines
#[arg(long)]
pub json: bool,
}
/// What `stack merge` did, or would have done.
///
/// A dry run reaches the knot's `mergeCheck` and stops, so `merged: false`
/// there means "the knot says this applies cleanly" rather than "nothing
/// happened" — a conflict is an error and never gets this far.
#[derive(serde::Serialize, Debug, PartialEq)]
pub(super) struct MergedJson {
pub dry_run: bool,
/// Whether the knot actually moved the branch. `false` on a dry run.
pub merged: bool,
pub repo_did: String,
pub target_branch: String,
/// The host that performed the merge — a git operation, not a record
/// write, and the one part of this that no PDS could answer for.
pub knot: String,
/// The pulls that landed, or would, bottom first. Already-merged
/// members contribute nothing and are not listed.
pub pulls: Vec,
/// How many members the chain has in total, against which `pulls` is
/// the subset `--through` and the members' states selected.
pub total: usize,
pub url: String,
}
/// Everything one knot merge needs, however it was assembled. Shared with
/// `pr merge`, whose plan is a stack of one.
pub(in crate::cmd) struct MergePlan {
pub knot: String,
/// The account making the call: it signs the knot's service-auth token,
/// and the merged status records land in *its* PDS. Not necessarily the
/// repo's owner — see [`repo_facts`].
pub actor_did: String,
/// Who the merge commit is authored by, which Tangled makes the pull's
/// author rather than whoever pressed the button. Only read for a patch
/// that is not a mailbox: `git am` carries its own authorship.
pub author_did: String,
/// How the repo's owner names it, when this account can find that out.
/// `None` costs nothing on a current knot and is only a problem on one
/// old enough to route by owner and name — see [`RepoOwner`].
pub owner: Option,
pub repo_did: String,
pub target_branch: String,
/// Combined patch, bottom first — the text the knot applies as one
/// merge, exactly as Tangled's own stacked merge sends it.
pub patch: String,
pub title: String,
pub body: Option,
/// The pulls this merge lands, bottom first, to be marked merged.
pub pulls: Vec,
}
/// A repo as its owner names it: the pair `sh.tangled.repo.merge` takes in
/// `did` and `name`.
///
/// Those two fields are the *legacy* spelling. A knot advertising the
/// `repo-did-input` capability routes by `repo` — the repo's own DID — and
/// ignores both, which is why a merge no longer needs them and why not
/// having them is no longer a refusal.
pub(in crate::cmd) struct RepoOwner {
pub did: String,
pub name: String,
}
/// Where a repo lives, and who owns it if this account can tell.
pub(in crate::cmd) struct RepoFacts {
pub knot: String,
pub owner: Option,
}
/// The knot behind a repo DID, and the owner's name for it when that is
/// discoverable from here.
///
/// This used to be `own_repo_facts`, and reading it out of the acting
/// account's own `sh.tangled.repo` records doubled as an ownership check:
/// no record, no merge. That check was atgc's invention. A knot authorizes
/// `sh.tangled.repo.merge` with `IsPushAllowed`, so every collaborator with
/// push access may merge — as they can on tangled.org, which sends the merge
/// under the *logged-in* account and not the owner's. Refusing here meant
/// answering a question only the knot can answer, in the negative, without
/// asking it.
///
/// So the owner's record is now a source of two optional fields rather than
/// a gate, and the fact that must be right — which host to call — falls back
/// to the repo's own DID document, which names its knot to anyone.
pub(in crate::cmd) async fn repo_facts(pds: &str, did: &str, repo_did: &str) -> Result {
let (records, _truncated) = crate::clients::atproto::pds::list_records_all(
pds,
did,
crate::lexicon::tangled::REPO_NSID,
|page| {
page.iter()
.any(|r| r["value"]["repoDid"].as_str() == Some(repo_did))
},
)
.await?;
for record in &records {
if record["value"]["repoDid"].as_str() != Some(repo_did) {
continue;
}
let knot = record["value"]["knot"].as_str().unwrap_or_default();
let name = record["value"]["name"]
.as_str()
.or_else(|| record["uri"].as_str().and_then(|u| u.rsplit('/').next()))
.unwrap_or_default();
if knot.is_empty() || name.is_empty() {
bail!("the repo record for {repo_did} names no knot or no name");
}
return Ok(RepoFacts {
knot: knot.to_string(),
owner: Some(RepoOwner {
did: did.to_string(),
name: name.to_string(),
}),
});
}
crate::logging::debug::log(format!(
"no sh.tangled.repo record for {repo_did} in {did}'s PDS; \
asking the repo's DID document which knot holds it"
));
let Some(knot) = crate::clients::atproto::did::knot_from_did_doc(repo_did).await else {
bail!(
"cannot tell which knot holds {repo_did}: this account has no \
sh.tangled.repo record for it, and its DID document names no knot\n\
a repo created before knot v1.13 has no DID of its own and no document \
to name one; merge it from the owner's account or on tangled.org"
);
};
Ok(RepoFacts { knot, owner: None })
}
/// A record's latest round reduced to its patch text — public reads from
/// the author's PDS, bounded the way `pr diff` bounds them. `label` names
/// the pull in failures.
pub(in crate::cmd) async fn latest_round_patch(
pds: &str,
did: &str,
value: &serde_json::Value,
label: &str,
) -> Result {
// Where the bytes are is [`crate::model::pull::latest_patch`]'s to say;
// fetching them is this function's. The reading used to be here, and it
// knew only the `rounds[]` shape — so a pre-rounds record, which `pr
// view` has always read, was refused by every `stack` path with "has no
// rounds".
let cid = match crate::model::pull::latest_patch(value, label)? {
crate::model::pull::PatchBytes::Inline(patch) => return Ok(patch),
crate::model::pull::PatchBytes::Blob { cid, size } => {
if size > crate::cmd::pr::review::DEFAULT_MAX_BYTES {
bail!(
"{label}'s latest patch is {size} bytes compressed, over the {}-byte limit",
crate::cmd::pr::review::DEFAULT_MAX_BYTES
);
}
cid
}
};
let bytes = crate::clients::atproto::pds::blob_bounded(
pds,
did,
&cid,
crate::cmd::pr::review::DEFAULT_MAX_BYTES,
)
.await
.map_err(|e| anyhow::anyhow!("could not read {label}'s latest patch: {e}"))?;
crate::cmd::pr::review::decompress(&bytes, crate::cmd::pr::review::DEFAULT_MAX_BYTES)
}
/// The verdict a `mergeCheck` response carries, read strictly rather than
/// guessed at. This is the interesting half of `run_merge`'s knot call — the
/// other half is the socket — and it is separated because the gate on the
/// destructive call that follows must read a verdict that is actually there.
/// `unwrap_or(false)` here once turned a truncated body, an HTML error page
/// from a proxy, or a renamed field into "clean", and then merged; the
/// function's caller records that this happened for real. `Ok(None)` means
/// clean; `Ok(Some(lines))` carries the conflict detail already formatted
/// for the `bail!` that names the branch; `Err` means the knot did not
/// answer with a verdict at all.
fn interpret_merge_check(check: &serde_json::Value) -> Result> {
let Some(conflicted) = check["is_conflicted"].as_bool() else {
bail!(
"the knot's merge check answered without an is_conflicted verdict\n\
refusing to merge on a non-answer (--debug shows the response body)"
);
};
if !conflicted {
return Ok(None);
}
let mut lines = String::new();
if let Some(conflicts) = check["conflicts"].as_array() {
for c in conflicts {
lines.push_str(&format!(
" {} {}\n",
c["filename"].as_str().unwrap_or("?"),
c["reason"].as_str().unwrap_or(""),
));
}
}
// A conflict with no files in it is how the knot spells some refusals —
// an empty patch among them — so the message it did send is the only
// lead worth printing.
if lines.is_empty() {
lines = format!(
" (no file details from the knot{})\n",
check["message"]
.as_str()
.or(check["error"].as_str())
.map(|m| format!(": {m}"))
.unwrap_or_default()
);
}
Ok(Some(lines))
}
/// Add `did` and `name` to a knot input when they are known.
///
/// The pair a knot without the `repo-did-input` capability reads *in place
/// of* `repo`, per the `sh.tangled.repo.merge` lexicon. A current knot
/// ignores them, so they are sent when this account happens to hold the
/// owner's record and left out otherwise, rather than being something a
/// merge has to establish first.
fn name_the_owner(input: &mut serde_json::Value, plan: &MergePlan) {
let Some(owner) = &plan.owner else {
return;
};
input["did"] = serde_json::json!(owner.did);
input["name"] = serde_json::json!(owner.name);
}
/// Say what to do about a knot's refusal, for the one refusal that has an
/// answer worth printing.
///
/// `AccessControl` is the knot saying this account is not on the repo's push
/// list. That is now reachable — atgc no longer decides for itself who may
/// merge — and it is worth distinguishing from the other reason a merge
/// stops, because the fix is somebody else's to make.
fn explain_refusal(error: anyhow::Error, plan: &MergePlan) -> anyhow::Error {
let Some(refusal) = error.downcast_ref::() else {
return error;
};
if refusal.tag != "AccessControl" {
return error;
}
let owner = match &plan.owner {
Some(owner) => format!("{}'s to give", owner.did),
None => "the repo owner's to give".to_string(),
};
crate::exit::fail(
crate::exit::Exit::Denied,
format!(
"{error}\n\
{} authorizes a merge by push access, and this account has none on \
{}. Push access is {owner}, as a collaborator on the repo, which is \
not the same as the SSH key `atgc key add` registers",
plan.knot, plan.repo_did,
),
)
}
/// Whether the knot composed this refusal itself, rather than stopping.
///
/// The rule is [`crate::clients::tangled::knot::decided`]; this is only the
/// unwrapping, and it has to run *before* [`explain_refusal`], which replaces
/// the error with one carrying no `Refused` to find.
fn knot_decided(error: &anyhow::Error) -> bool {
error
.downcast_ref::()
.is_some_and(|r| crate::clients::tangled::knot::decided(&r.tag, r.status))
}
/// Whose patch a merge is landing: the author of the chain's bottom member.
///
/// Every member of a chain atgc can write is the same account's, so any
/// member answers — and the bottom is the one whose commits sit first in the
/// combined patch. Falls back to the acting account only when the chain
/// carries no readable at-uri at all, which is a shape the walk that built it
/// would already have refused.
fn bottom_author(chain: &super::Chain<'_>) -> Option {
chain
.members
.first()
.and_then(|m| m["uri"].as_str())
.and_then(crate::model::record::authority_of)
.map(str::to_string)
}
/// Say when a recorded mark is cutting nothing.
///
/// The cut it was recorded to make is not the cut being made, and nothing
/// else says so at the moment it matters: `stack mark` reports a stranded
/// mark only when somebody asks it to, and the commands that act on the cut
/// are the ones changing shape because of it.
fn warn_stranded_marks(stranded: &[String]) {
if stranded.is_empty() {
return;
}
crate::term::say::warning!(
Git,
"{} recorded mark(s) sit outside this range and are cutting nothing: {}\n\
the members below are cut without them. `atgc stack rebase` carries marks and a \
plain `git rebase` does not; `atgc stack mark` shows where each one is, and \
`--forget ` drops one",
stranded.len(),
stranded.join(", "),
);
}
/// Say so when a failed merge call may have moved the branch anyway.
///
/// **"Retry later" is the wrong advice for a call whose outcome is
/// unknown**, and it is what [`crate::exit::Exit::Unreachable`] means. A
/// knot that *refuses* — 401 from the push-access guard, 403 from the
/// collaborator handler — has done nothing, and retrying after fixing the
/// access is exactly right. A knot that dies mid-call, or times out, or
/// answers 502, has either merged the patch or not, and nothing in the
/// answer says which. Reporting the two the same way tells somebody to
/// retry a merge that may already be sitting on the target branch.
///
/// The distinction is the tag: a refusal atgc can name is one the knot
/// composed on purpose, which means it got far enough to compose it. Anything
/// else — no tag, a 5xx, a transport error that never became a `Refused` at
/// all — leaves the question open, and the answer is to look before retrying.
fn unknown_outcome(plan: &MergePlan) -> String {
format!(
"this may have merged anyway: {} did not answer, and a merge that reached it \
moves {} whether or not the answer came back. Check the branch before \
retrying — nothing here recorded these pulls as merged",
plan.knot, plan.target_branch,
)
}
/// Check, merge, and record: one `mergeCheck`, one `merge`, then a merged
/// status per pull. The status records are what the rest of the pull
/// machinery reads — the knot changed the branch, and without them every
/// listing would keep calling these pulls open.
pub(in crate::cmd) async fn run_merge(
agent: &jacquard::client::Agent,
plan: &MergePlan,
dry_run: bool,
) -> Result<()> {
use crate::lexicon::tangled::{PULL_STATUS_NSID, PullState};
use tangled_lexicon::sh_tangled::repo::pull::status::{Status as PullStatus, StatusStatus};
let mut check_input = serde_json::json!({
"branch": plan.target_branch,
"patch": plan.patch,
"repo": plan.repo_did,
});
name_the_owner(&mut check_input, plan);
let check =
crate::clients::tangled::knot::xrpc(agent, &plan.knot, MERGE_CHECK_NSID, &check_input)
.await?;
if let Some(lines) = interpret_merge_check(&check)? {
bail!(
"the knot reports this merge would conflict with {}:\n{lines}\
rebase the branch, `stack resubmit`, and try again",
plan.target_branch,
);
}
// The three lines below are this function's whole human output. Under
// `--json` the caller prints one object covering all of it, so they are
// suppressed here rather than duplicated there — and this is what the
// `jsonout` global is for: `run_merge` is shared by two commands and has
// neither's arguments.
let quiet = crate::term::jsonout::active();
if !quiet {
println!(
"check: clean against {} on {}",
plan.target_branch, plan.knot
);
}
if dry_run {
if !quiet {
println!("dry run; nothing merged");
}
return Ok(());
}
let who = crate::clients::atproto::did::Identity::fetch(&plan.author_did).await;
let author_name = who
.handle
.map(|h| format!("@{h}"))
.unwrap_or_else(|| plan.author_did.clone());
let mut merge_input = serde_json::json!({
"branch": plan.target_branch,
"patch": plan.patch,
"repo": plan.repo_did,
"commitMessage": plan.title,
"authorName": author_name,
"authorEmail": plan.author_did,
});
name_the_owner(&mut merge_input, plan);
if let Some(body) = &plan.body {
merge_input["commitBody"] = serde_json::json!(body);
}
crate::clients::tangled::knot::xrpc(agent, &plan.knot, MERGE_NSID, &merge_input)
.await
// Decided *before* `explain_refusal`, which rebuilds the error into
// an `exit::fail` and so is the last moment the knot's own `Refused`
// is still downcastable. Reversing these two put the ambiguity
// warning on a 401 that the knot had plainly decided.
.map_err(|e| {
let decided = knot_decided(&e);
let e = explain_refusal(e, plan);
match decided {
true => e,
false => e.context(unknown_outcome(plan)),
}
})?;
if !quiet {
println!(
"merged {} pull(s) into {}",
plan.pulls.len(),
plan.target_branch
);
}
// The knot's branch moved; now say so in records — one batch, one
// commit, where a sequential loop could die partway and leave some
// landed pulls reading open with no single message saying which.
let mut ticker = Ticker::new();
let mut ops: Vec = Vec::new();
for uri in &plan.pulls {
let record: PullStatus = PullStatus {
pull: AtUri::new(uri.clone().into())?,
status: StatusStatus::from_value(PullState::Merged.token().into()),
created_at: Datetime::now(),
extra_data: None,
};
let rkey = ticker.next(None).to_string();
ops.push(Op::Create {
nsid: PULL_STATUS_NSID,
rkey: Key::any_owned(&rkey).map_err(|e| anyhow::anyhow!("bad TID {rkey}: {e}"))?,
value: serde_json::to_value(&record)?,
});
}
if let Err(e) =
crate::clients::atproto::record::batch(agent, "merged status", &plan.actor_did, ops, None)
.await
{
bail!(
"the knot merged the patch, but writing the merged statuses failed: {e}\n\
these are merged on the branch and still read open in listings:\n {}\n\
mark them in Tangled's web UI, or retry once the PDS is reachable",
plan.pulls.join("\n ")
);
}
if !quiet {
for uri in &plan.pulls {
println!("status merged -> {uri}");
}
}
Ok(())
}
/// Which of the bottom `through` members of a chain a merge actually
/// combines, decided from state alone: a `"merged"` row already sits on the
/// target branch and contributes nothing, `"open"` is what a merge combines,
/// and anything else is refused rather than silently dropped or silently
/// folded in. This is the choice that decides exactly which subset of a
/// stack becomes the single most consequential write in this file, so it is
/// separated from the per-member patch fetch that follows a selection —
/// that half needs a live PDS read and stays where it is. `Ok` carries the
/// indexes to include, in order; `Err` carries the index of the first row
/// whose state refuses, leaving the caller to name that member in its own
/// words.
fn select_merge_range(states: &[&str], through: usize) -> Result, usize> {
let mut selected = Vec::new();
for (index, state) in states.iter().enumerate().take(through) {
match *state {
"merged" => {}
"open" => selected.push(index),
_ => return Err(index),
}
}
Ok(selected)
}
/// Merge the current branch's stack — the whole of it, or `--through` a
/// position counted from the bottom — as one combined patch, the way
/// Tangled itself lands a stack.
pub(in crate::cmd) async fn merge(args: MergeArgs) -> Result<()> {
crate::term::jsonout::init(args.json);
let selection = crate::config::account::select().await?;
selection.announce();
let me = selection.did.clone();
let branch = git::current_branch()?;
let remote_url = git::remote_url(&args.remote)?;
let repo = resolve::repo_ref(&remote_url).await?;
// The shared preamble again, with merge's own words: this path ends at
// the knot, where a wrong bottom is merged history.
let rows = super::complete_rows(listing::Source::EVERY, &repo.did, "a merge").await?;
let items: Vec<&serde_json::Value> = rows.iter().map(|r| &r.item).collect();
// `Whose::Anyones`: a merge writes no member record, only
// its own status records, so the repo owner landing a contributor's
// stack is exactly the case the help promises and `Whose::Mine` refuses.
let chain = super::chain_for_branch(
&items,
&branch,
&me,
&super::closed_uris(&rows),
listing::Source::EVERY,
super::Whose::Anyones,
"nothing to merge",
"`atgc pr merge` lands a single pull",
)?;
let total = chain.members.len();
let through = args.through.unwrap_or(total);
if through == 0 || through > total {
// A number the command line got wrong, which is `Usage` — the same
// answer clap gives for a value outside a declared range, and not
// the unclassified `1` that reads as "something went wrong, try
// again". Nothing here will succeed on a retry.
return Err(crate::exit::fail(
crate::exit::Exit::Usage,
format!(
"--through {through} is out of range; the stack has {total} pulls (1 = bottom)"
),
));
}
let pds = crate::clients::atproto::did::pds_or_fail(&me).await?;
let facts = repo_facts(&pds, &me, &repo.did).await?;
// Bottom through `through`: merged members contribute nothing (their
// commits are already on the target), anything not cleanly open is
// refused, and the rest are reduced to their latest round's patch.
let states: Vec = chain
.members
.iter()
.map(|m| super::state_of(&rows, m["uri"].as_str().unwrap_or_default()))
.collect();
let state_refs: Vec<&str> = states.iter().map(String::as_str).collect();
let selected = match select_merge_range(&state_refs, through) {
Ok(selected) => selected,
Err(index) => {
let member = &chain.members[index];
let uri = member["uri"].as_str().unwrap_or_default();
let title = member["value"]["title"].as_str().unwrap_or("(untitled)");
let state = &states[index];
bail!("{title} ({uri}) is {state}; a stack merges bottom-up through open pulls only");
}
};
// Every state here is "open": `selected` is exactly the members the
// refusal above let through, and that is what it checks.
let read = read_members(
selected
.iter()
.map(|&index| (chain.members[index], "open".to_string())),
)
.await?;
let landing: Vec = selected
.iter()
.map(|&index| {
chain.members[index]["uri"]
.as_str()
.unwrap_or_default()
.to_string()
})
.collect();
let patches: Vec = read.into_iter().map(|old| old.latest_patch).collect();
if landing.is_empty() {
bail!("everything at or below position {through} is already merged; nothing to do");
}
let top = &chain.members[through - 1];
let target_branch = crate::model::pull::target_branch_of_row(top)?;
let plan = MergePlan {
knot: facts.knot,
actor_did: me.clone(),
// **The pulls' author, not whoever is merging.** The merge commit
// belongs to the person whose patch it is, which is what Tangled's
// own merge sends and what `pr merge` has always done.
//
// This was `me`, and was right only for as long as `own_chain`
// refused anybody else's stack. Letting the repo owner merge a
// contributor's stack made it wrong in a way no record shows and no
// test here looked at: git attribution is permanent, and the
// contributor's work would have landed under the maintainer's name.
author_did: bottom_author(&chain).unwrap_or_else(|| me.clone()),
owner: facts.owner,
repo_did: repo.did.clone(),
target_branch,
patch: patches.join("\n"),
title: top["value"]["title"]
.as_str()
.unwrap_or("(untitled)")
.to_string(),
body: top["value"]["body"].as_str().map(str::to_string),
pulls: landing,
};
if !args.json {
println!(
"merge: {} of {total} pull(s), bottom first, into {} as one commit series",
plan.pulls.len(),
plan.target_branch
);
for uri in &plan.pulls {
println!(" {uri}");
}
}
let agent = auth::agent_for_did(&me).await?;
run_merge(&agent, &plan, args.dry_run).await?;
// What to do next is advice about this checkout, not a fact about the
// merge, so it is a note under --json and a line of the report otherwise.
let next = format!(
"next: git fetch {r} && git rebase {r}/{t}, then `atgc stack resubmit` \
reconciles the survivors above the merge",
r = args.remote,
t = plan.target_branch,
);
if args.json {
crate::term::jsonout::emit(&MergedJson {
dry_run: args.dry_run,
merged: !args.dry_run,
repo_did: plan.repo_did.clone(),
target_branch: plan.target_branch.clone(),
knot: plan.knot.clone(),
pulls: plan.pulls.clone(),
total,
url: format!("{}/pulls", repo.web_url),
})?;
if !args.dry_run {
crate::term::say::note!(Git, "{next}");
}
return Ok(());
}
if !args.dry_run {
crate::term::say::note!(Git, "{next}");
}
Ok(())
}
/// One member's record, fresh from the PDS at write time — the listing that
/// planned the reconcile is not the copy to mutate.
async fn fetch_member(pds: &str, did: &str, rkey: &str) -> Result<(Pull, Option)> {
let (value, cid) = crate::clients::atproto::pds::get_record(pds, did, PULL_NSID, rkey).await?;
let pull: Pull = serde_json::from_value(value)
.map_err(|e| anyhow::anyhow!("pull {rkey} does not parse as a stacked pull: {e}"))?;
Ok((pull, cid))
}
#[cfg(test)]
mod tests {
use super::{
OldMember, Plan, Planned, Slot, change_id_header, interpret_merge_check, reconcile,
select_merge_range, two_column_listing, with_change_id_header,
};
/// Pinned so a refactor of the four `bail!` sites this feeds can't
/// silently reflow the listing: two spaces, the two columns, two
/// spaces between them, one row per line, in the order given.
#[test]
fn listing_renders_two_indented_columns_in_order() {
let rendered =
two_column_listing([("abc1234", "first commit"), ("def5678", "second commit")]);
assert_eq!(
rendered,
" abc1234 first commit\n def5678 second commit\n"
);
}
/// No rows is the empty string, not a stray newline — every caller
/// checks emptiness itself before building the listing, but the helper
/// should not need that guard to behave.
#[test]
fn listing_of_nothing_is_empty() {
let rendered = two_column_listing(std::iter::empty::<(&str, &str)>());
assert_eq!(rendered, "");
}
/// The five fates a reconcile can hand a member, as one word each and
/// with the identifiers each fate actually has: an `add` has no record
/// key yet, and nothing but an `update` or an `add` names a commit.
/// `rounds` is what the record will hold afterwards, which is the field
/// a caller would otherwise have to derive from the action.
#[test]
fn every_reconcile_fate_reports_its_own_word_and_identifiers() {
let old = vec![
member("3aaa", "Ione", "patch one", "open", None),
member("3bbb", "Itwo", "patch two", "merged", None),
];
let planned = vec![Planned {
shas: vec!["abc1234def".to_string()],
change_ids: vec!["Ione".to_string()],
subject: "the commit".to_string(),
body: None,
patch: "patch one changed".to_string(),
images: Ok(crate::cmd::images::Images::none()),
}];
let update = super::reconciled_member_json(
1,
&Slot::Update {
index: 0,
member: 0,
},
&old,
&planned,
);
assert_eq!(update.action, "update");
assert_eq!(update.rkey.as_deref(), Some("3aaa"));
assert_eq!(update.sha.as_deref(), Some("abc1234def"));
assert_eq!(update.rounds, 2, "a round is appended");
let relink = super::reconciled_member_json(
1,
&Slot::Keep {
member: 0,
relink: true,
},
&old,
&planned,
);
assert_eq!(relink.action, "relink");
assert_eq!(relink.sha, None, "no commit touches a relinked member");
assert_eq!(relink.rounds, 1);
let keep = super::reconciled_member_json(
1,
&Slot::Keep {
member: 0,
relink: false,
},
&old,
&planned,
);
assert_eq!(keep.action, "keep");
let frozen = super::reconciled_member_json(2, &Slot::Frozen { member: 1 }, &old, &planned);
assert_eq!(frozen.action, "merged");
assert_eq!(frozen.rkey.as_deref(), Some("3bbb"));
let add = super::reconciled_member_json(3, &Slot::Add { index: 0 }, &old, &planned);
assert_eq!(add.action, "add");
assert_eq!(add.rkey, None, "the PDS mints the key for a new record");
assert_eq!(add.title, "the commit");
assert_eq!(add.rounds, 1);
}
fn member(rkey: &str, id: &str, patch: &str, state: &str, dep: Option<&str>) -> OldMember {
OldMember {
uri: format!("at://did:plc:me/sh.tangled.repo.pull/{rkey}"),
rkey: rkey.to_string(),
title: format!("pull {rkey}"),
state: state.to_string(),
change_ids: id
.split(' ')
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect(),
latest_patch: patch.to_string(),
dependent_on: dep.map(|d| format!("at://did:plc:me/sh.tangled.repo.pull/{d}")),
body: None,
rounds: 1,
}
}
/// The whole point of predicting CIDs: the patch takes its final,
/// comparable text at planning time — message rewritten, diff bytes
/// sacred.
#[test]
fn prediction_finalizes_the_message_and_never_touches_the_diff() {
let _repo = crate::testutil::TempRepo::new("predict-patch");
std::fs::write("logo.svg", "").unwrap();
let body = "With proof: ";
let images = Ok(crate::cmd::images::scan(body).expect("scans"));
let patch = "From abc Mon Sep 17 00:00:00 2001\n\
Subject: [PATCH] feat: x\n\n\
With proof: \n\
---\n \
logo.svg | 1 +\n\
diff --git a/logo.svg b/logo.svg\n\
+ the diff may mention  and must keep it\n";
let (new_body, new_patch) = super::predict_member_rewrites(
"did:plc:me",
Some(body.to_string()),
patch.to_string(),
&images,
)
.expect("rewrites");
assert!(new_body.unwrap().contains("blob+at://did:plc:me/bafkrei"));
let scissors = new_patch.find("\n---\n").expect("still a patch");
assert!(
new_patch[..scissors].contains("blob+at://did:plc:me/bafkrei"),
"message carries the prediction"
);
assert!(
new_patch[scissors..].contains(""),
"diff bytes are untouched"
);
}
/// A planned member. `ids` is space-separated for a member of several
/// commits — `commit("Ia Ib", …)` is the two-commit member the ordinary
/// `commit("Ia", …)` is the one-commit case of.
fn commit(ids: &str, patch: &str) -> Planned {
let change_ids: Vec = ids.split(' ').map(str::to_string).collect();
Planned {
shas: change_ids.iter().map(|id| format!("{id:0<40}")).collect(),
change_ids,
subject: format!("commit {ids}"),
body: None,
patch: patch.to_string(),
images: Ok(crate::cmd::images::Images::none()),
}
}
fn plan(old: &[OldMember], new: &[Planned], prune: bool) -> Plan {
reconcile(old, new, prune).expect("plans")
}
/// Marks cut the range: a mark *ends* a member, and the top member runs
/// to HEAD however far that is. An unmarked range is one member per
/// commit, which is every stack written before marks existed.
#[test]
fn marks_end_the_members() {
let marks = [(1usize, "part1".to_string())];
let cut = super::cut_at_marks(4, &marks);
assert_eq!(cut.groups, vec![vec![0, 1], vec![2, 3]]);
assert_eq!(cut.labels, vec![Some("part1".to_string()), None]);
// Two marks, three members, and a mark on the last commit does not
// mint an empty member above it.
let marks = [(0usize, "one".to_string()), (3usize, "three".to_string())];
let cut = super::cut_at_marks(4, &marks);
assert_eq!(cut.groups, vec![vec![0], vec![1, 2, 3]]);
let bare = super::cut_at_marks(4, &[]);
assert_eq!(bare.groups, super::one_group_per_commit(4));
assert!(bare.labels.iter().all(Option::is_none));
}
/// A member of several commits gets a header per commit, and a member of
/// one comes out exactly as it always did — which is what keeps an old
/// stack's bytes comparing equal on its next reconcile.
#[test]
fn every_message_of_a_members_mailbox_carries_its_own_change_id() {
let one = "From aaa Mon Sep 17 00:00:00 2001\nSubject: [PATCH 1/2] a\n\nbody a\n";
let two = "From bbb Mon Sep 17 00:00:00 2001\nSubject: [PATCH 2/2] b\n\nbody b\n";
let mailbox = format!("{one}{two}");
assert_eq!(
crate::clients::git::patch::message_offsets(&mailbox),
vec![0, one.len()]
);
let ids = ["Ia".to_string(), "Ib".to_string()];
let out = super::with_change_id_headers(&mailbox, &ids).expect("injects");
assert_eq!(out.matches("Change-Id: ").count(), 2, "{out}");
assert_eq!(super::change_id_headers(&out), ids, "{out}");
// One commit, one header, byte for byte what the single-commit path
// produced before members could hold more.
let solo = super::with_change_id_headers(one, &ids[..1]).expect("injects");
assert_eq!(solo, with_change_id_header(one, "Ia").expect("injects"));
// A count that does not match the mailbox is refused rather than
// guessed at: a header on the wrong commit is a member that answers
// to somebody else's change-id.
let err = super::with_change_id_headers(&mailbox, &ids[..1])
.unwrap_err()
.to_string();
assert!(err.contains("2 message(s) but 1 commit(s)"), "{err}");
}
/// The grouping lives in the records: a member owning two change-ids
/// claims the run its commits span, and an unclaimed commit inside that
/// span joins it rather than splintering the member.
#[test]
fn the_existing_records_say_where_the_cuts_are() {
let old = [
member("a", "Ia Ib", "pa", "open", None),
member("c", "Ic", "pc", "open", Some("a")),
];
// Branch order: Ia, (a new commit), Ib, Ic. The new one is inside
// member a's span, so it joins it.
let commits = ["Ia", "Inew", "Ib", "Ic"].map(String::from);
let ids = |sha: &str| Some(sha.to_string());
// groups_from_members reads change-ids off real commits, so the
// shape is asserted through the pure part: spans over these ids.
let groups =
super::groups_from_ids(&commits.iter().map(|c| ids(c)).collect::>(), &old)
.expect("groups");
assert_eq!(groups, vec![vec![0, 1, 2], vec![3]]);
}
/// Two members whose commits are interleaved cannot both be a run, and
/// that is said rather than silently re-cut.
#[test]
fn interleaved_members_are_refused() {
let old = [
member("a", "Ia Ib", "pa", "open", None),
member("c", "Ic", "pc", "open", Some("a")),
];
let ids: Vec> = ["Ia", "Ic", "Ib"]
.iter()
.map(|s| Some(s.to_string()))
.collect();
let err = super::groups_from_ids(&ids, &old).unwrap_err().to_string();
assert!(err.contains("interleaved"), "{err}");
}
/// The no-op that makes a rerun safe: same ids, same bytes, same order
/// — nothing to send, and in particular no round appended per pull the
/// way a naive port of Tangled's resubmit would.
#[test]
fn an_unchanged_stack_reconciles_to_nothing() {
let old = [
member("a", "Ia", "pa", "open", None),
member("b", "Ib", "pb", "open", Some("a")),
];
let new = [commit("Ia", "pa"), commit("Ib", "pb")];
let p = plan(&old, &new, false);
assert_eq!(
p.chain,
[
Slot::Keep {
member: 0,
relink: false
},
Slot::Keep {
member: 1,
relink: false
},
]
);
assert!(p.drops.is_empty());
assert!(p.anchor.is_none());
}
/// An amend mid-stack: the amended commit and everything above it carry
/// new bytes and get rounds; the untouched bottom does not.
#[test]
fn an_amend_appends_rounds_only_where_bytes_changed() {
let old = [
member("a", "Ia", "pa", "open", None),
member("b", "Ib", "pb", "open", Some("a")),
member("c", "Ic", "pc", "open", Some("b")),
];
let new = [
commit("Ia", "pa"),
commit("Ib", "pb-amended"),
commit("Ic", "pc-rebased"),
];
let p = plan(&old, &new, false);
assert_eq!(
p.chain,
[
Slot::Keep {
member: 0,
relink: false
},
Slot::Update {
index: 1,
member: 1
},
Slot::Update {
index: 2,
member: 2
},
]
);
}
/// A reorder with identical bytes is a relink: records update, no
/// rounds. (In practice reordered commits change bytes too — the sha in
/// the From line moves — and become Updates; this pins the pure case.)
#[test]
fn a_reorder_relinks_without_rounds() {
let old = [
member("a", "Ia", "pa", "open", None),
member("b", "Ib", "pb", "open", Some("a")),
];
let new = [commit("Ib", "pb"), commit("Ia", "pa")];
let p = plan(&old, &new, false);
assert_eq!(
p.chain,
[
Slot::Keep {
member: 1,
relink: true
},
Slot::Keep {
member: 0,
relink: true
},
]
);
}
/// A new commit mid-stack: an Add, and the member above it relinks to
/// the minted record even though its own bytes did not move.
#[test]
fn an_inserted_commit_adds_and_relinks_its_successor() {
let old = [
member("a", "Ia", "pa", "open", None),
member("b", "Ib", "pb", "open", Some("a")),
];
let new = [
commit("Ia", "pa"),
commit("Inew", "pnew"),
commit("Ib", "pb"),
];
let p = plan(&old, &new, false);
assert_eq!(
p.chain,
[
Slot::Keep {
member: 0,
relink: false
},
Slot::Add { index: 1 },
Slot::Keep {
member: 1,
relink: true
},
]
);
}
/// The bottom merged and the branch rebased past it: the merged member
/// is the anchor, never a drop, and the surviving bottom keeps pointing
/// at it without so much as a relink.
#[test]
fn a_merged_bottom_becomes_the_anchor() {
let old = [
member("a", "Ia", "pa", "merged", None),
member("b", "Ib", "pb", "open", Some("a")),
];
let new = [commit("Ib", "pb")];
let p = plan(&old, &new, false);
assert_eq!(p.anchor, Some(0));
assert!(p.drops.is_empty());
assert_eq!(
p.chain,
[Slot::Keep {
member: 1,
relink: false
}]
);
}
/// A live pull whose commit vanished needs --prune; without it the
/// whole reconcile refuses, because relinking around it would leave a
/// fork the appview rejects.
#[test]
fn a_dropped_commit_needs_prune() {
let old = [
member("a", "Ia", "pa", "open", None),
member("b", "Ib", "pb", "open", Some("a")),
];
let new = [commit("Ib", "pb")];
let err = reconcile(&old, &new, false).unwrap_err().to_string();
assert!(err.contains("--prune"), "{err}");
assert!(err.contains("pull a"), "{err}");
let p = plan(&old, &new, true);
assert_eq!(p.drops, [0]);
assert_eq!(
p.chain,
[Slot::Keep {
member: 1,
relink: true
}]
);
}
/// A *closed* pull whose commit vanished needs no flag and loses no
/// record: closing it already said it was out of the stack, so the
/// reconcile routes the chain past it and leaves it alone. Deleting it
/// would take the comment explaining the close with it, which is the one
/// outcome someone who closed a pull deliberately cannot want.
#[test]
fn a_closed_member_is_retired_not_deleted() {
let old = [
member("a", "Ia", "pa", "closed", None),
member("b", "Ib", "pb", "open", Some("a")),
];
let new = [commit("Ib", "pb")];
let p = plan(&old, &new, false);
assert_eq!(p.retired, [0]);
assert!(p.drops.is_empty());
assert_eq!(
p.anchor, None,
"a closed pull never landed, so it anchors nothing"
);
assert_eq!(
p.chain,
[Slot::Keep {
member: 1,
relink: true
}]
);
}
/// The shape that forced a fresh PR before this: a closed member in the
/// *middle* of a chain. The member above it relinks down to the one
/// below, so the stack closes up with one write and no deletion.
#[test]
fn a_closed_member_in_the_middle_relinks_the_chain_around_it() {
let old = [
member("a", "Ia", "pa", "open", None),
member("b", "Ib", "pb", "closed", Some("a")),
member("c", "Ic", "pc", "open", Some("b")),
];
let new = [commit("Ia", "pa"), commit("Ic", "pc")];
let p = plan(&old, &new, false);
assert_eq!(p.retired, [1]);
assert!(p.drops.is_empty());
assert_eq!(
p.chain,
[
Slot::Keep {
member: 0,
relink: false
},
Slot::Keep {
member: 2,
relink: true
},
]
);
}
/// Unknown state blocks every destructive path: a vanished member that
/// might be merged, and an update to a member that might be merged.
#[test]
fn unknown_state_refuses_destructive_fates() {
let old = [member("a", "Ia", "pa", "?", None)];
let err = reconcile(&old, &[commit("Ix", "px")], true)
.unwrap_err()
.to_string();
assert!(err.contains("cannot be settled"), "{err}");
let err = reconcile(&old, &[commit("Ia", "pa-changed")], false)
.unwrap_err()
.to_string();
assert!(err.contains("cannot be settled"), "{err}");
// Identical bytes and unmoved order touch nothing, so unknown state
// is tolerable there.
let p = plan(&old, &[commit("Ia", "pa")], false);
assert_eq!(
p.chain,
[Slot::Keep {
member: 0,
relink: false
}]
);
}
/// A merged member matched by an unchanged commit holds its place,
/// frozen; matched by a changed one, the reconcile refuses — a merged
/// pull is never updated.
#[test]
fn a_merged_member_is_frozen_or_the_reconcile_refuses() {
let old = [
member("a", "Ia", "pa", "merged", None),
member("b", "Ib", "pb", "open", Some("a")),
];
let new = [commit("Ia", "pa"), commit("Ib", "pb")];
let p = plan(&old, &new, false);
assert_eq!(
p.chain,
[
Slot::Frozen { member: 0 },
Slot::Keep {
member: 1,
relink: false
}
]
);
let err = reconcile(
&old,
&[commit("Ia", "pa-edited"), commit("Ib", "pb")],
false,
)
.unwrap_err()
.to_string();
assert!(err.contains("merged"), "{err}");
}
/// The no-op guarantee must survive the formatter: `git format-patch`
/// signs its output with the git version, so raw byte equality read
/// "changed" after a git upgrade, from another machine, or against a
/// round the web appended — and a "no-op" rerun appended a round per
/// pull.
#[test]
fn the_signature_block_does_not_count_as_change() {
use super::comparable_patch;
let v1 = "From x\nSubject: [PATCH] a\n\ndiff --git a/f b/f\n+x\n-- \n2.43.0\n";
let v2 = "From x\nSubject: [PATCH] a\n\ndiff --git a/f b/f\n+x\n-- \n2.53.0\n";
assert_eq!(comparable_patch(v1), comparable_patch(v2));
let old = [
member("a", "Ia", v1, "open", None),
member("b", "Ib", "pb", "open", Some("a")),
];
let new = [commit("Ia", v2), commit("Ib", "pb")];
let p = plan(&old, &new, false);
assert_eq!(
p.chain,
[
Slot::Keep {
member: 0,
relink: false
},
Slot::Keep {
member: 1,
relink: false
},
],
"a formatter delta is not a new round"
);
// A real content delta still counts.
let changed = "From x\nSubject: [PATCH] a\n\ndiff --git a/f b/f\n+y\n-- \n2.43.0\n";
let p = plan(&old, &[commit("Ia", changed), commit("Ib", "pb")], false);
assert_eq!(
p.chain[0],
Slot::Update {
index: 0,
member: 0
}
);
}
/// The empty-commit refusal: the knot refuses to merge an empty patch
/// (its merge check answers "conflicted" with no files), so a stack
/// carrying one is refused at the door. `patch_has_diff` is what tells
/// an empty commit's patch — headers, no diff — from a real one.
#[test]
fn empty_patches_are_refused_by_name() {
use super::{patch_has_diff, refuse_empty_patches};
let full = commit(
"Ia",
"From x\nSubject: [PATCH] a\n\ndiff --git a/f b/f\n+x\n",
);
let mut empty = commit("Ib", "From y\nSubject: [PATCH] b\n\n---\n2.53.0\n");
empty.shas = vec!["beefbeefbeef".into()];
empty.subject = "Empty: docs to follow".into();
assert!(patch_has_diff(&full.patch));
assert!(!patch_has_diff(&empty.patch));
assert!(refuse_empty_patches(std::slice::from_ref(&full)).is_ok());
let err = refuse_empty_patches(&[full, empty])
.unwrap_err()
.to_string();
assert!(err.contains("beefbee"), "{err}");
assert!(err.contains("Empty: docs to follow"), "{err}");
assert!(err.contains("empty patch"), "{err}");
}
/// One identity per record and per commit, or matching means nothing —
/// and a cherry-pick keeps the trailer, so the duplicate-commit case is
/// an ordinary user accident, not sabotage.
#[test]
fn duplicate_change_ids_are_refused_on_both_sides() {
let old = [
member("a", "Idup", "pa", "open", None),
member("b", "Idup", "pb", "open", Some("a")),
];
let err = reconcile(&old, &[commit("Idup", "pa")], false)
.unwrap_err()
.to_string();
assert!(err.contains("both answer to change-id Idup"), "{err}");
let old = [member("a", "Ia", "pa", "open", None)];
let err = reconcile(&old, &[commit("Ia", "pa"), commit("Ia", "px")], false)
.unwrap_err()
.to_string();
assert!(err.contains("carry the change-id Ia"), "{err}");
}
/// A member whose latest round has no Change-Id header cannot be
/// matched to anything and the reconcile says whose fault that is.
#[test]
fn a_member_with_no_change_id_is_refused_by_name() {
let old = [member("a", "", "pa", "open", None)];
let err = reconcile(&old, &[commit("Ia", "pa")], false)
.unwrap_err()
.to_string();
assert!(err.contains("pull a"), "{err}");
assert!(err.contains("no Change-Id header"), "{err}");
}
/// The pull body is the commit body minus the Change-Id trailer, found
/// live: the first stacked pull whose commit had no body beyond its
/// added trailer went up with `Change-Id: I…` as its whole description.
#[test]
fn pull_bodies_do_not_carry_the_change_id_trailer() {
use super::strip_change_id_trailers as strip;
// A trailer-only body is no body.
assert_eq!(strip("Change-Id: Iabc\n"), "");
// Prose stays; the trailer paragraph goes.
assert_eq!(strip("Real prose.\n\nChange-Id: Iabc\n"), "Real prose.");
// Other trailers in the block survive; only Change-Id leaves.
assert_eq!(
strip("Prose.\n\nSigned-off-by: A \nChange-Id: Iabc\n"),
"Prose.\n\nSigned-off-by: A "
);
// A Change-Id quoted mid-body is prose, not bookkeeping.
assert_eq!(
strip("See Change-Id: Iabc for context.\n\nMore prose.\n"),
"See Change-Id: Iabc for context.\n\nMore prose."
);
// No trailer at all: untouched.
assert_eq!(strip("Just a body.\n"), "Just a body.");
// CRLF bodies split on their own paragraph breaks; before the
// normalization the whole body read as one trailer block, and this
// first paragraph — prose that happens to open with the words
// Change-Id — was deleted along with the real trailer.
assert_eq!(
strip("Change-Id: Iref, quoted in prose.\r\n\r\nChange-Id: Iabc\r\n"),
"Change-Id: Iref, quoted in prose."
);
}
/// The header parse the whole reconcile keys on.
#[test]
fn reads_the_change_id_header_and_only_the_header() {
let patch = "From abc Mon Sep 17 00:00:00 2001\nSubject: [PATCH] x\n\
Change-Id: Ifromheader\n\n\
body mentions Change-Id: Ifrombody\n---\n";
assert_eq!(change_id_header(patch).as_deref(), Some("Ifromheader"));
assert_eq!(change_id_header("Subject: x\n\nbody\n"), None);
}
const PATCH: &str = "From abc123 Mon Sep 17 00:00:00 2001\n\
From: A U Thor \n\
Date: Sat, 9 Aug 2026 12:00:00 +0000\n\
Subject: [PATCH] Add b\n\
\n\
---\n b.txt | 1 +\n";
/// The header goes into the mail header block — before the first blank
/// line — because that is the only place the appview's parser reads it.
#[test]
fn injects_the_header_into_the_header_block() {
let out = with_change_id_header(PATCH, "Iabcdef").unwrap();
let headers = out.split("\n\n").next().unwrap();
assert!(headers.contains("\nChange-Id: Iabcdef"), "{out}");
assert!(headers.contains("Subject: [PATCH] Add b"), "{out}");
// The body is byte-identical.
assert_eq!(out.split("\n\n").nth(1), PATCH.split("\n\n").nth(1));
}
/// Idempotent: a patch that already carries the header keeps exactly
/// one, whatever id a re-run would have used.
#[test]
fn does_not_double_an_existing_header() {
let once = with_change_id_header(PATCH, "Iabcdef").unwrap();
let twice = with_change_id_header(&once, "Iother").unwrap();
assert_eq!(once, twice);
assert_eq!(twice.matches("Change-Id:").count(), 1);
}
/// A body mentioning "Change-Id:" — a commit message quoting one, say —
/// must not suppress the real header.
#[test]
fn a_change_id_in_the_body_is_not_a_header() {
let patch = "From abc Mon Sep 17 00:00:00 2001\nSubject: [PATCH] x\n\n\
quoting Change-Id: Iquoted here\n---\n";
let out = with_change_id_header(patch, "Ireal").unwrap();
let headers = out.split("\n\n").next().unwrap();
assert!(headers.contains("Change-Id: Ireal"), "{out}");
}
/// Garbage in, error out — not a patch with a header spliced somewhere.
#[test]
fn refuses_a_patch_with_no_header_block() {
let err = with_change_id_header("not a patch", "I1").unwrap_err();
assert!(err.to_string().contains("malformed"), "{err}");
}
/// No verdict at all — a truncated body, or an HTML error page from a
/// proxy that never got near the knot — must refuse rather than default
/// to clean. This is the exact failure mode `unwrap_or(false)` hid once.
#[test]
fn a_merge_check_with_no_verdict_refuses_rather_than_defaulting_clean() {
let err = interpret_merge_check(&serde_json::json!({})).unwrap_err();
assert!(err.to_string().contains("is_conflicted"), "{err}");
}
/// The clean answer: no conflict lines, nothing to bail on.
#[test]
fn a_clean_merge_check_carries_no_conflict_lines() {
let verdict = interpret_merge_check(&serde_json::json!({"is_conflicted": false})).unwrap();
assert_eq!(verdict, None);
}
/// A conflicted verdict with files: each conflict becomes its own
/// indented line, filename then reason.
#[test]
fn a_conflicted_merge_check_lists_each_file_and_reason() {
let verdict = interpret_merge_check(&serde_json::json!({
"is_conflicted": true,
"conflicts": [
{"filename": "a.rs", "reason": "both modified"},
{"filename": "b.rs", "reason": "deleted in HEAD"},
],
}))
.unwrap()
.expect("conflicted");
assert_eq!(verdict, " a.rs both modified\n b.rs deleted in HEAD\n");
}
/// A conflicted verdict with no file details — how the knot spells some
/// refusals, an empty patch among them — still gives the caller a line
/// to print rather than nothing at all.
#[test]
fn a_conflicted_merge_check_with_no_files_falls_back_to_a_placeholder_line() {
let verdict = interpret_merge_check(&serde_json::json!({"is_conflicted": true}))
.unwrap()
.expect("conflicted");
assert_eq!(verdict, " (no file details from the knot)\n");
}
/// The one place a wrong guess is unrecoverable: a record naming a real
/// branch reads back exactly that branch.
#[test]
fn target_branch_of_reads_the_named_branch() {
let member = serde_json::json!({"value": {"target": {"branch": "release/1.0"}}});
assert_eq!(
crate::model::pull::target_branch_of_row(&member).unwrap(),
"release/1.0"
);
}
/// An empty string is not a branch name; refuse rather than land on "".
#[test]
fn target_branch_of_refuses_an_empty_branch_field() {
let member = serde_json::json!({"uri": "at://did:plc:me/sh.tangled.repo.pull/abc", "value": {"target": {"branch": ""}}});
let err = crate::model::pull::target_branch_of_row(&member).unwrap_err();
assert_eq!(
err.to_string(),
"at://did:plc:me/sh.tangled.repo.pull/abc names no target branch; \
refusing to guess where this stack lands"
);
}
/// No `target` at all — the old default-to-"main" path this comment
/// warns about — refuses by the same message, naming the member when it
/// can and falling back to a generic label when it cannot.
#[test]
fn target_branch_of_refuses_a_missing_branch_field() {
let member = serde_json::json!({});
let err = crate::model::pull::target_branch_of_row(&member).unwrap_err();
assert_eq!(
err.to_string(),
"a stack member names no target branch; refusing to guess where this stack lands"
);
}
/// The ordinary case: some merged rows skipped, the rest — all open —
/// selected in order.
#[test]
fn select_merge_range_skips_merged_and_keeps_open_in_order() {
let states = ["merged", "open", "open"];
assert_eq!(select_merge_range(&states, 3), Ok(vec![1, 2]));
}
/// `through` bounds how far up the walk goes; rows above it are never
/// looked at, merged or not.
#[test]
fn select_merge_range_stops_at_through() {
let states = ["open", "open", "closed"];
assert_eq!(select_merge_range(&states, 2), Ok(vec![0, 1]));
}
/// All merged, nothing above `through` to disqualify it: an empty
/// selection, not an error — the caller decides that an empty selection
/// means nothing to do.
#[test]
fn select_merge_range_of_an_all_merged_prefix_is_empty_not_an_error() {
let states = ["merged", "merged"];
assert_eq!(select_merge_range(&states, 2), Ok(vec![]));
}
/// Anything that is not "merged" or "open" — closed, unknown, a stale
/// index reading "?" — refuses at the first offending index rather than
/// silently skipping or silently including it.
#[test]
fn select_merge_range_refuses_at_the_first_non_open_non_merged_row() {
let states = ["merged", "open", "closed", "open"];
assert_eq!(select_merge_range(&states, 4), Err(2));
}
/// The refusal only looks within `through`; a bad row past the cutoff
/// does not stop a merge that never reaches it.
#[test]
fn select_merge_range_does_not_see_a_bad_row_past_through() {
let states = ["open", "merged", "closed"];
assert_eq!(select_merge_range(&states, 2), Ok(vec![0]));
}
/// A patch fixture in the shape `format_patch_one` emits, with the
/// `Change-Id` header `with_change_id_header` injects.
fn patch_with_message(message: &str) -> String {
format!(
"From cff78b1 Mon Sep 17 00:00:00 2001\n\
From: A U Thor \n\
Date: Wed, 19 Aug 2026 14:25:41 -0400\n\
Subject: [PATCH] feat: a thing\n\
Change-Id: Ithing\n\
\n\
{message}---\n \
f | 1 +\n"
)
}
/// The body a patch generates is `%b` with the trailer off — the same
/// text `subject_and_body` hands `stack create`, which is the whole
/// point: the two have to agree or every stored body reads as edited.
#[test]
fn a_patch_yields_the_body_its_commit_message_would() {
assert_eq!(
super::body_of_patch(&patch_with_message(
"Why this change.\n\nAnd a second paragraph.\n\nChange-Id: Ithing\n"
)),
Some("Why this change.\n\nAnd a second paragraph.".to_string())
);
// A commit whose message is a subject and a trailer has no body,
// exactly as `strip_change_id_trailers` decides for the commit.
assert_eq!(
super::body_of_patch(&patch_with_message("Change-Id: Ithing\n")),
None
);
// No message at all: the header block runs straight into the
// scissors.
assert_eq!(super::body_of_patch(&patch_with_message("")), None);
// Not a patch: no verdict, which the caller reads as "not
// generated from this" and so keeps what is stored.
assert_eq!(super::body_of_patch("nothing mail-shaped here"), None);
}
/// The comparison a round turns on: a description nobody has written
/// over is regenerated from the commit, and one somebody has is not.
#[test]
fn only_an_untouched_body_still_belongs_to_its_commit() {
let patch = patch_with_message("Why this change.\n\nChange-Id: Ithing\n");
let mut m = member("aaa", "Ithing", &patch, "open", None);
m.body = Some("Why this change.".to_string());
assert!(m.body_is_its_commits(), "the body the last round generated");
m.body = Some("Why this change.\n\n".to_string());
assert!(!m.body_is_its_commits(), "a body with a screenshot added");
m.body = None;
assert!(!m.body_is_its_commits(), "a body someone cleared");
// A commit with no message body and a record with no body agree,
// so an amended message still reaches a pull that never had one.
let bare = patch_with_message("Change-Id: Ithing\n");
let mut m = member("bbb", "Ithing", &bare, "open", None);
assert!(m.body_is_its_commits(), "neither side has a body");
// `pr edit --body ''` leaves the field present and empty; that is
// the same nothing, not an edit to preserve.
m.body = Some(" ".to_string());
assert!(m.body_is_its_commits(), "an empty body is no body");
}
}