1use 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
52pub 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 pub check: Option<crate::semcheck::Check>,
61 pub attempts: usize,
62 pub certificate_dir: Option<PathBuf>,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum Outcome {
68 Landed(String),
70 Refused(&'static str),
72}
73
74pub 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
107struct 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
124pub 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
228pub enum Prep {
229 Ready(String),
231 Refused(Refusal),
233}
234
235#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct Refusal {
238 pub reason: &'static str,
243 pub files: Vec<String>,
245 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
260pub 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 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(®ions.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 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 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 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
426fn 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
435pub 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
443pub 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
464pub 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#[derive(Debug, Clone, PartialEq, Eq)]
510pub enum Gated {
511 Committed,
514 Refused(Vec<String>),
516 Aborted,
519}
520
521pub 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 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
594fn 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 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
631fn 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
676fn 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 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 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 #[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
883fn 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 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 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
1008fn 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}