onevcs/stack_base.rs
1//! Assembling a synthetic stack base: a branch cut from a root that carries a merge
2//! of several parent branches, put on its origin with no change request.
3//!
4//! A change that depends on two earlier changes still in review can be drafted
5//! against neither alone: against the root it lacks their work, and against one of
6//! them it shows the other's diff as its own. A stack base is the branch it *is*
7//! drafted against — the root, with each parent merged into it in a declared order —
8//! and this module is the one thing that writes and pushes one. Owning that here keeps
9//! every push a remote sees inside the crate that already guarantees base history is
10//! never rewritten, so an engine can rebuild the base after every round of review
11//! without a second, unguarded path to the remote.
12//!
13//! **Every move is forward.** A first assembly cuts the branch from the root and
14//! merges each parent; a later one merges the root's new head first — work that
15//! landed there since, a parent's squash among it — and then each parent's head the
16//! branch does not already carry. Every merge is a merge commit, never a fast-forward,
17//! a rebase or a force: a child drafted against the stack base can always merge it
18//! forward, because nothing it was based on is ever taken out of its history. A parent
19//! named by an earlier call and not by this one is neither merged again nor unmerged.
20//!
21//! **Every merge commit carries a `Stack-Base` trailer** naming what it merged and
22//! where that stood (`provenance::stack_base_trailer`), under the rules file's prefix,
23//! which is where `status` reads a stack base's parents back from. What makes a branch
24//! a stack base is not that trailer but its name ([`is_stack_base`]): a change cut from
25//! a stack base inherits every one of its merges, and one cut at its head shares that
26//! head, and each is still ordinary work — and a host that never saw the base assembled
27//! holds no record of it, yet reads the same ref the same way.
28//!
29//! **Nothing here is a publication.** No change request is opened, and the push skips
30//! the repository's own `pre-push` hook (`git::push_preserving`): a stack base merges
31//! only branches each verified on its own change request, and the merge path is what
32//! verifies *those*. What it still passes is the public boundary, which every write to
33//! a remote passes before it is made.
34//!
35//! **A conflict is the session's to resolve.** The work happens in a session opened on
36//! the branch, and a merge that conflicts is left in progress there exactly as
37//! `session open` leaves a continuation's — the same [`OpenConflict`], the same session
38//! — and answered as [`StackBaseOutcome::Conflicted`]. Once that merge is concluded,
39//! the same request merges what is left and pushes.
40
41use std::collections::BTreeMap;
42use std::path::{Path, PathBuf};
43
44use serde::{Deserialize, Serialize};
45use serde_json::json;
46
47use crate::boundary::TermScope;
48use crate::error::{self, Error, Result};
49use crate::event::EventKind;
50use crate::git::{self, Integrated};
51use crate::guidance;
52use crate::host::Sha;
53use crate::provenance::{self, Trailers};
54use crate::providers::Providers;
55use crate::registry::Registry;
56use crate::session::{OpenConflict, SessionRequest, SessionToken};
57use crate::store::{self, Resolution};
58use crate::stream::Stream;
59use crate::workspace::{self, object, BaseMerge, Opening};
60
61/// What to assemble, and from what.
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct StackBaseRequest {
64 // llmlint: ignore-block[invalid_states_unrepresentable] the contract this crate's
65 // `docs/contract.md` states for the stack base fixes every field here as the text a
66 // caller typed — three other plan nodes restate it — and each is held to what it is
67 // where `assemble_stack_base` reads it: `repo` by `store::resolve`, which is the one
68 // place the four spellings of a repository are told apart, every name by git's own
69 // ref-name grammar, the parents' count and distinctness before anything is fetched,
70 // and the labels by the session open that stamps them.
71 /// The identity (normalized origin) or a registered checkout path.
72 pub repo: String,
73 /// The full branch name, e.g. `"<host prefix>stack-base/<plan>/<node>"`.
74 pub branch: String,
75 /// The root base the branch is cut from, e.g. `"main"`.
76 pub root: String,
77 /// The parent branches, merged in this order; at least two.
78 pub parents: Vec<String>,
79 /// Session labels, as `session open --label` takes them.
80 #[serde(default)]
81 pub labels: BTreeMap<String, String>,
82 // llmlint: ignore-end[invalid_states_unrepresentable]
83}
84
85/// One parent of a stack base, and the head this assembly merged it at or found the
86/// branch already carrying.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88pub struct StackParent {
89 // llmlint: ignore[invalid_states_unrepresentable] the stack-base amendment in
90 // `docs/contract.md` fixes this field as `String`, and three plan nodes restate that
91 // shape, so a branch-name newtype here would change an interface this crate may not
92 // change alone. Every value it carries is one `assemble` already held to git's
93 // ref-name grammar, or one `status` read back through `Ref`'s own conversion.
94 /// The parent branch.
95 pub branch: String,
96 /// The commit the origin had it at when this assembly read it.
97 pub head: Sha,
98}
99
100/// What one assembly of a stack base did.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct StackBase {
103 // llmlint: ignore[invalid_states_unrepresentable] fixed as `String` by the same
104 // amendment, for the same reason as `StackParent::branch`; the value is the request's
105 // own branch, which `assemble` refuses unless git's ref-name grammar accepts it.
106 /// The stack base branch.
107 pub branch: String,
108 /// Where the branch stands: what was pushed, what the origin already had, or —
109 /// for [`StackBaseOutcome::Conflicted`] — the commit the merge left in progress
110 /// is being made on.
111 pub head: Sha,
112 /// Every parent the request named, in its order, with the head it was read at.
113 pub parents: Vec<StackParent>,
114 /// Which of the four things this assembly found to do.
115 pub outcome: StackBaseOutcome,
116}
117
118/// What one assembly of a stack base found to do.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(tag = "kind", rename_all = "kebab-case")]
121#[non_exhaustive]
122pub enum StackBaseOutcome {
123 /// The branch did not exist: it was cut from the root, each parent merged in
124 /// order, and pushed as a new ref.
125 Created,
126 /// The branch existed and something it did not carry was merged — the root's
127 /// head, a parent's — and pushed forward-only.
128 Advanced,
129 /// The branch already carried the root's head and every parent's, and the origin
130 /// already had it. Nothing was merged and nothing was pushed.
131 Unchanged,
132 /// A merge conflicted and is left in progress in `session`'s worktree, for the
133 /// session to conclude; nothing was pushed. The same request, made again once the
134 /// merge is committed, merges what is left and pushes.
135 Conflicted {
136 /// The session holding the merge.
137 session: SessionToken,
138 /// The merge it holds.
139 conflict: OpenConflict,
140 },
141}
142
143impl StackBaseOutcome {
144 /// The word this outcome travels as in a `stack-base-assembled` payload.
145 ///
146 /// An exhaustive match for the reason `EventKind::wire` is one: stamping an event
147 /// cannot fail, and an outcome added here cannot reach a stream unnamed.
148 fn wire(&self) -> &'static str {
149 match self {
150 StackBaseOutcome::Created => "created",
151 StackBaseOutcome::Advanced => "advanced",
152 StackBaseOutcome::Unchanged => "unchanged",
153 StackBaseOutcome::Conflicted { .. } => "conflicted",
154 }
155 }
156}
157
158/// The remote a stack base is read from and pushed to, which is the only one it knows.
159const ORIGIN: &str = "origin";
160
161/// The path segment that opens the stack-base namespace: a stack base is named
162/// `<host prefix>stack-base/<plan>/<node>`.
163const NAMESPACE: &str = "stack-base";
164
165/// Whether `branch` is a stack base, decided from its name and nothing else.
166///
167/// A name is one where a `stack-base` segment is followed by at least two more — the
168/// plan and the node — whatever host prefix stands before it. Never from commit content,
169/// ancestry, a shared head or this host's record of an assembly: a change cut from a
170/// stack base inherits every one of its merges and one cut at its head shares that
171/// head, and a host that never saw the base assembled holds no record of it, so each of
172/// those would classify one ref two ways. `assemble` refuses a branch this does not
173/// accept, so every branch the verb writes is one this answers `true` for.
174pub(crate) fn is_stack_base(branch: &str) -> bool {
175 let segments: Vec<&str> = branch.split('/').collect();
176 segments
177 .iter()
178 .position(|segment| *segment == NAMESPACE)
179 .is_some_and(|at| {
180 let rest = &segments[at + 1..];
181 rest.len() >= 2 && rest.iter().all(|segment| !segment.is_empty())
182 })
183}
184
185/// Assemble, advance, or confirm one stack base, and push what moved.
186///
187/// The library form of `onevcs stack-base`, and the whole of the verb. A failure —
188/// a refused request, a ref the origin does not have, a boundary refusal, a push the
189/// origin declines — returns its error, emits nothing, and leaves the origin as it
190/// was; a conflict is not a failure, and answers
191/// [`Conflicted`](StackBaseOutcome::Conflicted).
192pub fn assemble(providers: &Providers<'_>, request: &StackBaseRequest) -> Result<StackBase> {
193 validate(request)?;
194 let registry = store::load()?;
195 let resolution = store::resolve(®istry, &request.repo)?;
196 let (rules, _source) = crate::policy::load(®istry)?;
197 let trailers = provenance::from_rules(&rules);
198 // Every name the assembly merges has to be on the origin before a session is cut
199 // for it, so a request naming a branch nobody pushed is refused with nothing
200 // opened, merged or handed back.
201 let execution = workspace::execution_checkout(®istry, &resolution, None)?;
202 if git::has_remote(&execution, ORIGIN) {
203 git::fetch(&execution, ORIGIN)?;
204 }
205 for name in std::iter::once(&request.root).chain(&request.parents) {
206 if git::tip(&execution, &tracking(name)).is_none() {
207 return Err(not_on_origin(&resolution, name, &request.root));
208 }
209 }
210
211 let opening = open(®istry, request)?;
212 let token = opening.record.token.to_string();
213 match assemble_in(providers, request, &resolution, &trailers, opening) {
214 Ok(assembled) => Ok(assembled),
215 Err(Failed {
216 error,
217 session,
218 rewind,
219 }) => {
220 // Nothing was pushed, so nothing reached a remote, and the branch is put
221 // back where this call found it before the session hands it back: a merge
222 // this call made and could not push — one the public boundary refused among
223 // them — must not become the copy the next call continues. The session is
224 // then let go rather than left holding a run root nobody is coming back to,
225 // unless what it holds is somebody's resolution, which a close would abort.
226 // A close that fails is said beside the error rather than in place of it.
227 if let Some((worktree, start)) = &rewind {
228 git::abort_merge(worktree);
229 if let Err(rewinding) = git::reset_hard(worktree, start) {
230 eprintln!(
231 "onevcs: stack base session {token} could not be put back at {start}: \
232 {rewinding}"
233 );
234 }
235 }
236 if session == Session::Close {
237 if let Err(closing) = workspace::close(&token) {
238 eprintln!("onevcs: stack base session {token} could not be closed: {closing}");
239 }
240 }
241 Err(error)
242 }
243 }
244}
245
246/// An assembly that failed once its session was open: the error, whether the session
247/// is let go, and — once this call began merging — the worktree and the commit its
248/// branch is put back at.
249struct Failed {
250 error: Error,
251 session: Session,
252 rewind: Option<(PathBuf, String)>,
253}
254
255/// What becomes of the session of an assembly that failed.
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257enum Session {
258 /// Closed: it holds nothing but what this call merged, which its branch keeps.
259 Close,
260 /// Left open: it holds a merge somebody resolved and has not committed.
261 Keep,
262}
263
264/// Everything after the session is open, saying with an error whether the caller
265/// closes the session it was made in.
266fn assemble_in(
267 providers: &Providers<'_>,
268 request: &StackBaseRequest,
269 resolution: &Resolution,
270 trailers: &Trailers,
271 opening: Opening,
272) -> std::result::Result<StackBase, Failed> {
273 let Opening {
274 record,
275 mut stream,
276 conflict,
277 cut,
278 } = opening;
279 let worktree = record.worktree.clone();
280 let clone = record.clone.clone();
281 let branch = request.branch.as_str();
282 let fail = |error: Error| {
283 Err(Failed {
284 error,
285 session: Session::Close,
286 rewind: None,
287 })
288 };
289
290 // Every head is read where the origin was just fetched — the execution checkout,
291 // whose objects the session's clone borrows — rather than off the clone's own
292 // remote-tracking refs, which begin as that checkout's local branches.
293 let execution = &record.execution_checkout;
294 let read = |name: &str| -> Result<String> {
295 git::tip(execution, &tracking(name))
296 .ok_or_else(|| not_on_origin(resolution, name, &request.root))
297 };
298 let heads = match request
299 .parents
300 .iter()
301 .map(|parent| read(parent).map(|head| (parent.clone(), head)))
302 .collect::<Result<Vec<(String, String)>>>()
303 {
304 Ok(heads) => heads,
305 Err(error) => return fail(error),
306 };
307 let parents: Vec<StackParent> = heads
308 .iter()
309 .map(|(branch, head)| StackParent {
310 branch: branch.clone(),
311 head: Sha(head.clone()),
312 })
313 .collect();
314
315 // A session this request resumed may still be part way through the merge an
316 // earlier call left for it.
317 if let Some(conflict) = conflict {
318 return Ok(conflicted(
319 &mut stream,
320 request,
321 &record.token,
322 parents,
323 conflict,
324 ));
325 }
326 match git::merge_in_progress(&worktree) {
327 Ok(None) => {}
328 Ok(Some(_)) => {
329 return Err(Failed {
330 error: Error::SyncConflict {
331 reason: format!(
332 "session {token} holds the merge stack base {branch:?} stopped on with \
333 every conflict staged and nothing committed. Conclude it with `{commit}` \
334 and run this again",
335 token = record.token,
336 commit = guidance::command([
337 "git",
338 "-C",
339 &worktree.to_string_lossy(),
340 "commit",
341 "--no-edit",
342 ]),
343 ),
344 },
345 session: Session::Keep,
346 rewind: None,
347 })
348 }
349 Err(error) => return fail(error),
350 }
351
352 // Where this call found the branch, which a failure from here on puts it back at.
353 let start = match git::head_sha(&worktree) {
354 Ok(start) => start,
355 Err(error) => return fail(error),
356 };
357 let undo = |error: Error| {
358 Err(Failed {
359 error,
360 session: Session::Close,
361 rewind: Some((worktree.clone(), start.clone())),
362 })
363 };
364
365 // The root first, so whatever landed there since — a parent's squash among it —
366 // reaches the base before any parent is merged over it; then each parent in the
367 // request's order.
368 let root = match read(&request.root) {
369 Ok(root) => root,
370 Err(error) => return fail(error),
371 };
372 let merges = std::iter::once((request.root.clone(), root.clone())).chain(heads);
373 for (name, head) in merges {
374 match merge(&worktree, branch, &name, &head, trailers) {
375 Ok(None) => {}
376 Ok(Some(conflict)) => {
377 return Ok(conflicted(
378 &mut stream,
379 request,
380 &record.token,
381 parents,
382 conflict,
383 ));
384 }
385 Err(error) => return undo(error),
386 }
387 }
388
389 let head = match git::head_sha(&worktree) {
390 Ok(head) => head,
391 Err(error) => return undo(error),
392 };
393 // Read where the heads were, for the same reason: in the clone, a copy this host
394 // merely holds would read as the origin's.
395 let on_origin = git::tip(execution, &tracking(branch));
396 // Absent is a branch nothing carried work on: one this open cut, or a copy an
397 // earlier failed call put back at a root this branch had not moved past, which is
398 // still no stack base anybody can have drafted against.
399 let absent = match (cut, &on_origin) {
400 (true, _) => true,
401 (false, Some(_)) => false,
402 (false, None) => match git::is_ancestor(&worktree, &start, &root) {
403 Ok(behind) => behind,
404 Err(error) => return undo(error),
405 },
406 };
407 let outcome = if on_origin.as_deref() == Some(head.as_str()) {
408 StackBaseOutcome::Unchanged
409 } else {
410 if let Err(error) = push(providers, resolution, &clone, request) {
411 return undo(error);
412 }
413 match absent {
414 true => StackBaseOutcome::Created,
415 false => StackBaseOutcome::Advanced,
416 }
417 };
418
419 // The base is on its origin by now, so letting the session go is bookkeeping: a
420 // close that fails is said, never turned into the failure of an assembly whose
421 // push the origin already took.
422 if let Err(closing) = workspace::close(&record.token) {
423 eprintln!(
424 "onevcs: stack base {branch:?} was assembled, and its session {token} could not be \
425 closed: {closing}",
426 token = record.token
427 );
428 }
429 let assembled = StackBase {
430 branch: branch.to_owned(),
431 head: Sha(head),
432 parents,
433 outcome,
434 };
435 record_assembly(&mut stream, request, &assembled);
436 Ok(assembled)
437}
438
439/// The answer, and the record, for a merge left in progress.
440fn conflicted(
441 stream: &mut Stream,
442 request: &StackBaseRequest,
443 token: &workspace::Token,
444 parents: Vec<StackParent>,
445 conflict: OpenConflict,
446) -> StackBase {
447 let assembled = StackBase {
448 branch: request.branch.clone(),
449 head: Sha(conflict.branch_tip.clone()),
450 parents,
451 outcome: StackBaseOutcome::Conflicted {
452 session: SessionToken(token.to_string()),
453 conflict,
454 },
455 };
456 record_assembly(stream, request, &assembled);
457 assembled
458}
459
460/// Refuse what no assembly could honour, before anything is fetched or opened.
461fn validate(request: &StackBaseRequest) -> Result<()> {
462 if request.parents.len() < 2 {
463 return Err(Error::Invalid {
464 reason: format!(
465 "a stack base merges at least two parents and {count} {named} named, so nothing \
466 was assembled or pushed. With one unlanded parent, base the change on that \
467 parent's branch; with none, on {root:?}",
468 count = request.parents.len(),
469 named = match request.parents.len() {
470 1 => "was",
471 _ => "were",
472 },
473 root = request.root,
474 ),
475 });
476 }
477 for name in std::iter::once(&request.branch)
478 .chain(std::iter::once(&request.root))
479 .chain(&request.parents)
480 {
481 if !git::is_valid_branch_name(name) {
482 return Err(error::invalid(format!(
483 "{name:?} is not a valid branch name, so nothing was assembled or pushed"
484 )));
485 }
486 }
487 if !is_stack_base(&request.branch) {
488 return Err(error::invalid(format!(
489 "{branch:?} is not in the stack-base namespace, so nothing was assembled or \
490 pushed; name the branch `<host prefix>{NAMESPACE}/<plan>/<node>`, which is how \
491 every host recognises a stack base and offers it no landing",
492 branch = request.branch,
493 )));
494 }
495 if request.branch == request.root || request.parents.contains(&request.branch) {
496 return Err(error::invalid(format!(
497 "stack base {branch:?} is also named as its own root or parent, so it would be \
498 merged into itself and nothing was assembled or pushed; name the stack base's own \
499 branch with --branch and what it merges with --root and --parent",
500 branch = request.branch,
501 )));
502 }
503 if request.parents.contains(&request.root) {
504 return Err(error::invalid(format!(
505 "{root:?} is named as both the root and a parent, so nothing was assembled or \
506 pushed; the root is merged first on its own, so name only the branches above it \
507 with --parent",
508 root = request.root,
509 )));
510 }
511 let mut seen: Vec<&String> = Vec::new();
512 for parent in &request.parents {
513 if seen.contains(&parent) {
514 return Err(error::invalid(format!(
515 "parent {parent:?} is named twice, so nothing was assembled or pushed; each \
516 parent is merged once, in the order named"
517 )));
518 }
519 seen.push(parent);
520 }
521 Ok(())
522}
523
524/// Open the session the assembly is made in, on the branch, over the root.
525///
526/// The open continues the branch where anything carries it — the origin, a checkout,
527/// a run clone — and cuts it from the root where nothing does, through the same
528/// search every session makes; a session already open on it is that session, resumed,
529/// which is how a second call picks up the merge a first one left in progress. What
530/// it does not do is merge the root: [`BaseMerge::Leave`], because that merge is this
531/// module's to write, with the trailer every merge of a stack base carries.
532fn open(registry: &Registry, request: &StackBaseRequest) -> Result<Opening> {
533 workspace::open_with(
534 registry,
535 &SessionRequest {
536 repo: request.repo.clone(),
537 branch: Some(request.branch.clone()),
538 branch_name: None,
539 branch_prefix: None,
540 base: Some(request.root.clone()),
541 execution_checkout: None,
542 pool: None,
543 overflow: None,
544 labels: request.labels.clone(),
545 refuse_conflicts: false,
546 caller_merges_base: false,
547 },
548 BaseMerge::Leave,
549 )
550}
551
552/// Merge `name` at `head` into the branch checked out in `worktree`, unless the branch
553/// already carries it: `None` when it is carried by the end, the conflict when the
554/// merge stopped on one.
555fn merge(
556 worktree: &Path,
557 branch: &str,
558 name: &str,
559 head: &str,
560 trailers: &Trailers,
561) -> Result<Option<OpenConflict>> {
562 if git::is_ancestor(worktree, head, "HEAD")? {
563 return Ok(None);
564 }
565 let before = git::head_sha(worktree)?;
566 let message = format!(
567 "Merge {name} into stack base {branch}\n\n{}",
568 provenance::stack_base_trailer(trailers, name, head)
569 );
570 // The sha rather than the name, so what is merged is exactly what the trailer
571 // says was — a fetch between the read and the merge cannot slip a newer head in.
572 match git::merge_commit_leaving_conflict(worktree, head, &message)? {
573 Integrated::Settled => Ok(None),
574 Integrated::Conflicted(conflict) => Ok(Some(OpenConflict {
575 paths: conflict.paths().to_vec(),
576 base_commit: head.to_owned(),
577 branch_tip: before,
578 })),
579 }
580}
581
582/// Hold the branch to the public boundary, then push it forward-only.
583///
584/// Never forced and never through the merge path: the branch's history only grows,
585/// so the push is a fast-forward of whatever the origin has or the origin refuses it,
586/// and the repository's own `pre-push` hook verifies publications, which this is not.
587fn push(
588 providers: &Providers<'_>,
589 resolution: &Resolution,
590 clone: &Path,
591 request: &StackBaseRequest,
592) -> Result<()> {
593 let branch = request.branch.as_str();
594 let reference = format!("refs/heads/{branch}");
595 let compared = format!("{ORIGIN}/{}", request.root);
596 crate::boundary::evidence::guard_publication(
597 providers.hosting,
598 &resolution.key,
599 &crate::boundary::screen::Outgoing {
600 repo: clone,
601 base: Some(compared.as_str()),
602 tip: &reference,
603 branch: Some(branch),
604 title: None,
605 body: None,
606 },
607 &TermScope::default(),
608 )?;
609 let pushed = git::push_preserving(clone, branch, ORIGIN)?;
610 if pushed.accepted() {
611 return Ok(());
612 }
613 let summary = pushed
614 .refusal()
615 .unwrap_or_else(|| pushed.output().lines().next_back().unwrap_or("").trim());
616 Err(Error::PushRejected {
617 reason: format!(
618 "the origin of {key:?} declined stack base {branch:?}: {summary}. Nothing was \
619 replaced — a stack base is never force-pushed — so a branch somebody else moved \
620 is reported rather than overwritten",
621 key = resolution.key,
622 ),
623 })
624}
625
626/// Record what one assembly did on the session's own stream.
627///
628/// After the work, and only for an assembly that answered: a call that fails records
629/// nothing, because the payload's `outcome` is the whole of what a reader routes on
630/// and there is no word in it for "this did not happen". Emitting cannot fail the
631/// call — a failed write says so on stderr.
632fn record_assembly(stream: &mut Stream, request: &StackBaseRequest, assembled: &StackBase) {
633 let payload = object(json!({
634 "branch": assembled.branch,
635 "root": request.root,
636 "parents": assembled.parents,
637 "head": assembled.head,
638 "outcome": assembled.outcome.wire(),
639 }));
640 let result = if matches!(assembled.outcome, StackBaseOutcome::Conflicted { .. }) {
641 crate::event::EmissionResult::Failed
642 } else {
643 crate::event::EmissionResult::Success
644 };
645 stream.emit_result(EventKind::StackBaseAssembled, payload, Vec::new(), result);
646}
647
648/// The remote-tracking ref a branch of the origin is read through.
649fn tracking(name: &str) -> String {
650 format!("refs/remotes/{ORIGIN}/{name}")
651}
652
653/// Why a name the assembly has to merge is refused: the origin has no such branch.
654fn not_on_origin(resolution: &Resolution, name: &str, root: &str) -> Error {
655 let what = match name == root {
656 true => "root",
657 false => "parent",
658 };
659 error::invalid(format!(
660 "{what} {name:?} is not a branch on the origin of {key:?}, so nothing was assembled or \
661 pushed. A stack base merges only what the origin carries: push {name:?} first, or \
662 name a branch the origin has",
663 key = resolution.key,
664 ))
665}