Skip to main content

weave_cli/
onto.rs

1//! `weave land --onto <remote>/<branch>` — the whole landing: bring the
2//! branch's latest commits onto a shared branch, and publish nothing that has
3//! not been checked against the exact commit it is published onto.
4//!
5//! The loop, at most `attempts` times:
6//!
7//! 1. fetch `<remote>/<branch>`; call its tip `T`;
8//! 2. if `T` is not already in the branch, `git merge --no-commit T` (with
9//!    whatever merge driver the repository configures) and run the
10//!    working-tree gate of [`crate::land`] over the merge in progress —
11//!    conflicted files AND files the driver or git merged cleanly. Every
12//!    file must come out PROVEN or VERIFIED (DROPPED / UNDELETED included);
13//!    then the merge is committed. Otherwise the merge is left in progress
14//!    with the refused files conflicted, and nothing is published;
15//! 3. every other merge commit between `T` and HEAD not yet checked (one the
16//!    agent committed itself) is checked the same way, its own tree as the
17//!    answer (revisions mode, `--result`) — and a file it refuses gets a
18//!    second hearing as HEAD holds it now, so a later fix commit counts;
19//! 4. `--verify-cmd`, when given, runs on the final tree (a build, the
20//!    affected tests); a non-zero exit refuses; then `--check sem` (or the
21//!    `.weave/config` default) runs `sem check --base <T>` on it: any verdict
22//!    but pass — fail, could not decide, no sem installed — refuses, and the
23//!    diagnostics are the refusal's report ([`crate::semcheck`]);
24//! 5. a fast-forward-only update of `<branch>` on `<remote>`. When it is
25//!    rejected, the branch moved: back to 1 — the new merge is gated again,
26//!    and the verify command runs again on the new tree.
27//!
28//! The default is to refuse: no step's failure leads to publishing.
29//!
30//! Checked merges are remembered by commit id in `<git-dir>/weave-land-verified`
31//! (a commit id fixes both parents and the tree, so the verdict cannot go
32//! stale), and verify passes by tree and command in
33//! `<git-dir>/weave-land-verify-ok`, so a retry does no work twice.
34
35use std::collections::BTreeSet;
36use std::io::Write;
37use std::path::{Path, PathBuf};
38use std::process::Command;
39use std::time::Duration;
40
41use sha2::{Digest, Sha256};
42use weave_core::host::Host;
43
44use crate::gitscan;
45use crate::land::{self, Present, Resolver, Status};
46use crate::mergestate;
47use crate::preserve;
48use crate::semcheck;
49
50type R<T> = Result<T, Box<dyn std::error::Error>>;
51
52/// What `--onto` was asked to do.
53pub struct Options {
54    pub remote: String,
55    pub branch: String,
56    pub resolver: Option<Resolver>,
57    pub verify_cmd: Option<String>,
58    pub verify_timeout: Duration,
59    /// `sem check` on the final tree (`--check sem`); limited by `verify_timeout`.
60    pub check: Option<crate::semcheck::Check>,
61    pub attempts: usize,
62    pub certificate_dir: Option<PathBuf>,
63}
64
65/// How the run ended.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum Outcome {
68    /// Published: the branch on the remote is now this commit.
69    Landed(String),
70    /// Nothing was published, and why (already printed).
71    Refused(&'static str),
72}
73
74/// `remote/branch` → `(remote, branch)`.
75pub fn split_target(target: &str) -> R<(String, String)> {
76    match target.split_once('/') {
77        Some((r, b)) if !r.is_empty() && !b.is_empty() => Ok((r.to_string(), b.to_string())),
78        _ => {
79            Err(format!("--onto wants <remote>/<branch> (e.g. origin/main), not `{target}`").into())
80        }
81    }
82}
83
84fn git_run(dir: &Path, args: &[&str], limit: Duration) -> R<(bool, String, String)> {
85    let mut cmd = Command::new("git");
86    cmd.args(args).current_dir(dir);
87    let (status, out, err) = gitscan::run_bounded(cmd, &format!("git {}", args[0]), None, limit)?;
88    Ok((
89        status.success(),
90        String::from_utf8_lossy(&out).to_string(),
91        String::from_utf8_lossy(&err).to_string(),
92    ))
93}
94
95fn git(dir: &Path, args: &[&str]) -> R<String> {
96    Ok(gitscan::git(dir, args)?.trim().to_string())
97}
98
99fn git_ok(dir: &Path, args: &[&str]) -> bool {
100    git_run(dir, args, Duration::from_secs(120)).is_ok_and(|(ok, _, _)| ok)
101}
102
103fn rev(dir: &Path, r: &str) -> R<String> {
104    git(dir, &["rev-parse", "--verify", "-q", r])
105}
106
107/// A set of ids kept one per line in a file under the git dir.
108struct Ledger(PathBuf);
109
110impl Ledger {
111    fn has(&self, id: &str) -> bool {
112        std::fs::read_to_string(&self.0).is_ok_and(|t| t.lines().any(|l| l == id))
113    }
114    fn add(&self, id: &str) -> R<()> {
115        let mut f = std::fs::OpenOptions::new()
116            .create(true)
117            .append(true)
118            .open(&self.0)?;
119        writeln!(f, "{id}")?;
120        Ok(())
121    }
122}
123
124/// Land the current branch onto `opts.remote`/`opts.branch`. Progress and
125/// every refusal go to `out`, in words meant for the person (or agent) who
126/// has to act on them.
127pub fn land_onto(dir: &Path, opts: &Options, host: &Host, out: &mut dyn Write) -> R<Outcome> {
128    let top = PathBuf::from(git(dir, &["rev-parse", "--show-toplevel"])?);
129    let dir = top.as_path();
130    let gd = PathBuf::from(git(dir, &["rev-parse", "--absolute-git-dir"])?);
131    let target = format!("{}/{}", opts.remote, opts.branch);
132
133    if gd.join("rebase-merge").exists() || gd.join("rebase-apply").exists() {
134        writeln!(
135            out,
136            "land: a rebase is in progress; finish it (git rebase --continue) first. land merges; \
137             it does not rebase."
138        )?;
139        return Ok(Outcome::Refused("rebase in progress"));
140    }
141
142    // An index.lock nobody holds is removed (and said); one that is held,
143    // or whose holder cannot be told, refuses: land never works around it.
144    if let Some(why) = mergestate::clear_stale_lock(&gd, out)? {
145        writeln!(
146            out,
147            "land: REFUSED. {why}. Nothing was published. Wait for that process to finish, then \
148             run land again."
149        )?;
150        return Ok(Outcome::Refused("index.lock held"));
151    }
152
153    // A merge in progress: the person's in-place resolution, judged by the
154    // gate — unless it is a merge land itself started and never finished,
155    // which is nobody's resolution: it is aborted and merged again.
156    if gd.join("MERGE_HEAD").exists() {
157        if let Some(why) = resume_merge_in_progress(dir, &gd, opts, host, out)? {
158            return Ok(Outcome::Refused(why));
159        }
160    } else {
161        mergestate::clear_record(&gd);
162    }
163
164    if !git(dir, &["status", "--porcelain", "--untracked-files=no"])?.is_empty() {
165        writeln!(
166            out,
167            "land: you have uncommitted changes to tracked files; commit them first."
168        )?;
169        return Ok(Outcome::Refused("dirty"));
170    }
171
172    for attempt in 1..=opts.attempts.max(1) {
173        let (ok, _, err) = git_run(
174            dir,
175            &["fetch", "-q", &opts.remote, &opts.branch],
176            Duration::from_secs(300),
177        )?;
178        if !ok {
179            return Err(format!("git fetch {target} failed: {}", err.trim()).into());
180        }
181        let tip = rev(dir, "FETCH_HEAD")?;
182        match prepare(dir, opts, host, out, &tip, &[])? {
183            Prep::Ready(_) => {}
184            Prep::Refused(r) => return Ok(Outcome::Refused(r.reason)),
185        }
186
187        // 5. publish, fast-forward only
188        let head = rev(dir, "HEAD")?;
189        let refspec = format!("HEAD:refs/heads/{}", opts.branch);
190        let (ok, _, err) = git_run(
191            dir,
192            &["push", "-q", &opts.remote, &refspec],
193            Duration::from_secs(300),
194        )?;
195        if ok {
196            writeln!(
197                out,
198                "land: LANDED {} on {target}.",
199                &head[..head.len().min(12)]
200            )?;
201            return Ok(Outcome::Landed(head));
202        }
203        let raced = [
204            "rejected",
205            "fetch first",
206            "non-fast-forward",
207            "stale info",
208            "failed to update ref",
209            "cannot lock ref",
210        ]
211        .iter()
212        .any(|w| err.contains(w));
213        if !raced {
214            return Err(format!("could not publish to {target}: {}", err.trim()).into());
215        }
216        writeln!(
217            out,
218            "land: {target} moved while landing (attempt {attempt}); merging the new tip and \
219             checking again."
220        )?;
221    }
222    writeln!(out, "land: {target} kept moving; run land again.")?;
223    Ok(Outcome::Refused("target kept moving"))
224}
225
226/// A tree ready to publish, or why not.
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub enum Prep {
229    /// HEAD is this commit: gated against the tip, verified; publish it.
230    Ready(String),
231    /// Nothing may be published. The report was written to `out`.
232    Refused(Refusal),
233}
234
235/// Why [`prepare`] refused, in a form another process can act on.
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct Refusal {
238    /// Short machine reason: "nothing to land", "merge refused",
239    /// "a merge on the branch was refused", "verify command failed",
240    /// "sem check failed", "sem check could not decide", "sem not installed",
241    /// "sem check checked another tree".
242    pub reason: &'static str,
243    /// The files the gate refused (empty for a verify failure).
244    pub files: Vec<String>,
245    /// `path:first-last` line ranges of the conflict blocks left in those
246    /// files, where there are markers.
247    pub regions: Vec<String>,
248}
249
250impl Refusal {
251    fn bare(reason: &'static str) -> Refusal {
252        Refusal {
253            reason,
254            files: Vec::new(),
255            regions: Vec::new(),
256        }
257    }
258}
259
260/// Steps 2–4 of the loop against one fetched `tip`, on the branch checked out
261/// in `dir`: merge `tip` in and gate every file of the merge, gate every other
262/// unchecked merge on the branch, run the verify command on the final tree.
263/// `Ready(HEAD)` when all of it passed — the caller publishes HEAD with a
264/// lease on `tip`. `env` is extra environment for the verify command.
265///
266/// A merge refusal leaves the merge in progress in `dir`, the refused files
267/// conflicted, so the person can resolve them in place.
268pub fn prepare(
269    dir: &Path,
270    opts: &Options,
271    host: &Host,
272    out: &mut dyn Write,
273    tip: &str,
274    env: &[(&str, &str)],
275) -> R<Prep> {
276    let gd = PathBuf::from(git(dir, &["rev-parse", "--absolute-git-dir"])?);
277    let checked = Ledger(gd.join("weave-land-verified"));
278    let verify_ok = Ledger(gd.join("weave-land-verify-ok"));
279    let target = format!("{}/{}", opts.remote, opts.branch);
280
281    if git(dir, &["rev-list", &format!("{tip}..HEAD")])?.is_empty() {
282        writeln!(
283            out,
284            "land: nothing to land: your branch has no commits that are not already on {target}."
285        )?;
286        return Ok(Prep::Refused(Refusal::bare("nothing to land")));
287    }
288
289    // 2. the merge with the tip, gated before it is committed. land's own
290    // merge whose commit failed is aborted cleanly; it is merged again once.
291    if !git_ok(dir, &["merge-base", "--is-ancestor", tip, "HEAD"]) {
292        let short = &tip[..tip.len().min(12)];
293        let mut committed = false;
294        for _ in 0..2 {
295            if let Err(why) = start_merge(dir, &gd, &target, tip, out)? {
296                return Ok(Prep::Refused(Refusal::bare(why)));
297            }
298            let conflicted = git(dir, &["diff", "--name-only", "--diff-filter=U"])?;
299            if conflicted.is_empty() {
300                writeln!(out, "land: merged {target} ({short}); checking the merge …")?;
301            } else {
302                writeln!(
303                    out,
304                    "land: merging {target} ({short}) conflicts in:\n{}",
305                    indent(&conflicted)
306                )?;
307            }
308            match gate_merge_in_progress(dir, opts, host, out, tip, true)? {
309                Gated::Committed => {
310                    committed = true;
311                    break;
312                }
313                Gated::Refused(refused) => {
314                    let regions = conflict_regions(dir, &refused);
315                    if !regions.is_empty() {
316                        writeln!(
317                            out,
318                            "land: conflict regions:\n{}",
319                            indent(&regions.join("\n"))
320                        )?;
321                    }
322                    return Ok(Prep::Refused(Refusal {
323                        reason: "merge refused",
324                        files: refused,
325                        regions,
326                    }));
327                }
328                Gated::Aborted => {
329                    writeln!(
330                        out,
331                        "land: land's own merge could not be committed and was aborted; merging \
332                         again."
333                    )?;
334                }
335            }
336        }
337        if !committed {
338            writeln!(
339                out,
340                "\nland: REFUSED. land's own merge could not be committed twice; nothing was \
341                 published. Run land again once no other git command is running here."
342            )?;
343            return Ok(Prep::Refused(Refusal::bare("merge could not be committed")));
344        }
345    }
346    // 3. every other merge on the branch not yet checked
347    let refused = gate_branch_merges(dir, opts, host, out, &checked, tip)?;
348    if !refused.is_empty() {
349        return Ok(Prep::Refused(Refusal {
350            reason: "a merge on the branch was refused",
351            files: refused,
352            regions: Vec::new(),
353        }));
354    }
355
356    // 4. the verify command on the exact tree to be published
357    if let Some(cmd) = &opts.verify_cmd {
358        let tree = rev(dir, "HEAD^{tree}")?;
359        let key = format!("{tree} {}", hex(&Sha256::digest(cmd.as_bytes())));
360        if !verify_ok.has(&key) {
361            writeln!(
362                out,
363                "land: running the verify command on the merged tree: {cmd}"
364            )?;
365            match run_verify(dir, cmd, tip, opts.verify_timeout, env)? {
366                Ok(()) => verify_ok.add(&key)?,
367                Err(report) => {
368                    writeln!(out, "{report}")?;
369                    writeln!(
370                        out,
371                        "\nland: REFUSED. The verify command failed on the merged tree \
372                         (your branch + {target}); nothing was published. Fix the failure \
373                         (it may come from combining your change with what is on {target}), \
374                         commit the fix, then run land again."
375                    )?;
376                    return Ok(Prep::Refused(Refusal::bare("verify command failed")));
377                }
378            }
379        }
380    }
381    // 4b. sem check on the exact tree to be published
382    if let Some(check) = &opts.check {
383        let tree = rev(dir, "HEAD^{tree}")?;
384        let head = rev(dir, "HEAD")?;
385        let key = format!(
386            "{tree} sem-check {}",
387            check.checkers.as_deref().unwrap_or("*")
388        );
389        if !verify_ok.has(&key) {
390            writeln!(
391                out,
392                "land: running sem check on the merged tree (base {}) …",
393                &tip[..tip.len().min(12)]
394            )?;
395            let outcome = semcheck::run(dir, tip, &tree, check, opts.verify_timeout, env)?;
396            let cert = semcheck::certificate(tip, &head, &tree, &outcome);
397            write_certificate(opts, "check", &cert)?;
398            record_check(&gd, &tree, &cert);
399            match outcome {
400                semcheck::Outcome::Pass(doc) => {
401                    writeln!(
402                        out,
403                        "land: sem check PASS: {} (certificate {})",
404                        semcheck::summary(&doc),
405                        doc["certificate"]["digest"].as_str().unwrap_or("?")
406                    )?;
407                    verify_ok.add(&key)?;
408                }
409                semcheck::Outcome::Refused { reason, report, .. } => {
410                    writeln!(out, "{report}")?;
411                    writeln!(
412                        out,
413                        "\nland: REFUSED. sem check did not pass the merged tree (your branch + \
414                         {target}); nothing was published. Fix what it reports (it may come from \
415                         combining your change with what is on {target}), commit the fix, then \
416                         run land again."
417                    )?;
418                    return Ok(Prep::Refused(Refusal::bare(reason)));
419                }
420            }
421        }
422    }
423    Ok(Prep::Ready(rev(dir, "HEAD")?))
424}
425
426/// The last sem check document for `tree`, kept in `<git-dir>/weave-land-checks`
427/// so the landing queue can put it in the ticket's result.
428fn record_check(gd: &Path, tree: &str, cert: &serde_json::Value) {
429    let d = gd.join("weave-land-checks");
430    if std::fs::create_dir_all(&d).is_ok() {
431        let _ = std::fs::write(d.join(format!("{tree}.json")), cert.to_string());
432    }
433}
434
435/// The sem check document recorded for `tree` in `dir`'s git dir, if any.
436pub fn recorded_check(dir: &Path, tree: &str) -> Option<serde_json::Value> {
437    let gd = PathBuf::from(git(dir, &["rev-parse", "--absolute-git-dir"]).ok()?);
438    let t =
439        std::fs::read_to_string(gd.join("weave-land-checks").join(format!("{tree}.json"))).ok()?;
440    serde_json::from_str(&t).ok()
441}
442
443/// `path:first-last` (1-based) for every conflict block still in `files`.
444pub fn conflict_regions(dir: &Path, files: &[String]) -> Vec<String> {
445    let mut out = Vec::new();
446    for f in files {
447        let Ok(text) = std::fs::read_to_string(dir.join(f)) else {
448            continue;
449        };
450        let mut open = None;
451        for (i, line) in text.lines().enumerate() {
452            if line.starts_with("<<<<<<<") {
453                open = Some(i + 1);
454            } else if line.starts_with(">>>>>>>") {
455                if let Some(a) = open.take() {
456                    out.push(format!("{f}:{a}-{}", i + 1));
457                }
458            }
459        }
460    }
461    out
462}
463
464/// The merge in progress in `dir`: the person's in-place resolution, judged
465/// with the working-tree gate and the both-sides invariant and committed when
466/// it passes — or land's own unfinished merge, aborted (never judged as a
467/// resolution). `true`: go on (committed, or aborted cleanly).
468pub fn check_resolution(dir: &Path, opts: &Options, host: &Host, out: &mut dyn Write) -> R<bool> {
469    let gd = PathBuf::from(git(dir, &["rev-parse", "--absolute-git-dir"])?);
470    if let Some(why) = mergestate::clear_stale_lock(&gd, out)? {
471        writeln!(
472            out,
473            "land: REFUSED. {why}. Nothing was published. Wait for that process to finish, then \
474             run land again."
475        )?;
476        return Ok(false);
477    }
478    Ok(resume_merge_in_progress(dir, &gd, opts, host, out)?.is_none())
479}
480
481fn indent(lines: &str) -> String {
482    lines
483        .lines()
484        .map(|l| format!("  {l}"))
485        .collect::<Vec<_>>()
486        .join("\n")
487}
488
489fn hex(bytes: &[u8]) -> String {
490    bytes.iter().map(|b| format!("{b:02x}")).collect()
491}
492
493fn write_certificate(opts: &Options, tag: &str, doc: &serde_json::Value) -> R<()> {
494    if let Some(d) = &opts.certificate_dir {
495        std::fs::create_dir_all(d)?;
496        let stamp = std::time::SystemTime::now()
497            .duration_since(std::time::UNIX_EPOCH)
498            .map(|d| d.as_nanos())
499            .unwrap_or(0);
500        std::fs::write(
501            d.join(format!("{stamp}-{tag}.json")),
502            serde_json::to_string_pretty(doc)? + "\n",
503        )?;
504    }
505    Ok(())
506}
507
508/// How [`gate_merge_in_progress`] ended.
509#[derive(Debug, Clone, PartialEq, Eq)]
510pub enum Gated {
511    /// Every file passed, the tree keeps both sides' changes, and the merge
512    /// is committed (HEAD).
513    Committed,
514    /// Nothing committed; these files are why (the report is printed).
515    Refused(Vec<String>),
516    /// land's own merge could not be committed and was aborted cleanly: the
517    /// branch is back where it was before the merge. Merge again.
518    Aborted,
519}
520
521/// Start land's own merge of `tip` (`git merge --no-commit --no-ff`),
522/// recorded in `<git-dir>/weave-land-merge` as in-flight first. `Ok(Err)`
523/// (the reason; the report printed) when git did not complete the merge —
524/// it could not write the index, say — in which case whatever it left is
525/// aborted: a merge git did not finish is never judged as a merge.
526pub fn start_merge(
527    dir: &Path,
528    gd: &Path,
529    target: &str,
530    tip: &str,
531    out: &mut dyn Write,
532) -> R<Result<(), &'static str>> {
533    let short = &tip[..tip.len().min(12)];
534    if let Some(why) = mergestate::clear_stale_lock(gd, out)? {
535        writeln!(
536            out,
537            "land: REFUSED. {why}. Nothing was published. Wait for that process to finish, then \
538             run land again."
539        )?;
540        return Ok(Err("index.lock held"));
541    }
542    let pre = rev(dir, "HEAD")?;
543    mergestate::write_record(gd, "in-flight", &pre, tip)?;
544    let msg = format!("Merge {target} ({short}) into the branch");
545    let (ok, mout, merr) = git_run(
546        dir,
547        &["merge", "--no-commit", "--no-ff", "-m", &msg, tip],
548        Duration::from_secs(300),
549    )?;
550    let started = gd.join("MERGE_HEAD").exists();
551    let conflicted = if started {
552        git(dir, &["diff", "--name-only", "--diff-filter=U"])?
553    } else {
554        String::new()
555    };
556    // git merge exits 1 for a merge with conflicts; anything else that is not
557    // a success — or any word that git could not write — is not a merge.
558    let written =
559        started && !mergestate::merge_not_written(&mout, &merr) && (ok || !conflicted.is_empty());
560    if written {
561        return Ok(Ok(()));
562    }
563    writeln!(
564        out,
565        "land: git did not complete the merge of {target} ({short}):\n{}",
566        indent(format!("{}\n{}", mout.trim(), merr.trim()).trim())
567    )?;
568    let clean = if started {
569        abort_own_merge(dir, gd, &pre, out)?
570    } else {
571        let back = rev(dir, "HEAD")? == pre
572            && git(dir, &["status", "--porcelain", "--untracked-files=no"])?.is_empty();
573        if back {
574            mergestate::clear_record(gd);
575        }
576        back
577    };
578    writeln!(
579        out,
580        "\nland: REFUSED. Nothing was published, and nothing from that merge was taken as a \
581         merge. {}",
582        if clean {
583            "Your branch is as it was. Run land again (if git could not write the index, wait \
584             until no other git command is running in this repository)."
585        } else {
586            "land could not put your branch back as it was; its unfinished merge is recorded — \
587             run land again: land aborts its own unfinished merge before anything else. Do not \
588             commit that merge yourself."
589        }
590    )?;
591    Ok(Err("merge did not complete"))
592}
593
594/// Abort the merge in progress that land itself started from `pre`, and
595/// check that the branch is back at `pre` with a clean index and working
596/// tree. `false` (said in `out`) when it could not be done; the record of the
597/// unfinished merge stays, so the next run tries again.
598fn abort_own_merge(dir: &Path, gd: &Path, pre: &str, out: &mut dyn Write) -> R<bool> {
599    let mut tries = 0;
600    loop {
601        tries += 1;
602        let (ok, _, err) = git_run(dir, &["merge", "--abort"], Duration::from_secs(120))?;
603        if ok || !gd.join("MERGE_HEAD").exists() {
604            break;
605        }
606        if tries >= 2 {
607            writeln!(out, "land: git merge --abort failed: {}", err.trim())?;
608            return Ok(false);
609        }
610        // Most likely the lock again: clear it if nobody holds it, else stop.
611        if let Some(why) = mergestate::clear_stale_lock(gd, out)? {
612            writeln!(out, "land: could not abort land's own merge: {why}.")?;
613            return Ok(false);
614        }
615    }
616    let back = rev(dir, "HEAD")? == pre
617        && !gd.join("MERGE_HEAD").exists()
618        && git(dir, &["status", "--porcelain", "--untracked-files=no"])?.is_empty();
619    if back {
620        mergestate::clear_record(gd);
621    } else {
622        writeln!(
623            out,
624            "land: after aborting its own merge the branch is not back at {} with a clean tree.",
625            &pre[..pre.len().min(12)]
626        )?;
627    }
628    Ok(back)
629}
630
631/// A merge is in progress when land starts. land's own unfinished merge
632/// (recorded in-flight, same HEAD, same tip) is aborted — never taken as a
633/// resolution. Anything else is the person's resolution, judged by the gate
634/// and the both-sides invariant. `Some(reason)` = refused.
635fn resume_merge_in_progress(
636    dir: &Path,
637    gd: &Path,
638    opts: &Options,
639    host: &Host,
640    out: &mut dyn Write,
641) -> R<Option<&'static str>> {
642    let theirs = rev(dir, "MERGE_HEAD")?;
643    let head = rev(dir, "HEAD")?;
644    match mergestate::read_record(gd) {
645        Some(r) if r.state == "in-flight" && r.pre == head && r.tip == theirs => {
646            writeln!(
647                out,
648                "land: a merge of {} that land started was never finished (its commit failed, or \
649                 land was stopped); it is nobody's resolution. Aborting it and merging again …",
650                &theirs[..theirs.len().min(12)]
651            )?;
652            if abort_own_merge(dir, gd, &head, out)? {
653                Ok(None)
654            } else {
655                writeln!(
656                    out,
657                    "\nland: REFUSED. Nothing was published. Run land again once no other git \
658                     command is running here; do not commit that merge yourself."
659                )?;
660                Ok(Some("unfinished merge could not be aborted"))
661            }
662        }
663        _ => {
664            writeln!(
665                out,
666                "land: checking your resolution of the merge in progress …"
667            )?;
668            match gate_merge_in_progress(dir, opts, host, out, &theirs, false)? {
669                Gated::Committed => Ok(None),
670                _ => Ok(Some("resolution refused")),
671            }
672        }
673    }
674}
675
676/// The working-tree gate over the merge in progress (theirs = `theirs`):
677/// conflicted files answered by the file on disk once it has no markers
678/// left (else by `--resolver`), cleanly merged files by what the merge wrote.
679/// All PROVEN / VERIFIED, and the tree to be committed keeps every change
680/// only one side made ([`preserve`]): the merge is committed and remembered
681/// as checked. Otherwise the refused files are left conflicted.
682///
683/// `own`: land started this merge (it is recorded in-flight). A refusal
684/// hands it to the person (recorded `refused`); a commit that fails aborts
685/// it ([`Gated::Aborted`]) — land never leaves its own unfinished merge for
686/// anyone to take as a resolution. Not `own`, it is the person's resolution:
687/// whatever happens, it is left exactly as it is.
688fn gate_merge_in_progress(
689    dir: &Path,
690    opts: &Options,
691    host: &Host,
692    out: &mut dyn Write,
693    theirs: &str,
694    own: bool,
695) -> R<Gated> {
696    let gd = PathBuf::from(git(dir, &["rev-parse", "--absolute-git-dir"])?);
697    let plan = land::plan(
698        dir,
699        None,
700        None,
701        Some(theirs),
702        Some(&Present::WorkingTree { unmerged: true }),
703    )?;
704    let mut reports = plan.not_text.clone();
705    for unit in &plan.units {
706        reports.push(land::land_unit(unit, host, opts.resolver.as_ref())?);
707    }
708    reports.sort_by(|a, b| a.path.cmp(&b.path));
709    land::write_back(dir, &plan, &reports)?;
710    write_certificate(
711        opts,
712        "merge",
713        &land::document(&plan, &reports, "working-tree"),
714    )?;
715    let refused: Vec<&str> = reports
716        .iter()
717        .filter(|r| r.status == Status::Refused)
718        .map(|r| r.path.as_str())
719        .collect();
720    let still_unmerged = git(dir, &["diff", "--name-only", "--diff-filter=U"])?;
721    if !reports.is_empty() {
722        write!(out, "{}", land::render(&plan, &reports))?;
723    }
724    let pre = rev(dir, "HEAD")?;
725    if !refused.is_empty() || !still_unmerged.is_empty() {
726        let mut names: BTreeSet<String> = refused.iter().map(|s| s.to_string()).collect();
727        names.extend(still_unmerged.lines().map(str::to_string));
728        let names: Vec<String> = names.into_iter().collect();
729        writeln!(
730            out,
731            "\nland: REFUSED. These files are not merged in a way weave can prove or verify:\n{}\n\
732             Edit them in place to resolve the conflicts (keep both your changes and the changes \
733             from the other side; remove every conflict marker), re-run the tests, then run land \
734             again. land checks your resolution and commits the merge for you. (Or abort the \
735             merge with `git merge --abort`, change your own commits so they no longer collide — \
736             a different case value or key, say — commit, and run land again.)",
737            indent(&names.join("\n"))
738        )?;
739        if own {
740            mergestate::write_record(&gd, "refused", &pre, theirs)?;
741        }
742        return Ok(Gated::Refused(names.into_iter().collect()));
743    }
744
745    // What the gate read from disk must be what is committed.
746    let judged: Vec<&str> = plan
747        .units
748        .iter()
749        .map(|u| u.path.as_str())
750        .chain(plan.not_examined.iter().map(String::as_str))
751        .collect();
752    if !judged.is_empty() {
753        let mut args = vec!["diff", "--name-only", "--"];
754        args.extend(judged.iter().copied());
755        let unstaged = git(dir, &args)?;
756        if !unstaged.is_empty() {
757            writeln!(
758                out,
759                "\nland: REFUSED. These files on disk are not what the merge commit would hold \
760                 (the index has another version):\n{}\nStage what you mean to commit (git add), \
761                 then run land again.",
762                indent(&unstaged)
763            )?;
764            return Ok(Gated::Refused(
765                unstaged.lines().map(str::to_string).collect(),
766            ));
767        }
768    }
769
770    // The tree to be committed, however it came about, keeps every change
771    // only one side made — the gate above judged only files both changed.
772    let tree = preserve::index_tree(dir, &gd)?;
773    let lost = preserve::check(dir, &plan.base, &plan.ours, &plan.theirs, &tree)?;
774    if !lost.is_empty() {
775        let target = format!("{}/{}", opts.remote, opts.branch);
776        write_certificate(
777            opts,
778            "preserve",
779            &serde_json::json!({
780                "schema": land::SCHEMA,
781                "merge": {"base": plan.base, "ours": plan.ours, "theirs": plan.theirs, "tree": tree},
782                "one_sided": lost,
783            }),
784        )?;
785        writeln!(
786            out,
787            "\nland: REFUSED. The merge in progress does not keep every change: files only one \
788             side changed have lost that change:\n{}",
789            preserve::render(&lost, "your branch", &target)
790        )?;
791        let names: Vec<String> = lost.iter().map(|v| v.path.clone()).collect();
792        if own {
793            let clean = abort_own_merge(dir, &gd, &pre, out)?;
794            writeln!(
795                out,
796                "land: {} Nothing was published. Run land again.",
797                if clean {
798                    "land aborted its merge."
799                } else {
800                    "land could not abort its merge; it stays recorded as unfinished and the next \
801                     run aborts it."
802                }
803            )?;
804        } else {
805            writeln!(
806                out,
807                "This is not a resolution of the merge with {target}: a file only one side \
808                 changed must keep that change (a merge git did not finish writing leaves such \
809                 files as they were). Nothing was published. Run `git merge --abort`, then run \
810                 land again — land merges again from scratch."
811            )?;
812        }
813        return Ok(Gated::Refused(names));
814    }
815
816    // Fault injection for the tests (debug builds only): run a command just
817    // before land's own commit — another git process taking index.lock, say.
818    #[cfg(debug_assertions)]
819    if let Ok(cmd) = std::env::var("WEAVE_LAND_FAULT_BEFORE_COMMIT") {
820        let _ = Command::new("sh")
821            .arg("-c")
822            .arg(cmd)
823            .current_dir(dir)
824            .stdin(std::process::Stdio::null())
825            .stdout(std::process::Stdio::null())
826            .stderr(std::process::Stdio::null())
827            .status();
828    }
829    let (ok, _, err) = git_run(
830        dir,
831        &["commit", "-q", "--no-edit"],
832        Duration::from_secs(120),
833    )?;
834    if !ok {
835        writeln!(
836            out,
837            "land: git commit of the checked merge failed: {}",
838            err.trim()
839        )?;
840        if own && abort_own_merge(dir, &gd, &pre, out)? {
841            return Ok(Gated::Aborted);
842        }
843        writeln!(
844            out,
845            "\nland: REFUSED. Nothing was published. {}",
846            if own {
847                "land's own merge could not be committed or aborted; it stays recorded as \
848                 unfinished, and the next run aborts it and merges again. Run land again once no \
849                 other git command is running here; do not commit that merge yourself."
850            } else {
851                "Your resolution is left as it is. Run land again once no other git command is \
852                 running here."
853            }
854        )?;
855        return Ok(Gated::Refused(Vec::new()));
856    }
857    let head = rev(dir, "HEAD")?;
858    let committed = rev(dir, "HEAD^{tree}")?;
859    if committed != tree {
860        writeln!(
861            out,
862            "\nland: REFUSED. The merge commit {} holds tree {}, not the tree weave checked \
863             ({}); nothing was published. Run land again: the commit is checked as a merge on \
864             your branch.",
865            &head[..head.len().min(12)],
866            &committed[..committed.len().min(12)],
867            &tree[..tree.len().min(12)]
868        )?;
869        mergestate::clear_record(&gd);
870        return Ok(Gated::Refused(Vec::new()));
871    }
872    Ledger(gd.join("weave-land-verified")).add(&head)?;
873    Ledger(gd.join("weave-land-preserved")).add(&head)?;
874    mergestate::clear_record(&gd);
875    writeln!(
876        out,
877        "land: the merge passed weave's gate; committed {}.",
878        &head[..head.len().min(12)]
879    )?;
880    Ok(Gated::Committed)
881}
882
883/// Every merge commit in `tip..HEAD` not yet checked, judged in revisions
884/// mode with its own tree as the answer. `false` (and the report printed)
885/// when one is refused.
886fn gate_branch_merges(
887    dir: &Path,
888    opts: &Options,
889    host: &Host,
890    out: &mut dyn Write,
891    checked: &Ledger,
892    tip: &str,
893) -> R<Vec<String>> {
894    let merges = git(dir, &["rev-list", "--merges", &format!("{tip}..HEAD")])?;
895    let gd = PathBuf::from(git(dir, &["rev-parse", "--absolute-git-dir"])?);
896    let preserved = Ledger(gd.join("weave-land-preserved"));
897    for m in merges.lines().filter(|m| !m.is_empty()) {
898        // Every merge on the branch, gated before or not, keeps every change
899        // only one side made — as committed, or as the branch holds it now.
900        if !preserved.has(m) {
901            let parents = git(dir, &["rev-list", "--parents", "-n", "1", m])?;
902            let parents: Vec<&str> = parents.split_whitespace().skip(1).collect();
903            if parents.len() == 2 {
904                let base = git(dir, &["merge-base", parents[0], parents[1]])?;
905                let lost = preserve::check_now(dir, &base, parents[0], parents[1], m, "HEAD")?;
906                if !lost.is_empty() {
907                    let target = format!("{}/{}", opts.remote, opts.branch);
908                    write_certificate(
909                        opts,
910                        &format!("{}-preserve", &m[..m.len().min(12)]),
911                        &serde_json::json!({
912                            "schema": land::SCHEMA,
913                            "merge": {"base": base, "ours": parents[0], "theirs": parents[1], "result": m},
914                            "one_sided": lost,
915                        }),
916                    )?;
917                    writeln!(
918                        out,
919                        "\nland: REFUSED. The merge {} on your branch loses changes that only one \
920                         side made (and the branch has not put them back since):\n{}\nPut each \
921                         change back (for a file only the other side changed: `git checkout {} -- \
922                         <file>`), commit, re-run the tests, then run land again.",
923                        &m[..m.len().min(12)],
924                        preserve::render(&lost, "your branch", &target),
925                        &parents[1][..parents[1].len().min(12)]
926                    )?;
927                    return Ok(lost.into_iter().map(|v| v.path).collect());
928                }
929            }
930            preserved.add(m)?;
931        }
932        if checked.has(m) {
933            continue;
934        }
935        let parents = git(dir, &["rev-list", "--parents", "-n", "1", m])?;
936        let parents: Vec<&str> = parents.split_whitespace().skip(1).collect();
937        if parents.len() != 2 {
938            writeln!(
939                out,
940                "land: REFUSED. {m} is a merge of {} parents; land checks two-parent merges only.",
941                parents.len()
942            )?;
943            return Ok(vec![m.to_string()]);
944        }
945        let base = git(dir, &["merge-base", parents[0], parents[1]])?;
946        let plan = land::plan(
947            dir,
948            Some(&base),
949            Some(parents[0]),
950            Some(parents[1]),
951            Some(&Present::Rev(m.to_string())),
952        )?;
953        let mut reports = plan.not_text.clone();
954        for unit in &plan.units {
955            let mut r = land::land_unit(unit, host, None)?;
956            // A merge refused as committed may have been repaired by a later
957            // commit: what the branch holds now is the answer that counts,
958            // and it is held to the same gate against the same three stages.
959            if r.status == Status::Refused {
960                let now = match git_run(
961                    dir,
962                    &["show", &format!("HEAD:{}", unit.path)],
963                    Duration::from_secs(120),
964                )? {
965                    (true, text, _) => land::Candidate::File(text),
966                    (false, _, _) => land::Candidate::Delete,
967                };
968                if unit.present.as_ref() != Some(&now) {
969                    let later = land::Unit {
970                        present: Some(now),
971                        ..unit.clone()
972                    };
973                    let r2 = land::land_unit(&later, host, None)?;
974                    if r2.status != Status::Refused {
975                        r = r2;
976                        r.reason = format!("as the branch holds it now: {}", r.reason);
977                    }
978                }
979            }
980            reports.push(r);
981        }
982        reports.sort_by(|a, b| a.path.cmp(&b.path));
983        write_certificate(
984            opts,
985            &m[..m.len().min(12)],
986            &land::document(&plan, &reports, "revisions"),
987        )?;
988        if reports.iter().any(|r| r.status == Status::Refused) {
989            write!(out, "{}", land::render(&plan, &reports))?;
990            writeln!(
991                out,
992                "\nland: REFUSED. The merge {} on your branch does not pass weave's gate (see \
993                 above). Fix the files it names (keep both sides' changes), commit the fix, re-run \
994                 the tests, then run land again.",
995                &m[..m.len().min(12)]
996            )?;
997            return Ok(reports
998                .iter()
999                .filter(|r| r.status == Status::Refused)
1000                .map(|r| r.path.clone())
1001                .collect());
1002        }
1003        checked.add(m)?;
1004    }
1005    Ok(Vec::new())
1006}
1007
1008/// Run the verify command in the repository root on a clean checkout of
1009/// HEAD. `Ok(Err(report))` when it fails or leaves tracked files changed.
1010fn run_verify(
1011    dir: &Path,
1012    cmd: &str,
1013    tip: &str,
1014    limit: Duration,
1015    env: &[(&str, &str)],
1016) -> R<Result<(), String>> {
1017    let mut c = Command::new("sh");
1018    c.arg("-c")
1019        .arg(cmd)
1020        .current_dir(dir)
1021        .env("WEAVE_LAND_ONTO", tip)
1022        .env("WEAVE_LAND_HEAD", rev(dir, "HEAD")?);
1023    for (k, v) in env {
1024        c.env(k, v);
1025    }
1026    let run = gitscan::run_bounded(c, "the verify command", None, limit);
1027    let (status, stdout, stderr) = match run {
1028        Ok(r) => r,
1029        Err(e) => return Ok(Err(format!("land: the verify command did not finish: {e}"))),
1030    };
1031    let tail = |b: &[u8]| {
1032        let t = String::from_utf8_lossy(b);
1033        let lines: Vec<&str> = t.lines().collect();
1034        lines[lines.len().saturating_sub(40)..].join("\n")
1035    };
1036    if !status.success() {
1037        return Ok(Err(format!(
1038            "land: the verify command exited {}:\n{}\n{}",
1039            status
1040                .code()
1041                .map_or("on a signal".into(), |c| c.to_string()),
1042            tail(&stdout),
1043            tail(&stderr)
1044        )));
1045    }
1046    let dirty = git(dir, &["status", "--porcelain", "--untracked-files=no"])?;
1047    if !dirty.is_empty() {
1048        return Ok(Err(format!(
1049            "land: the verify command changed tracked files; the tree it checked is not the tree \
1050             that would be published:\n{}",
1051            indent(&dirty)
1052        )));
1053    }
1054    Ok(Ok(()))
1055}