Skip to main content

onevcs/
integrate.rs

1//! The merge train: landing finished branches on a local base, in order.
2//!
3//! One failure does not block the others. Each candidate merges the current base in
4//! its own worktree and is then **squash-published**: its tree becomes one commit
5//! built in a detached scratch worktree that the base checkout fast-forwards onto.
6//! Squashing rather than fast-forwarding the branch itself is what keeps this verb
7//! on the same base-history contract as publication — a recovered incomplete step
8//! reaches the base as a trailer on that one commit and never as the marker and
9//! attestation commits themselves.
10//!
11//! # What verifies a train
12//!
13//! **The repository's own `pre-push` hook, at the push that publishes the advanced
14//! base** — the same verifier every other landing of a local-first identity
15//! answers to, reached through [`crate::merge_path::comparison_env`] so the hook
16//! judges against the base the train is publishing onto.
17//!
18//! Verification is therefore per *push* and not per candidate, which costs the
19//! finer attribution a refused aggregate used to get. Without `--push` the train
20//! reaches no merge path at all — that base is the operator's own checkout, and
21//! nothing else builds on it until the push the hook rules on.
22//!
23//! An identity whose merge path runs nothing at all
24//! ([`crate::store::Coverage::None`]) is **warned about rather than refused**, in
25//! the same words `onevcs register` uses: a publication of such an identity is not
26//! refused either, and a train that refused where a publication does not would send
27//! an operator to raw `git merge`, which is verified by even less.
28//!
29//! # Which identities may run one
30//!
31//! Exactly those whose **resolved publication policy is `local-direct`** — the one
32//! policy under which a landing is a local merge and a push, which is what the train
33//! performs. The three change-request policies are refused, naming the policy and
34//! the rule that decided it, and each candidate is routed to `publish-branch`. The
35//! gate consults nothing stored about the identity: it used to refuse on two fields
36//! `onevcs register` inferred from whether the origin had a host, which no operator
37//! could change and which every hosted origin therefore failed, so a repository whose
38//! rules resolved `local-direct` was refused the one verb documented for that
39//! condition. The rules file is the routing everywhere else, and now here.
40
41use std::path::{Path, PathBuf};
42
43use serde_json::json;
44
45use crate::boundary::TermScope;
46use crate::error::{Error, Result};
47use crate::event::EventKind;
48use crate::rules::MergePolicy;
49use crate::store::{self, Resolution};
50use crate::stream::Stream;
51use crate::workspace::{object, Ref};
52use crate::{git, guidance, home, ids, lock, merge_path, policy, provenance, publish, queue};
53
54/// What happened to one candidate of the train.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum Status {
57    /// It was verified and landed on the base as one commit.
58    Merged,
59    /// Its content was already on the base, so there was nothing to add.
60    AlreadyMerged,
61    /// It was left where it was, for this reason. A skip always has one: it is the
62    /// only thing that tells a reader which of half a dozen refusals happened.
63    Skipped(String),
64}
65
66impl Status {
67    /// How the train reports it.
68    pub fn describe(&self) -> String {
69        match self {
70            Status::Merged => "merged".to_owned(),
71            Status::AlreadyMerged => "already-merged".to_owned(),
72            Status::Skipped(reason) => format!("skipped ({reason})"),
73        }
74    }
75}
76
77/// What one candidate of the train did.
78#[derive(Debug, Clone)]
79pub struct BranchOutcome {
80    /// The candidate.
81    pub branch: Ref,
82    /// What happened to it.
83    pub status: Status,
84}
85
86/// Where the train left the base.
87///
88/// One value rather than two flags, because a base that never moved cannot have
89/// been pushed: `--push` updates the remote only when the base advanced, and
90/// "pushed an unchanged base" is a state the train has no way to produce.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum Ending {
93    /// Every candidate was skipped or was already on the base.
94    Unchanged,
95    /// At least one candidate landed, and the base was left local.
96    Advanced,
97    /// At least one candidate landed, and the advanced base reached the remote.
98    AdvancedAndPushed,
99}
100
101impl Ending {
102    /// Whether the base moved at all.
103    pub fn advanced(self) -> bool {
104        self != Ending::Unchanged
105    }
106
107    /// Whether the advanced base reached the remote.
108    pub fn pushed(self) -> bool {
109        self == Ending::AdvancedAndPushed
110    }
111}
112
113/// What the whole train did.
114#[derive(Debug, Clone)]
115pub struct Outcome {
116    /// The base the train landed on.
117    pub base: Ref,
118    /// Each candidate, in the order it was offered.
119    pub branches: Vec<BranchOutcome>,
120    /// Where it left the base.
121    pub ending: Ending,
122}
123
124/// Run the train against a registered identity whose rules publish locally.
125pub fn run(
126    resolution: &Resolution,
127    candidates: &[String],
128    push: bool,
129    term_scope: &TermScope,
130    stream: &mut Stream,
131) -> Result<Outcome> {
132    // The rules are read once, here, and what they decide is carried into the
133    // train: a second load would report a spent `gate:` twice, and the policy this
134    // gate rules on has to be the policy the train's provenance vocabulary came
135    // from.
136    let registry = store::load()?;
137    let (file, source) = policy::load(&registry)?;
138    let resolved = policy::resolve_for(&file, &source, resolution);
139    let publication = resolved.policy.publication;
140    if publication != MergePolicy::LocalDirect {
141        return Err(Error::Invalid {
142            reason: format!(
143                "direct integration is refused for identity {:?}: its publication policy \
144                 resolves to {} (from {}; rules: {}), and the train lands only an identity \
145                 whose policy is local-direct. Set publication: local-direct for it there to \
146                 land locally, or publish each branch through its change-request path: {}",
147                resolution.key,
148                policy::spell(publication),
149                resolved.publication_from,
150                resolved.source,
151                change_request_route(resolution, candidates),
152            ),
153        });
154    }
155    let root = &resolution.publication;
156    // Said before the work rather than after it: the train advances a base, and an
157    // operator who learns afterwards that nothing will ever judge what it landed has
158    // already landed it. The same sentence `onevcs register` prints, because it is
159    // the same fact and a second wording would read as a second problem.
160    if store::merge_path_coverage(resolution, root, publication) == store::Coverage::None {
161        eprintln!(
162            "onevcs: warning: nothing on this identity's merge path runs a gate, so what this \
163             train lands is unproven. Install an executable pre-push hook in {}, or confirm \
164             what covers it with `{}`",
165            root.display(),
166            guidance::command(["onevcs", "repos", "--audit-gates"]),
167        );
168    }
169    let base = git::current_branch(root)?;
170    if git::is_dirty(root)? {
171        return Err(Error::Invalid {
172            reason: format!(
173                "the base worktree {} is dirty; the train advances that base and will not \
174                 build on work nobody recorded. Commit or stash what it holds, then re-run `{}`",
175                root.display(),
176                train_command(candidates, push),
177            ),
178        });
179    }
180    for branch in candidates {
181        if !git::is_valid_branch_name(branch) {
182            return Err(Error::Invalid {
183                reason: format!(
184                    "{branch:?} is not a valid branch name; `onevcs recoverable` lists every \
185                     preserved branch by name and the checkout it is in"
186                ),
187            });
188        }
189        if branch == &base {
190            return Err(Error::Invalid {
191                reason: format!(
192                    "the base branch {base:?} cannot also be a candidate; re-run `onevcs \
193                     integrate` naming only the branches to land on it"
194                ),
195            });
196        }
197        if !git::branch_exists(root, branch) {
198            return Err(Error::Invalid {
199                reason: format!(
200                    "{root:?} has no local branch {branch:?}; `onevcs recoverable` lists this \
201                     identity's unpublished branches and the checkout each is in",
202                    root = root.display()
203                ),
204            });
205        }
206    }
207    if candidates
208        .iter()
209        .collect::<std::collections::BTreeSet<_>>()
210        .len()
211        != candidates.len()
212    {
213        return Err(Error::Invalid {
214            reason: "a branch is offered to the train twice; re-run `onevcs integrate` naming \
215                     each branch once, in the order they should land"
216                .to_owned(),
217        });
218    }
219
220    let identity = lock::git_identity(&git::common_dir(root)?);
221    let turn = queue::turn(&identity, &crate::publish::NeverCancelled)?;
222    stream.emit(
223        EventKind::LockWait,
224        object(json!({
225            "identity": identity,
226            "elapsed": turn.waited.as_secs_f64(),
227            "queue_position": turn.position,
228        })),
229    );
230    stream.emit(
231        EventKind::LockAcquired,
232        object(json!({"identity": identity})),
233    );
234
235    let trailers = provenance::from_rules(&file);
236    let outcome = train(
237        resolution, &base, candidates, push, &trailers, term_scope, stream,
238    );
239    drop(turn);
240    outcome
241}
242
243/// The exact command that publishes each candidate the train may not land.
244///
245/// The train lands locally and stays that way — it is built for cheap deterministic
246/// candidates and must not absorb a publication's work — so this refusal is a
247/// routing signpost. It names the invocation per candidate rather than the shape of
248/// one, because a refusal that names no command is what leaves `git push` and `gh pr
249/// create` as the way forward.
250fn change_request_route(resolution: &Resolution, candidates: &[String]) -> String {
251    let repo = resolution.publication.to_string_lossy();
252    candidates
253        .iter()
254        .map(|branch| {
255            format!(
256                "`{}`",
257                guidance::command(["onevcs", "publish-branch", branch, "--repo", &repo])
258            )
259        })
260        .collect::<Vec<_>>()
261        .join(", ")
262}
263
264/// This same train, for a refusal that asks for it to be run again.
265///
266/// `--push` travels with it: a re-run that quietly dropped it would land the
267/// candidates locally and leave the operator believing the remote had them.
268fn train_command(candidates: &[String], push: bool) -> String {
269    let mut argv: Vec<&str> = vec!["onevcs", "integrate"];
270    argv.extend(candidates.iter().map(String::as_str));
271    if push {
272        argv.push("--push");
273    }
274    guidance::command(argv)
275}
276
277fn train(
278    resolution: &Resolution,
279    base: &str,
280    candidates: &[String],
281    push: bool,
282    trailers: &provenance::Trailers,
283    term_scope: &TermScope,
284    stream: &mut Stream,
285) -> Result<Outcome> {
286    let root = &resolution.publication;
287    let has_remote = git::has_remote(root, "origin");
288    if has_remote {
289        git::fetch(root, "origin")?;
290        stream.emit(
291            EventKind::Fetch,
292            object(json!({"remote": "origin", "checkout": root.display().to_string()})),
293        );
294    }
295    let remote_base = crate::vcs::base_ref(root, base);
296    let environment = merge_path::comparison_env("origin", base);
297
298    let initial = git::head_sha(root)?;
299    let workspace = home::workspaces_dir()?
300        .join("integrations")
301        .join(ids::unique());
302    home::ensure_dir(&workspace)?;
303
304    let train = Train {
305        resolution,
306        base,
307        remote_base: &remote_base,
308        workspace: &workspace,
309        trailers,
310    };
311    let mut branches = Vec::new();
312    for branch in candidates {
313        branches.push(one(&train, branch, stream)?);
314    }
315
316    let final_head = git::head_sha(root)?;
317    let mut ending = if final_head == initial {
318        Ending::Unchanged
319    } else {
320        Ending::Advanced
321    };
322    if push && ending.advanced() {
323        if !has_remote {
324            return Err(Error::Invalid {
325                reason: format!(
326                    "{} has no origin to push to; the base advanced locally, so re-run \
327                     `onevcs integrate` without --push, or give the checkout an origin",
328                    root.display()
329                ),
330            });
331        }
332        // What the train advanced the base by is held to the boundary before the base
333        // reaches the origin: every commit it would push, against every parent.
334        let compared = format!("origin/{base}");
335        crate::boundary::evidence::guard_publication(
336            crate::Providers::real().hosting,
337            &resolution.key,
338            &crate::boundary::screen::Outgoing {
339                repo: root,
340                base: git::tip(root, &compared).map(|_| compared.as_str()),
341                tip: base,
342                branch: Some(base),
343                title: None,
344                body: None,
345            },
346            term_scope,
347        )?;
348        // The train's push is a publication attempt of its own, and the hook it runs
349        // is that attempt's gate.
350        stream.begin_attempt();
351        let hooked = crate::gate_run::runs_pre_push(root);
352        let started = crate::gate_run::Moment::now();
353        let result = git::push(root, base, "origin", &environment)?;
354        let ended = crate::gate_run::Moment::now();
355        // Through the one recorder every publishing push uses: this push is where the
356        // repository's `pre-push` hook rules on the whole train, so what it wrote is
357        // the verdict and the only account of a refusal there will ever be. No run
358        // root is handed over — the train's scratch workspace is removed a few lines
359        // below, so the stored artifact is what outlives the run.
360        // The base the train advanced, which is the work being integrated rather
361        // than any one branch being made.
362        let kept = publish::record_push(
363            stream,
364            &Ref::from_git(base),
365            &result,
366            None,
367            crate::event::Phase::Integrate,
368            None,
369        )?;
370        publish::record_pre_push(stream, hooked, started, ended, &result);
371        if !result.accepted() {
372            // Through the one refusal builder every publishing push uses, for the
373            // reason the recorder above is one producer: a train's push is where the
374            // hook rules on the whole train, and an operator diagnosing that refusal
375            // needs the same account — where what it wrote is, and the end of it —
376            // as one diagnosing a publication's.
377            return Err(publish::rejected(
378                &format!("the push of {base:?}"),
379                &result,
380                &kept,
381                // The train pushes from the publication checkout, which is nobody's
382                // scratch tree: what the hook wrote about where it ran is still
383                // there when this returns.
384                None,
385            ));
386        }
387        ending = Ending::AdvancedAndPushed;
388    }
389    let _ = std::fs::remove_dir_all(&workspace);
390    if ending.advanced() {
391        stream.base_arrived(base, &final_head, candidates);
392    }
393    Ok(Outcome {
394        base: Ref::from_git(base),
395        branches,
396        ending,
397    })
398}
399
400/// What every candidate of one train is run against.
401struct Train<'a> {
402    resolution: &'a Resolution,
403    /// The local base each candidate lands on, in the order they land.
404    base: &'a str,
405    /// The base the origin currently has, which each candidate syncs with first.
406    remote_base: &'a str,
407    /// Where candidate worktrees are put.
408    workspace: &'a Path,
409    /// The provenance trailer keys this host reads.
410    trailers: &'a provenance::Trailers,
411}
412
413fn one(train: &Train, branch: &str, stream: &mut Stream) -> Result<BranchOutcome> {
414    let Train {
415        resolution,
416        base,
417        remote_base,
418        workspace,
419        trailers,
420    } = *train;
421    let root = &resolution.publication;
422    // A marker written under a prefix this host does not read is the one shape that
423    // would otherwise land here as finished work: nothing recognizes it, so nothing
424    // refuses it. It is named rather than merged.
425    if let Some(prefix) = provenance::unrecognized(root, base, branch, trailers)?.first() {
426        return Ok(skipped(
427            branch,
428            &format!(
429                "provenance under the trailer prefix {prefix:?}, which this host is not \
430                 configured to read; set trailer_prefix to {prefix:?} in the rules file, then \
431                 land it with `{}`",
432                guidance::command([
433                    "onevcs",
434                    "recover",
435                    branch,
436                    "--repo",
437                    &root.to_string_lossy()
438                ])
439            ),
440        ));
441    }
442    // Against the *local* base, which is what this candidate adds: the base has
443    // already moved under earlier candidates of this train, and judging against the
444    // remote would fold their commits into this one's provenance and subject.
445    let unattested = provenance::unattested(root, base, branch, trailers)?;
446    if !unattested.is_empty() {
447        return Ok(BranchOutcome {
448            branch: Ref::from_git(branch),
449            status: Status::Skipped(format!(
450                "incomplete provenance ({} unattested commit(s)); this branch belongs to `{}`",
451                unattested.len(),
452                guidance::command([
453                    "onevcs",
454                    "recover",
455                    branch,
456                    "--repo",
457                    &root.to_string_lossy()
458                ])
459            )),
460        });
461    }
462
463    let parent: PathBuf = workspace.join(policy::branch_slug(branch));
464    home::ensure_dir(&parent)?;
465    let worktree = parent.join("worktree");
466    git::worktree_add_existing(root, &worktree, branch)?;
467
468    let outcome = (|| -> Result<BranchOutcome> {
469        // The paths travel with the skip for the reason they travel with a
470        // publication's own conflict: a candidate the train passed over tells an
471        // operator nothing they can act on unless it says what conflicts.
472        if let git::Integrated::Conflicted(conflict) = git::merge_into_branch(
473            &worktree,
474            remote_base,
475            &format!("Merge {remote_base} into {branch}"),
476        )? {
477            return Ok(skipped(
478                branch,
479                &format!(
480                    "conflict with the current base in {}",
481                    guidance::listed(conflict.paths())
482                ),
483            ));
484        }
485        if let git::Integrated::Conflicted(conflict) = git::merge_into_branch(
486            &worktree,
487            base,
488            &format!("Merge integration train {base} into {branch}"),
489        )? {
490            return Ok(skipped(
491                branch,
492                &format!(
493                    "conflict with an earlier candidate in {}",
494                    guidance::listed(conflict.paths())
495                ),
496            ));
497        }
498        // The candidate merged the base in above, so the base is contained in the
499        // tree this would land — unless something advanced it since, which is exactly
500        // the state this must not silently reconcile: what would land is no longer
501        // what was merged.
502        if !git::is_ancestor(root, &git::head_sha(root)?, branch)? {
503            return Ok(skipped(
504                branch,
505                "not-ready: the base advanced while this candidate was being built",
506            ));
507        }
508        let subject =
509            match provenance::publication_subject(&worktree, base, "HEAD", None, trailers)? {
510                Ok(subject) => subject,
511                // The train takes no title of its own — one title cannot describe
512                // every candidate — so the skip hands the branch to the verb that
513                // does, rather than reporting a synthesis failure with no way past
514                // it. A subject naming no change is a worse record than this skip.
515                Err(reason) => {
516                    return Ok(skipped(
517                        branch,
518                        &format!(
519                            "{reason}; publish it with `{} --title <T>`",
520                            guidance::command([
521                                "onevcs",
522                                "publish-branch",
523                                branch,
524                                "--repo",
525                                &root.to_string_lossy()
526                            ])
527                        ),
528                    ))
529                }
530            };
531        let attested = provenance::attestation_trailers(&worktree, base, "HEAD", trailers)?;
532        let message = publish::compose_message(&subject, &attested);
533        let landed = squash_publish(root, base, branch, &message, workspace, stream)?;
534        Ok(BranchOutcome {
535            branch: Ref::from_git(branch),
536            status: if landed {
537                Status::Merged
538            } else {
539                Status::AlreadyMerged
540            },
541        })
542    })();
543
544    git::worktree_remove(root, &worktree)?;
545    let _ = std::fs::remove_dir_all(&parent);
546    outcome
547}
548
549fn skipped(branch: &str, reason: &str) -> BranchOutcome {
550    BranchOutcome {
551        branch: Ref::from_git(branch),
552        status: Status::Skipped(reason.to_owned()),
553    }
554}
555
556/// Land one candidate as a single commit the base checkout fast-forwards onto.
557fn squash_publish(
558    root: &Path,
559    base: &str,
560    branch: &str,
561    message: &str,
562    workspace: &Path,
563    stream: &mut Stream,
564) -> Result<bool> {
565    let parent = workspace.join(format!("publish-{}", ids::unique()));
566    home::ensure_dir(&parent)?;
567    let scratch = parent.join("worktree");
568    git::worktree_add_detached(root, &scratch, base)?;
569    let landed = (|| -> Result<bool> {
570        let Some(sha) = git::merge_squash(&scratch, branch, message)? else {
571            return Ok(false);
572        };
573        git::merge_ff_only(root, &sha)?;
574        if stream.in_operation() {
575            stream.emit(
576                EventKind::MergeCompleted,
577                object(json!({"branch": branch, "base": base, "sha": sha})),
578            );
579        }
580        Ok(true)
581    })();
582    git::worktree_remove(root, &scratch)?;
583    let _ = std::fs::remove_dir_all(&parent);
584    landed
585}