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)?);
314    }
315
316    let mut ending = if git::head_sha(root)? == initial {
317        Ending::Unchanged
318    } else {
319        Ending::Advanced
320    };
321    if push && ending.advanced() {
322        if !has_remote {
323            return Err(Error::Invalid {
324                reason: format!(
325                    "{} has no origin to push to; the base advanced locally, so re-run \
326                     `onevcs integrate` without --push, or give the checkout an origin",
327                    root.display()
328                ),
329            });
330        }
331        // What the train advanced the base by is held to the boundary before the base
332        // reaches the origin: every commit it would push, against every parent.
333        let compared = format!("origin/{base}");
334        crate::boundary::evidence::guard_publication(
335            crate::Providers::real().hosting,
336            &resolution.key,
337            &crate::boundary::screen::Outgoing {
338                repo: root,
339                base: git::tip(root, &compared).map(|_| compared.as_str()),
340                tip: base,
341                branch: Some(base),
342                title: None,
343                body: None,
344            },
345            term_scope,
346        )?;
347        // The train's push is a publication attempt of its own, and the hook it runs
348        // is that attempt's gate.
349        stream.begin_attempt();
350        let hooked = crate::gate_run::runs_pre_push(root);
351        let started = crate::gate_run::Moment::now();
352        let result = git::push(root, base, "origin", &environment)?;
353        let ended = crate::gate_run::Moment::now();
354        // Through the one recorder every publishing push uses: this push is where the
355        // repository's `pre-push` hook rules on the whole train, so what it wrote is
356        // the verdict and the only account of a refusal there will ever be. No run
357        // root is handed over — the train's scratch workspace is removed a few lines
358        // below, so the stored artifact is what outlives the run.
359        // The base the train advanced, which is the work being integrated rather
360        // than any one branch being made.
361        let kept = publish::record_push(
362            stream,
363            &Ref::from_git(base),
364            &result,
365            None,
366            crate::event::Phase::Integrate,
367            None,
368        )?;
369        publish::record_pre_push(stream, hooked, started, ended, &result);
370        if !result.accepted() {
371            // Through the one refusal builder every publishing push uses, for the
372            // reason the recorder above is one producer: a train's push is where the
373            // hook rules on the whole train, and an operator diagnosing that refusal
374            // needs the same account — where what it wrote is, and the end of it —
375            // as one diagnosing a publication's.
376            return Err(publish::rejected(
377                &format!("the push of {base:?}"),
378                &result,
379                &kept,
380                // The train pushes from the publication checkout, which is nobody's
381                // scratch tree: what the hook wrote about where it ran is still
382                // there when this returns.
383                None,
384            ));
385        }
386        ending = Ending::AdvancedAndPushed;
387    }
388    let _ = std::fs::remove_dir_all(&workspace);
389    Ok(Outcome {
390        base: Ref::from_git(base),
391        branches,
392        ending,
393    })
394}
395
396/// What every candidate of one train is run against.
397struct Train<'a> {
398    resolution: &'a Resolution,
399    /// The local base each candidate lands on, in the order they land.
400    base: &'a str,
401    /// The base the origin currently has, which each candidate syncs with first.
402    remote_base: &'a str,
403    /// Where candidate worktrees are put.
404    workspace: &'a Path,
405    /// The provenance trailer keys this host reads.
406    trailers: &'a provenance::Trailers,
407}
408
409fn one(train: &Train, branch: &str) -> Result<BranchOutcome> {
410    let Train {
411        resolution,
412        base,
413        remote_base,
414        workspace,
415        trailers,
416    } = *train;
417    let root = &resolution.publication;
418    // A marker written under a prefix this host does not read is the one shape that
419    // would otherwise land here as finished work: nothing recognizes it, so nothing
420    // refuses it. It is named rather than merged.
421    if let Some(prefix) = provenance::unrecognized(root, base, branch, trailers)?.first() {
422        return Ok(skipped(
423            branch,
424            &format!(
425                "provenance under the trailer prefix {prefix:?}, which this host is not \
426                 configured to read; set trailer_prefix to {prefix:?} in the rules file, then \
427                 land it with `{}`",
428                guidance::command([
429                    "onevcs",
430                    "recover",
431                    branch,
432                    "--repo",
433                    &root.to_string_lossy()
434                ])
435            ),
436        ));
437    }
438    // Against the *local* base, which is what this candidate adds: the base has
439    // already moved under earlier candidates of this train, and judging against the
440    // remote would fold their commits into this one's provenance and subject.
441    let unattested = provenance::unattested(root, base, branch, trailers)?;
442    if !unattested.is_empty() {
443        return Ok(BranchOutcome {
444            branch: Ref::from_git(branch),
445            status: Status::Skipped(format!(
446                "incomplete provenance ({} unattested commit(s)); this branch belongs to `{}`",
447                unattested.len(),
448                guidance::command([
449                    "onevcs",
450                    "recover",
451                    branch,
452                    "--repo",
453                    &root.to_string_lossy()
454                ])
455            )),
456        });
457    }
458
459    let parent: PathBuf = workspace.join(policy::branch_slug(branch));
460    home::ensure_dir(&parent)?;
461    let worktree = parent.join("worktree");
462    git::worktree_add_existing(root, &worktree, branch)?;
463
464    let outcome = (|| -> Result<BranchOutcome> {
465        // The paths travel with the skip for the reason they travel with a
466        // publication's own conflict: a candidate the train passed over tells an
467        // operator nothing they can act on unless it says what conflicts.
468        if let git::Integrated::Conflicted(conflict) = git::merge_into_branch(
469            &worktree,
470            remote_base,
471            &format!("Merge {remote_base} into {branch}"),
472        )? {
473            return Ok(skipped(
474                branch,
475                &format!(
476                    "conflict with the current base in {}",
477                    guidance::listed(conflict.paths())
478                ),
479            ));
480        }
481        if let git::Integrated::Conflicted(conflict) = git::merge_into_branch(
482            &worktree,
483            base,
484            &format!("Merge integration train {base} into {branch}"),
485        )? {
486            return Ok(skipped(
487                branch,
488                &format!(
489                    "conflict with an earlier candidate in {}",
490                    guidance::listed(conflict.paths())
491                ),
492            ));
493        }
494        // The candidate merged the base in above, so the base is contained in the
495        // tree this would land — unless something advanced it since, which is exactly
496        // the state this must not silently reconcile: what would land is no longer
497        // what was merged.
498        if !git::is_ancestor(root, &git::head_sha(root)?, branch)? {
499            return Ok(skipped(
500                branch,
501                "not-ready: the base advanced while this candidate was being built",
502            ));
503        }
504        let subject =
505            match provenance::publication_subject(&worktree, base, "HEAD", None, trailers)? {
506                Ok(subject) => subject,
507                // The train takes no title of its own — one title cannot describe
508                // every candidate — so the skip hands the branch to the verb that
509                // does, rather than reporting a synthesis failure with no way past
510                // it. A subject naming no change is a worse record than this skip.
511                Err(reason) => {
512                    return Ok(skipped(
513                        branch,
514                        &format!(
515                            "{reason}; publish it with `{} --title <T>`",
516                            guidance::command([
517                                "onevcs",
518                                "publish-branch",
519                                branch,
520                                "--repo",
521                                &root.to_string_lossy()
522                            ])
523                        ),
524                    ))
525                }
526            };
527        let attested = provenance::attestation_trailers(&worktree, base, "HEAD", trailers)?;
528        let message = publish::compose_message(&subject, &attested);
529        let landed = squash_publish(root, base, branch, &message, workspace)?;
530        Ok(BranchOutcome {
531            branch: Ref::from_git(branch),
532            status: if landed {
533                Status::Merged
534            } else {
535                Status::AlreadyMerged
536            },
537        })
538    })();
539
540    git::worktree_remove(root, &worktree)?;
541    let _ = std::fs::remove_dir_all(&parent);
542    outcome
543}
544
545fn skipped(branch: &str, reason: &str) -> BranchOutcome {
546    BranchOutcome {
547        branch: Ref::from_git(branch),
548        status: Status::Skipped(reason.to_owned()),
549    }
550}
551
552/// Land one candidate as a single commit the base checkout fast-forwards onto.
553fn squash_publish(
554    root: &Path,
555    base: &str,
556    branch: &str,
557    message: &str,
558    workspace: &Path,
559) -> Result<bool> {
560    let parent = workspace.join(format!("publish-{}", ids::unique()));
561    home::ensure_dir(&parent)?;
562    let scratch = parent.join("worktree");
563    git::worktree_add_detached(root, &scratch, base)?;
564    let landed = (|| -> Result<bool> {
565        let Some(sha) = git::merge_squash(&scratch, branch, message)? else {
566            return Ok(false);
567        };
568        git::merge_ff_only(root, &sha)?;
569        Ok(true)
570    })();
571    git::worktree_remove(root, &scratch)?;
572    let _ = std::fs::remove_dir_all(&parent);
573    landed
574}