Skip to main content

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(&registry, &request.repo)?;
196    let (rules, _source) = crate::policy::load(&registry)?;
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(&registry, &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(&registry, 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}