1use camino::{Utf8Path, Utf8PathBuf};
28use serde::Serialize;
29
30use crate::cli::integrate::IntegrateArgs;
31use crate::diagnostic::{Diagnostic, Reason};
32use crate::error::RkError;
33use crate::integrate::{self, Entry, Ledger};
34use crate::landing::{Integration, MergeForm};
35use crate::maintenance::{GIT_HOOK_VARS, last_line};
36use crate::output::Output;
37
38#[derive(Debug, Serialize)]
40struct Report {
41 schema: &'static str,
43 mode: &'static str,
45 target: String,
47 integration: &'static str,
49 authority_source: &'static str,
51 merge_form: &'static str,
53 branch: String,
55 trunk: String,
57 seat: String,
59 trunk_before: String,
61 #[serde(skip_serializing_if = "Option::is_none")]
64 trunk_commit: Option<String>,
65 steps: Vec<String>,
67 next: Vec<String>,
69}
70
71pub fn run(args: &IntegrateArgs) -> Result<(), RkError> {
79 let out = Output::new(args.json);
80 let target = &args.target;
81 let trunk = crate::config::trunk_of(target.as_std_path())?;
82
83 let (integration, authority_source) = authority(args, target)?;
84 let merge_form = recorded_merge_form(target)?;
85 let scope_required = recorded_scope_rule(target)?;
86 if let Some(reason) = integrate::refuse_branch_name(&args.branch, &trunk) {
87 return Err(refuse(Reason::Usage, reason));
88 }
89
90 let git_dir = common_git_dir(target)?;
91 let seats = seats(target)?;
92 let seat = seat_for(&seats, &args.branch, target)?;
93 let dirty = is_dirty(&seat)?;
94 if dirty {
95 return Err(refuse(
96 Reason::StateDrift,
97 format!(
98 "{seat} has uncommitted changes, so the gate would judge a tree nobody reviewed"
99 ),
100 ));
101 }
102
103 let run = LocalRun {
104 target,
105 git_dir: &git_dir,
106 seat: &seat,
107 seats: &seats,
108 trunk: &trunk,
109 authority_source,
110 merge_form,
111 scope_required,
112 };
113 match (integration, merge_form) {
114 (Integration::Forge, _) => forge_path(
115 args,
116 out,
117 &seat,
118 &trunk,
119 authority_source,
120 merge_form,
121 scope_required,
122 ),
123 (Integration::Local, MergeForm::Squash) => local_path(args, out, &run),
124 (Integration::Local, MergeForm::Rebase) => rebase_path(args, out, &run),
125 }
126}
127
128struct LocalRun<'a> {
130 target: &'a Utf8Path,
131 git_dir: &'a Utf8Path,
132 seat: &'a Utf8Path,
133 seats: &'a [crate::worktree::Worktree],
134 trunk: &'a str,
135 authority_source: &'static str,
136 merge_form: MergeForm,
137 scope_required: bool,
140}
141
142fn authority(
154 args: &IntegrateArgs,
155 target: &Utf8Path,
156) -> Result<(Integration, &'static str), RkError> {
157 if args.local {
158 return Ok((Integration::Local, "flag"));
159 }
160 if args.forge {
161 return Ok((Integration::Forge, "flag"));
162 }
163 let recorded = crate::landing::manifest::load(target)?.map(|record| record.git.integration);
164 Ok(recorded.map_or(
165 (crate::landing::manifest::integration_forge(), "default"),
166 |mode| (mode, "record"),
167 ))
168}
169
170fn recorded_merge_form(target: &Utf8Path) -> Result<MergeForm, RkError> {
178 Ok(crate::landing::manifest::load(target)?
179 .map_or_else(crate::landing::manifest::merge_form_squash, |record| {
180 record.git.merge_form
181 }))
182}
183
184fn recorded_scope_rule(target: &Utf8Path) -> Result<bool, RkError> {
190 Ok(crate::landing::manifest::load(target)?
191 .is_none_or(|record| record.profile.release.mode != crate::profile::ReleaseMode::None))
192}
193
194fn forge_path(
196 args: &IntegrateArgs,
197 out: Output,
198 seat: &Utf8Path,
199 trunk: &str,
200 authority_source: &'static str,
201 merge_form: MergeForm,
202 scope_required: bool,
203) -> Result<(), RkError> {
204 let mut steps = Vec::new();
205 if args.apply {
206 let pushed = git(seat, &["push", "--set-upstream", "origin", &args.branch])?;
210 if !pushed.status.success() {
211 return Err(refuse(
212 Reason::RemoteConflict,
213 format!(
214 "pushing {} refused: {}",
215 args.branch,
216 last_line(&pushed.stderr)
217 ),
218 ));
219 }
220 steps.push(format!("pushed {} to origin", args.branch));
221 } else {
222 steps.push(format!("would push {} to origin", args.branch));
223 }
224 let next = request_commands(
225 &origin_url(seat),
226 trunk,
227 &args.branch,
228 merge_form,
229 scope_required,
230 );
231 for line in &steps {
232 out.result_line(line);
233 }
234 out.emit(&Report {
235 schema: "rk.integrate/1",
236 mode: if args.apply { "apply" } else { "preview" },
237 target: args.target.to_string(),
238 integration: Integration::Forge.as_str(),
239 authority_source,
240 merge_form: merge_form.as_str(),
241 branch: args.branch.clone(),
242 trunk: trunk.to_owned(),
243 seat: seat.to_string(),
244 trunk_before: String::new(),
245 trunk_commit: None,
246 steps,
247 next: next.clone(),
248 })?;
249 out.next(&next);
250 Ok(())
251}
252
253fn origin_url(seat: &Utf8Path) -> String {
255 git(seat, &["remote", "get-url", "origin"])
256 .ok()
257 .filter(|answer| answer.status.success())
258 .map(|answer| String::from_utf8_lossy(&answer.stdout).trim().to_owned())
259 .unwrap_or_default()
260}
261
262fn request_commands(
275 remote: &str,
276 trunk: &str,
277 branch: &str,
278 form: MergeForm,
279 scope_required: bool,
280) -> Vec<String> {
281 let squash = form == MergeForm::Squash;
282 let create = if remote.contains("gitlab") {
283 format!(
284 "glab mr create --source-branch {branch} --target-branch {trunk}{}",
285 if squash { " --squash-before-merge" } else { "" }
286 )
287 } else {
288 format!("gh pr create --base {trunk} --head {branch}")
289 };
290 let reaches = match (squash, scope_required) {
291 (true, true) => {
292 "the title is the trunk's commit message, so it is a scoped Conventional Commit"
293 }
294 (true, false) => {
295 "the title is the trunk's commit message, so it is a Conventional Commit, whose scope is preferred and not required"
296 }
297 (false, true) => {
298 "every commit of the branch reaches the trunk with its own message, so each one passes the commit-msg judgment: a scoped Conventional Commit with no guarded content"
299 }
300 (false, false) => {
301 "every commit of the branch reaches the trunk with its own message, so each one passes the commit-msg judgment: a Conventional Commit, whose scope is preferred and not required, with no guarded content"
302 }
303 };
304 vec![
305 create,
306 reaches.to_owned(),
307 "the body is yours; rk message --check --kind body judges it before you post it".to_owned(),
308 ]
309}
310
311fn rebase_onto(run: &LocalRun<'_>, branch: &str, onto: &str) -> Result<(), RkError> {
324 let rebased = git(
325 run.seat,
326 &REBASE_ARGS
327 .iter()
328 .copied()
329 .chain([onto])
330 .collect::<Vec<_>>(),
331 )?;
332 if !rebased.status.success() {
333 let _ = git(run.seat, &["rebase", "--abort"]);
334 return Err(refuse(
335 Reason::StateDrift,
336 format!(
337 "{branch} does not rebase onto {} cleanly: {}",
338 integrate::short(onto),
339 last_line(&rebased.stderr)
340 ),
341 ));
342 }
343 Ok(())
344}
345
346const REBASE_ARGS: [&str; 5] = [
350 "rebase",
351 "--no-rebase-merges",
352 "--no-update-refs",
353 "--no-autosquash",
354 "--no-autostash",
355];
356
357fn refuse_merges_in_range(run: &LocalRun<'_>, from: &str, tip: &str) -> Result<(), RkError> {
365 let listed = git(
366 run.seat,
367 &["rev-list", "--merges", &format!("{from}..{tip}")],
368 )?;
369 if !listed.status.success() {
370 return Err(refuse(
371 Reason::StateDrift,
372 format!(
373 "the range {}..{} could not be read for merge commits: {}",
374 integrate::short(from),
375 integrate::short(tip),
376 last_line(&listed.stderr)
377 ),
378 ));
379 }
380 let merges: Vec<String> = String::from_utf8_lossy(&listed.stdout)
381 .lines()
382 .filter(|line| !line.is_empty())
383 .map(integrate::short)
384 .collect();
385 if merges.is_empty() {
386 return Ok(());
387 }
388 Err(refuse(
389 Reason::StateDrift,
390 format!(
391 "the rebased range {}..{} still holds the merge commit {}, and the trunk takes no merge commit, so nothing was published",
392 integrate::short(from),
393 integrate::short(tip),
394 merges.join(", ")
395 ),
396 ))
397}
398
399fn preview(args: &IntegrateArgs, out: Output, run: &LocalRun<'_>) -> Result<(), RkError> {
405 let mut steps = Vec::new();
406 let trunk_before = rev_parse(run.seat, run.trunk)?;
407 warn_if_uncounted(
408 out,
409 run,
410 args.message.as_deref().unwrap_or_default(),
411 &[&format!("{trunk_before}...{}", args.branch)],
412 true,
413 );
414 steps.push("no fetch and no lock: a preview refreshes nothing".to_owned());
415 steps.push(format!(
416 "would rebase {} onto {}, or onto whatever the fetch brings",
417 args.branch,
418 integrate::short(&trunk_before)
419 ));
420 steps.push("would run the manual stage in the seat".to_owned());
421 steps.push(format!("would write one squash commit onto {}", run.trunk));
422 report(
423 args,
424 out,
425 run,
426 &trunk_before,
427 None,
428 steps,
429 &[format!(
430 "rk integrate {} --target {} --apply performs it",
431 args.branch, run.target
432 )],
433 )
434}
435
436fn local_path(args: &IntegrateArgs, out: Output, run: &LocalRun<'_>) -> Result<(), RkError> {
444 let message = args.message.as_deref().unwrap_or_default();
445 refuse_message(run.seat, message, run.scope_required)?;
446 let mut steps = Vec::new();
447
448 if !args.apply {
449 return preview(args, out, run);
450 }
451
452 let _held = crate::landing::lock::acquire(run.git_dir)?;
455
456 let ledger_path = run.git_dir.join(integrate::LEDGER_PATH);
460 let mut ledger = read_ledger(&ledger_path)?;
461
462 refresh_trunk(run, &mut steps)?;
463
464 let trunk_before = rev_parse(run.seat, run.trunk)?;
465
466 rebase_onto(run, &args.branch, &trunk_before)?;
469 steps.push(format!(
470 "rebased {} onto {}",
471 args.branch,
472 integrate::short(&trunk_before)
473 ));
474
475 let branch_tip = rev_parse(run.seat, &args.branch)?;
480
481 warn_if_uncounted(out, run, message, &[&trunk_before, &branch_tip], false);
484
485 let (runner, gate) = gate(run.seat)?;
487 if !gate.status.success() {
488 out.child_passthrough(crate::events::ChildStream::Stderr, &gate.stderr);
489 out.child_passthrough(crate::events::ChildStream::Stdout, &gate.stdout);
490 return Err(refuse(
491 Reason::StateDrift,
492 format!(
493 "the manual stage failed in {} under {runner}, so nothing reached {}",
494 run.seat, run.trunk
495 ),
496 ));
497 }
498 steps.push(format!("the manual stage passed under {runner}"));
499
500 let now = rev_parse(run.seat, &args.branch)?;
506 if now != branch_tip {
507 return Err(refuse(
508 Reason::StateDrift,
509 format!(
510 "{} moved from {} to {} while the gate ran, so the gate judged a tree that is no longer the branch's",
511 args.branch,
512 integrate::short(&branch_tip),
513 integrate::short(&now)
514 ),
515 ));
516 }
517 let commit = build_squash(run, &branch_tip, &trunk_before, message)?;
518 ledger.record(Entry {
527 branch: args.branch.clone(),
528 branch_tip,
529 trunk_commit: commit.clone(),
530 at: crate::landing::manifest::now(),
531 });
532 write_ledger(&ledger_path, &ledger)?;
533
534 let drift = publish(run, &commit, &trunk_before)?;
535 steps.push(format!(
536 "{} now carries {}",
537 run.trunk,
538 integrate::short(&commit)
539 ));
540 steps.push("recorded the integration".to_owned());
541 if let Some(drift) = &drift {
542 steps.push(drift.step(run.trunk));
543 }
544
545 report(
546 args,
547 out,
548 run,
549 &trunk_before,
550 Some(commit.clone()),
551 steps,
552 &[
553 format!(
554 "git -C {} push origin {} pushes the trunk; it is a fast-forward and never a force",
555 run.target, run.trunk
556 ),
557 format!(
558 "rk worktree prune --target {} retires the seat, then its branch",
559 run.target
560 ),
561 ],
562 )?;
563 drift.map_or(Ok(()), |drift| Err(drift.into_error(run.trunk, &commit)))
564}
565
566fn rebase_path(args: &IntegrateArgs, out: Output, run: &LocalRun<'_>) -> Result<(), RkError> {
577 refuse_message_under_rebase(args)?;
578 if !args.apply {
579 return rebase_preview(args, out, run);
580 }
581 let mut steps = Vec::new();
582
583 let _held = crate::landing::lock::acquire(run.git_dir)?;
584 let ledger_path = run.git_dir.join(integrate::LEDGER_PATH);
585 let mut ledger = read_ledger(&ledger_path)?;
586
587 refresh_trunk(run, &mut steps)?;
588
589 let trunk_before = rev_parse(run.seat, run.trunk)?;
590
591 rebase_onto(run, &args.branch, &trunk_before)?;
592 steps.push(format!(
593 "rebased {} onto {}",
594 args.branch,
595 integrate::short(&trunk_before)
596 ));
597
598 let branch_tip = rev_parse(run.seat, &args.branch)?;
601 let commits = branch_commits(run.seat, &trunk_before, &branch_tip, &args.branch)?;
602 refuse_commit_messages(run.seat, &commits, run.scope_required)?;
603 steps.push(format!(
604 "judged the message of each of {} commit{}",
605 commits.len(),
606 if commits.len() == 1 { "" } else { "s" }
607 ));
608
609 let (runner, gate) = gate(run.seat)?;
610 if !gate.status.success() {
611 out.child_passthrough(crate::events::ChildStream::Stderr, &gate.stderr);
612 out.child_passthrough(crate::events::ChildStream::Stdout, &gate.stdout);
613 return Err(refuse(
614 Reason::StateDrift,
615 format!(
616 "the manual stage failed in {} under {runner}, so nothing reached {}",
617 run.seat, run.trunk
618 ),
619 ));
620 }
621 steps.push(format!("the manual stage passed under {runner}"));
622
623 let now = rev_parse(run.seat, &args.branch)?;
624 if now != branch_tip {
625 return Err(refuse(
626 Reason::StateDrift,
627 format!(
628 "{} moved from {} to {} while the gate ran, so the gate judged a tree that is no longer the branch's",
629 args.branch,
630 integrate::short(&branch_tip),
631 integrate::short(&now)
632 ),
633 ));
634 }
635
636 refuse_merges_in_range(run, &trunk_before, &branch_tip)?;
639
640 ledger.record(Entry {
645 branch: args.branch.clone(),
646 branch_tip: branch_tip.clone(),
647 trunk_commit: branch_tip.clone(),
648 at: crate::landing::manifest::now(),
649 });
650 write_ledger(&ledger_path, &ledger)?;
651
652 let drift = publish(run, &branch_tip, &trunk_before)?;
653 steps.push(format!(
654 "{} now carries {} at {}, with no new commit",
655 run.trunk,
656 args.branch,
657 integrate::short(&branch_tip)
658 ));
659 steps.push("recorded the integration".to_owned());
660 if let Some(drift) = &drift {
661 steps.push(drift.step(run.trunk));
662 }
663
664 report(
665 args,
666 out,
667 run,
668 &trunk_before,
669 Some(branch_tip.clone()),
670 steps,
671 &[
672 format!(
673 "git -C {} push origin {} pushes the trunk; it is a fast-forward and never a force",
674 run.target, run.trunk
675 ),
676 format!(
677 "rk worktree prune --target {} retires the seat, then its branch",
678 run.target
679 ),
680 ],
681 )?;
682 drift.map_or(
683 Ok(()),
684 |drift| Err(drift.into_error(run.trunk, &branch_tip)),
685 )
686}
687
688fn rebase_preview(args: &IntegrateArgs, out: Output, run: &LocalRun<'_>) -> Result<(), RkError> {
693 let trunk_before = rev_parse(run.seat, run.trunk)?;
694 let branch_tip = rev_parse(run.seat, &args.branch)?;
695 let commits = branch_commits(run.seat, &trunk_before, &branch_tip, &args.branch)?;
696 refuse_commit_messages(run.seat, &commits, run.scope_required)?;
697 let steps = vec![
698 "no fetch and no lock: a preview refreshes nothing".to_owned(),
699 format!(
700 "would rebase {} onto {}, or onto whatever the fetch brings",
701 args.branch,
702 integrate::short(&trunk_before)
703 ),
704 "would run the manual stage in the seat".to_owned(),
705 format!(
706 "would move {} to the tip of {}, carrying its {} commit{} with their own messages and writing no new commit",
707 run.trunk,
708 args.branch,
709 commits.len(),
710 if commits.len() == 1 { "" } else { "s" }
711 ),
712 ];
713 report(
714 args,
715 out,
716 run,
717 &trunk_before,
718 None,
719 steps,
720 &[format!(
721 "rk integrate {} --target {} --apply performs it",
722 args.branch, run.target
723 )],
724 )
725}
726
727fn refuse_message_under_rebase(args: &IntegrateArgs) -> Result<(), RkError> {
733 if args.message.is_none() {
734 return Ok(());
735 }
736 Err(RkError::refusal(
737 Diagnostic::new(
738 Reason::MergeFormMismatch,
739 "-m names a trunk message, and the recorded merge form is rebase: every commit of the branch reaches the trunk with its own message, and no commit is written to carry this one",
740 )
741 .expected("no -m under git.merge_form = \"rebase\"")
742 .action(format!(
743 "rk integrate {} --apply integrates without it; git rebase -i on the branch rewords a commit",
744 args.branch
745 ))
746 .target_state("unchanged; the trunk stands where it stood"),
747 ))
748}
749
750struct BranchCommit {
752 id: String,
753 message: String,
754}
755
756fn branch_commits(
761 seat: &Utf8Path,
762 trunk: &str,
763 tip: &str,
764 branch: &str,
765) -> Result<Vec<BranchCommit>, RkError> {
766 let listed = git(seat, &["rev-list", "--reverse", &format!("{trunk}..{tip}")])?;
767 if !listed.status.success() {
768 return Err(refuse(
769 Reason::StateDrift,
770 format!(
771 "the commits of {branch} could not be listed: {}",
772 last_line(&listed.stderr)
773 ),
774 ));
775 }
776 let ids: Vec<String> = String::from_utf8_lossy(&listed.stdout)
777 .lines()
778 .filter(|line| !line.is_empty())
779 .map(str::to_owned)
780 .collect();
781 if ids.is_empty() {
782 return Err(refuse(
783 Reason::StateDrift,
784 format!(
785 "{branch} carries no commit {} does not already carry, so there is nothing to integrate",
786 integrate::short(trunk)
787 ),
788 ));
789 }
790 ids.into_iter()
791 .map(|id| {
792 let shown = git(seat, &["log", "-1", "--format=%B", &id])?;
793 if !shown.status.success() {
794 return Err(refuse(
795 Reason::StateDrift,
796 format!(
797 "the message of {} could not be read: {}",
798 integrate::short(&id),
799 last_line(&shown.stderr)
800 ),
801 ));
802 }
803 Ok(BranchCommit {
804 message: String::from_utf8_lossy(&shown.stdout).into_owned(),
805 id,
806 })
807 })
808 .collect()
809}
810
811fn refuse_commit_messages(
821 at: &Utf8Path,
822 commits: &[BranchCommit],
823 scope_required: bool,
824) -> Result<(), RkError> {
825 for commit in commits {
826 let subject = commit.message.lines().next().unwrap_or("").trim();
827 let named = format!("commit {}", integrate::short(&commit.id));
828 if subject.is_empty() {
829 return Err(refuse(
830 Reason::Usage,
831 format!(
832 "{named} carries no message, and every commit reaches the trunk with its own"
833 ),
834 ));
835 }
836 if let Some(reason) = misshapen_subject(subject, scope_required) {
837 return Err(refuse(Reason::Usage, format!("{named}: {reason}")));
838 }
839 let (findings, _) = crate::commands::message::judge(
840 &commit.message,
841 crate::cli::message::MessageKind::Commit,
842 at,
843 subject,
844 crate::commands::message::Judgment {
845 exempt: crate::commands::message::exempt_title(subject),
846 scope_required,
847 },
848 );
849 if findings.is_empty() {
850 continue;
851 }
852 let listed: Vec<String> = findings
853 .iter()
854 .map(|finding| format!("{}:{} {}", finding.class, finding.line, finding.detail))
855 .collect();
856 return Err(refuse(
857 Reason::Usage,
858 format!(
859 "{named} '{subject}' carries {} finding{} the landed commit-msg stage would refuse, and the trunk move fires no hook: {}",
860 findings.len(),
861 if findings.len() == 1 { "" } else { "s" },
862 listed.join("; ")
863 ),
864 ));
865 }
866 Ok(())
867}
868
869fn build_squash(
876 run: &LocalRun<'_>,
877 branch_tip: &str,
878 parent: &str,
879 message: &str,
880) -> Result<String, RkError> {
881 let tree = rev_parse(run.seat, &format!("{branch_tip}^{{tree}}"))?;
882 let built = git(
883 run.seat,
884 &["commit-tree", &tree, "-p", parent, "-m", message],
885 )?;
886 if !built.status.success() {
887 return Err(refuse(
888 Reason::Internal,
889 format!(
890 "the squash commit could not be built: {}",
891 last_line(&built.stderr)
892 ),
893 ));
894 }
895 Ok(String::from_utf8_lossy(&built.stdout).trim().to_owned())
896}
897
898fn refresh_trunk(run: &LocalRun<'_>, steps: &mut Vec<String>) -> Result<(), RkError> {
905 let named = git(run.seat, &["remote", "get-url", "origin"])?;
911 if !named.status.success() {
912 steps.push("no origin remote; the local trunk stands alone".to_owned());
913 return Ok(());
914 }
915 let fetched = git(run.seat, &["fetch", "--quiet", "origin"])?;
916 if !fetched.status.success() {
917 return Err(refuse(
918 Reason::ForgeTemporary,
919 format!(
920 "origin is configured and did not answer, so the trunk could not be refreshed: {}",
921 last_line(&fetched.stderr)
922 ),
923 ));
924 }
925 steps.push("fetched origin".to_owned());
926 let Some(remote) = rev_parse(run.seat, &format!("refs/remotes/origin/{}", run.trunk)).ok()
927 else {
928 steps.push(format!(
929 "origin carries no {} yet; the local trunk stands alone",
930 run.trunk
931 ));
932 return Ok(());
933 };
934 let local = rev_parse(run.seat, run.trunk)?;
935 let state = integrate::trunk_state(
936 local == remote,
937 is_ancestor(run.seat, &local, &remote),
938 is_ancestor(run.seat, &remote, &local),
939 );
940 if let Some(reason) = integrate::refuse_trunk_state(state) {
941 return Err(refuse(Reason::RemoteConflict, reason));
942 }
943 match state {
944 integrate::TrunkState::Behind => {
945 fast_forward_trunk(run, &remote, &local)?;
946 steps.push(format!(
947 "fast-forwarded {} to origin/{}",
948 run.trunk, run.trunk
949 ));
950 }
951 integrate::TrunkState::Ahead => {
952 steps.push(format!(
953 "{} carries integrations nobody pushed yet",
954 run.trunk
955 ));
956 }
957 integrate::TrunkState::Level | integrate::TrunkState::Diverged => {}
958 }
959 Ok(())
960}
961
962fn fast_forward_trunk(run: &LocalRun<'_>, to: &str, from: &str) -> Result<(), RkError> {
970 let PostPublication::Unsettled(unsettled) = trunk_move(run, to, from, "fast-forward")? else {
971 return Ok(());
972 };
973 Err(unsettled.into_refresh_error(run.trunk, from, to))
974}
975
976fn publish(run: &LocalRun<'_>, commit: &str, expected: &str) -> Result<Option<Unsettled>, RkError> {
983 Ok(match trunk_move(run, commit, expected, "integration")? {
984 PostPublication::Confirmed => None,
985 PostPublication::Unsettled(unsettled) => Some(unsettled),
986 })
987}
988
989#[derive(Debug, Clone, PartialEq, Eq)]
997enum PostPublication {
998 Confirmed,
1001 Unsettled(Unsettled),
1003}
1004
1005#[derive(Debug, Clone, PartialEq, Eq)]
1007enum Unsettled {
1008 Raced(Drift),
1011 Unconfirmed {
1013 expected: String,
1015 reason: String,
1017 },
1018}
1019
1020impl Unsettled {
1021 fn step(&self, trunk: &str) -> String {
1023 match self {
1024 Self::Raced(drift) => drift.step(trunk),
1025 Self::Unconfirmed { expected, reason } => format!(
1026 "{trunk}'s move from the observed {} is not confirmed: {reason}",
1027 integrate::short(expected)
1028 ),
1029 }
1030 }
1031
1032 fn into_error(self, trunk: &str, published: &str) -> RkError {
1036 match self {
1037 Self::Raced(drift) => drift.into_error(trunk, published),
1038 Self::Unconfirmed { expected, reason } => {
1039 unconfirmed_error(trunk, &expected, published, &reason)
1040 }
1041 }
1042 }
1043
1044 fn into_refresh_error(self, trunk: &str, from: &str, to: &str) -> RkError {
1048 let message = match &self {
1049 Self::Raced(drift) => format!(
1050 "{trunk} was fast-forwarded to its remote tip {}, and its last move went from {} to {} where this run observed {}: a writer outside rk moved it at the same time, so no integration was published",
1051 integrate::short(to),
1052 integrate::short(&drift.found_old),
1053 integrate::short(&drift.found_new),
1054 integrate::short(from)
1055 ),
1056 Self::Unconfirmed { reason, .. } => format!(
1057 "{trunk} was fast-forwarded to its remote tip {}, but rk could not confirm that the move started at the observed {}: {reason}; no integration was published",
1058 integrate::short(to),
1059 integrate::short(from)
1060 ),
1061 };
1062 RkError::refusal(
1063 Diagnostic::new(Reason::StateDrift, message)
1064 .action(format!(
1065 "read git reflog {trunk} and git log {}..{}, then run the integration again",
1066 integrate::short(from),
1067 integrate::short(to)
1068 ))
1069 .target_state(format!(
1070 "{trunk} was refreshed to its remote tip {}, with no rollback; no integration was published",
1071 integrate::short(to)
1072 )),
1073 )
1074 }
1075}
1076
1077fn unconfirmed_error(trunk: &str, expected: &str, published: &str, reason: &str) -> RkError {
1079 let expected = integrate::short(expected);
1080 let published = integrate::short(published);
1081 RkError::check_failed(
1082 Diagnostic::new(
1083 Reason::StateDrift,
1084 format!(
1085 "{trunk} now stands at the published {published}, but rk could not confirm that the move started at the observed {expected}: {reason}"
1086 ),
1087 )
1088 .expected(format!("a confirmed move of {trunk} from {expected} to {published}"))
1089 .action(format!(
1090 "read git reflog {trunk} and git log {expected}..{published}, and decide whether anything else moved {trunk}; rk moves nothing back"
1091 ))
1092 .target_state(format!(
1093 "{trunk} is published at {published}, with no rollback and no reset; the staged evidence names {published} and the trunk reaches it"
1094 )),
1095 )
1096}
1097
1098#[derive(Debug, Clone, PartialEq, Eq)]
1100struct Drift {
1101 expected: String,
1103 found_old: String,
1105 found_new: String,
1107}
1108
1109impl Drift {
1110 fn step(&self, trunk: &str) -> String {
1112 format!(
1113 "{trunk}'s last move went from {} to {}, where this run observed {}",
1114 integrate::short(&self.found_old),
1115 integrate::short(&self.found_new),
1116 integrate::short(&self.expected)
1117 )
1118 }
1119
1120 fn into_error(self, trunk: &str, published: &str) -> RkError {
1124 RkError::check_failed(
1125 Diagnostic::new(
1126 Reason::StateDrift,
1127 format!(
1128 "{trunk} now stands at the published {}, and its last move went from {} to {}, not from the observed {}: a writer outside rk moved it during the publication",
1129 integrate::short(published),
1130 integrate::short(&self.found_old),
1131 integrate::short(&self.found_new),
1132 integrate::short(&self.expected)
1133 ),
1134 )
1135 .expected(format!(
1136 "a move of {trunk} from the observed {} to the published {}",
1137 integrate::short(&self.expected),
1138 integrate::short(published)
1139 ))
1140 .action(format!(
1141 "read the commits between the observed and the found starting commit, git log {}..{}, and decide whether they belong on {trunk}; rk moves nothing back",
1142 integrate::short(&self.expected),
1143 integrate::short(&self.found_old)
1144 ))
1145 .target_state(format!(
1146 "{trunk} is published at {}, with no rollback and no reset; the staged evidence names {} and the trunk reaches it",
1147 integrate::short(published),
1148 integrate::short(published)
1149 )),
1150 )
1151 }
1152}
1153
1154fn trunk_move(
1171 run: &LocalRun<'_>,
1172 to: &str,
1173 expected: &str,
1174 what: &str,
1175) -> Result<PostPublication, RkError> {
1176 let observed = run
1177 .seats
1178 .iter()
1179 .find(|seat| seat.branch.as_deref() == Some(run.trunk))
1180 .map(|seat| seat.path.clone());
1181 let live_seats = seats(run.target)?;
1182 let holder = live_seats
1183 .iter()
1184 .find(|seat| seat.branch.as_deref() == Some(run.trunk));
1185 if let Some(changed) = holder_change(
1186 run.trunk,
1187 observed.as_deref(),
1188 holder.map(|seat| seat.path.as_path()),
1189 what,
1190 ) {
1191 return Err(refuse(Reason::StateDrift, changed));
1192 }
1193 let reference = format!("refs/heads/{}", run.trunk);
1194 let live = rev_parse(run.seat, &reference)?;
1195 if live != expected {
1196 return Err(moved(run.trunk, expected, &live));
1197 }
1198 let Some(holder) = holder else {
1199 let swapped = git(run.seat, &["update-ref", &reference, to, expected])?;
1200 if !swapped.status.success() {
1201 let now =
1202 rev_parse(run.seat, &reference).unwrap_or_else(|_| "an unreadable tip".to_owned());
1203 return Err(moved(run.trunk, expected, &now));
1204 }
1205 return Ok(PostPublication::Confirmed);
1206 };
1207 if is_dirty(&holder.path)? {
1208 return Err(refuse(
1209 Reason::StateDrift,
1210 format!(
1211 "{} has the trunk checked out and carries uncommitted changes, so the {what} would leave it behind its own HEAD",
1212 holder.path
1213 ),
1214 ));
1215 }
1216 let merged = git_in_c_locale(
1220 &holder.path,
1221 &[
1222 "-c",
1223 "core.logAllRefUpdates=always",
1224 "-c",
1225 "core.abbrev=no",
1226 "merge",
1227 "--ff-only",
1228 to,
1229 ],
1230 )?;
1231 if !merged.status.success() {
1232 return Err(refuse(
1233 Reason::StateDrift,
1234 format!(
1235 "the {what} is not a fast-forward of {}, so nothing was published: {}",
1236 holder.path,
1237 last_line(&merged.stderr)
1238 ),
1239 ));
1240 }
1241 let report = String::from_utf8_lossy(&merged.stdout).into_owned();
1242 Ok(confirm_move(run, &reference, expected, to, &report))
1243}
1244
1245fn confirm_move(
1258 run: &LocalRun<'_>,
1259 reference: &str,
1260 expected: &str,
1261 to: &str,
1262 merge_report: &str,
1263) -> PostPublication {
1264 let reflog = reflog_transition(run, reference);
1265 let reported = parse_updating(merge_report, expected.len());
1266 judge_move(expected, to, reflog, reported)
1267}
1268
1269fn judge_move(
1271 expected: &str,
1272 to: &str,
1273 reflog: Result<(String, String), String>,
1274 reported: Result<(String, String), String>,
1275) -> PostPublication {
1276 let unconfirmed = |reason: String| {
1277 PostPublication::Unsettled(Unsettled::Unconfirmed {
1278 expected: expected.to_owned(),
1279 reason,
1280 })
1281 };
1282 let transition = match (reflog, reported) {
1283 (Ok(logged), Ok(said)) if logged != said => {
1284 return unconfirmed(format!(
1285 "the reflog shows {}..{} and the fast-forward reported {}..{}",
1286 integrate::short(&logged.0),
1287 integrate::short(&logged.1),
1288 integrate::short(&said.0),
1289 integrate::short(&said.1)
1290 ));
1291 }
1292 (Ok(logged), _) => logged,
1293 (Err(_), Ok(said)) => said,
1294 (Err(logged), Err(said)) => {
1295 return unconfirmed(format!("{logged}, and {said}"));
1296 }
1297 };
1298 let (old, new) = transition;
1299 if new != to {
1300 return unconfirmed(format!(
1301 "the move read back ends at {}, not at the published {}",
1302 integrate::short(&new),
1303 integrate::short(to)
1304 ));
1305 }
1306 if old == expected {
1307 return PostPublication::Confirmed;
1308 }
1309 PostPublication::Unsettled(Unsettled::Raced(Drift {
1310 expected: expected.to_owned(),
1311 found_old: old,
1312 found_new: new,
1313 }))
1314}
1315
1316fn full_name(name: &str, len: usize) -> bool {
1318 name.len() == len && name.bytes().all(|byte| byte.is_ascii_hexdigit())
1319}
1320
1321fn reflog_transition(run: &LocalRun<'_>, reference: &str) -> Result<(String, String), String> {
1329 let path = run.git_dir.join("logs").join(reference);
1330 match std::fs::read_to_string(path.as_std_path()) {
1331 Ok(text) => {
1332 let line = text.lines().last().unwrap_or_default();
1333 let mut fields = line.split_whitespace();
1334 match (fields.next(), fields.next()) {
1335 (Some(old), Some(new))
1336 if full_name(old, new.len()) && full_name(new, old.len()) =>
1337 {
1338 Ok((old.to_owned(), new.to_owned()))
1339 }
1340 _ => Err(format!("the reflog file {path} holds no readable entry")),
1341 }
1342 }
1343 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1344 let listed = git(
1345 run.seat,
1346 &["reflog", "show", "--format=%H", "-n", "2", reference],
1347 )
1348 .map_err(|error| format!("git reflog did not run: {error}"))?;
1349 if !listed.status.success() {
1350 return Err(format!(
1351 "git reflog show {reference} failed: {}",
1352 last_line(&listed.stderr)
1353 ));
1354 }
1355 let text = String::from_utf8_lossy(&listed.stdout).into_owned();
1356 let mut lines = text.lines();
1357 match (lines.next(), lines.next()) {
1358 (Some(new), Some(old))
1359 if full_name(new, old.len()) && full_name(old, new.len()) =>
1360 {
1361 Ok((old.to_owned(), new.to_owned()))
1362 }
1363 _ => Err(format!(
1364 "the reflog of {reference} holds fewer than two readable entries"
1365 )),
1366 }
1367 }
1368 Err(error) => Err(format!("the reflog file {path} could not be read: {error}")),
1369 }
1370}
1371
1372fn parse_updating(report: &str, len: usize) -> Result<(String, String), String> {
1377 let lines: Vec<&str> = report
1378 .lines()
1379 .filter_map(|line| line.strip_prefix("Updating "))
1380 .collect();
1381 let [line] = lines.as_slice() else {
1382 return Err(if lines.is_empty() {
1383 "the fast-forward reported no Updating line".to_owned()
1384 } else {
1385 "the fast-forward reported more than one Updating line".to_owned()
1386 });
1387 };
1388 let Some((old, new)) = line.trim().split_once("..") else {
1389 return Err(format!(
1390 "the fast-forward's Updating line is malformed: {line}"
1391 ));
1392 };
1393 if !full_name(old, len) || !full_name(new, len) {
1394 return Err(format!(
1395 "the fast-forward's Updating line names no full object names: {line}"
1396 ));
1397 }
1398 Ok((old.to_owned(), new.to_owned()))
1399}
1400
1401fn holder_change(
1403 trunk: &str,
1404 observed: Option<&Utf8Path>,
1405 live: Option<&Utf8Path>,
1406 what: &str,
1407) -> Option<String> {
1408 match (observed, live) {
1409 (None, None) => None,
1410 (Some(before), Some(now)) if before == now => None,
1411 (None, Some(now)) => Some(format!(
1412 "{trunk} was checked out in {now} after this run observed it, so the {what} would leave that worktree behind its own HEAD; nothing was published"
1413 )),
1414 (Some(before), None) => Some(format!(
1415 "{trunk} stopped being checked out in {before} after this run observed it, so the {what} no longer has the worktree it was planned for; nothing was published"
1416 )),
1417 (Some(before), Some(now)) => Some(format!(
1418 "{trunk} moved from being checked out in {before} to {now} after this run observed it; nothing was published"
1419 )),
1420 }
1421}
1422
1423fn moved(trunk: &str, expected: &str, now: &str) -> RkError {
1430 RkError::refusal(
1431 Diagnostic::new(
1432 Reason::StateDrift,
1433 integrate::refuse_moved_trunk(expected, now).unwrap_or_else(|| {
1434 format!(
1435 "{trunk} could not be moved and stands at {}",
1436 integrate::short(now)
1437 )
1438 }),
1439 )
1440 .target_state(format!(
1441 "{trunk} stands where it stood; the staged evidence names a commit it does not reach, which every prune ignores"
1442 )),
1443 )
1444}
1445
1446fn warn_if_uncounted(
1460 out: Output,
1461 run: &LocalRun<'_>,
1462 message: &str,
1463 range: &[&str],
1464 previewed: bool,
1465) {
1466 let Some(intent) = integrate::release_intent(message) else {
1467 return;
1468 };
1469 let rust_release = crate::landing::manifest::load(run.target)
1470 .ok()
1471 .flatten()
1472 .is_some_and(|record| {
1473 let release = &record.profile.release;
1474 release.mode == crate::profile::ReleaseMode::Automatic
1475 && release.driver.as_deref() == Some("rust")
1476 });
1477 if !rust_release {
1478 return;
1479 }
1480 let mut diff = vec!["diff", "--name-only", "--no-renames", "-z"];
1481 diff.extend_from_slice(range);
1482 let Ok(changed) = git(run.seat, &diff) else {
1483 return;
1484 };
1485 if !changed.status.success() {
1486 return;
1487 }
1488 let changed = String::from_utf8_lossy(&changed.stdout).into_owned();
1489 let changed: Vec<&str> = changed
1490 .split('\0')
1491 .filter(|path| !path.is_empty())
1492 .collect();
1493 let root_manifest = run.seat.as_std_path().join("Cargo.toml");
1494 let probe = crate::cargo_package::probe(&root_manifest, &["--locked", "--offline"], |args| {
1495 cargo_in(run.seat, args)
1496 });
1497 let Ok(crate::cargo_package::Probe::Listed(listing)) = probe else {
1498 return;
1499 };
1500 if !listing.touched_by(changed.iter().copied()) {
1501 out.warn(integrate::uncounted_release(intent, previewed));
1502 }
1503}
1504
1505fn cargo_in(seat: &Utf8Path, args: &[&str]) -> Result<crate::cargo_package::Answer, ()> {
1507 let mut command = std::process::Command::new("cargo");
1508 for var in GIT_HOOK_VARS {
1509 command.env_remove(var);
1510 }
1511 command
1512 .current_dir(seat.as_std_path())
1513 .args(args)
1514 .stdin(std::process::Stdio::null())
1515 .output()
1516 .map(|output| crate::cargo_package::Answer {
1517 success: output.status.success(),
1518 stdout: output.stdout,
1519 stderr: output.stderr,
1520 })
1521 .map_err(|_| ())
1522}
1523
1524fn hook_runner() -> Option<(&'static str, std::path::PathBuf)> {
1530 ["prek", "pre-commit"]
1531 .into_iter()
1532 .find_map(|name| program_on_path(name).map(|path| (name, path)))
1533}
1534
1535fn program_on_path(name: &str) -> Option<std::path::PathBuf> {
1540 let path = std::env::var_os("PATH")?;
1541 let cwd = std::env::current_dir().ok();
1542 std::env::split_paths(&path).find_map(|dir| {
1543 let dir = if dir.is_relative() {
1544 cwd.as_ref()?.join(dir)
1545 } else {
1546 dir
1547 };
1548 let candidate = dir.join(name);
1549 let executable = std::fs::metadata(&candidate).is_ok_and(|meta| {
1550 #[cfg(unix)]
1551 {
1552 use std::os::unix::fs::PermissionsExt as _;
1553 meta.is_file() && meta.permissions().mode() & 0o111 != 0
1554 }
1555 #[cfg(not(unix))]
1556 {
1557 meta.is_file()
1558 }
1559 });
1560 executable.then_some(candidate)
1561 })
1562}
1563
1564fn gate(seat: &Utf8Path) -> Result<(&'static str, std::process::Output), RkError> {
1567 let unrunnable = |message: String| {
1568 RkError::subprocess(
1569 Diagnostic::new(Reason::SubprocessSpawn, message)
1570 .expected(
1571 "prek or pre-commit on PATH, the hook runner that installs and runs this project's hooks",
1572 )
1573 .action("install prek on the host, or enter the project's environment that carries pre-commit, and run it again")
1574 .target_state("unchanged"),
1575 )
1576 };
1577 let (runner, executable) = hook_runner()
1578 .ok_or_else(|| unrunnable(format!("no hook runner is on PATH to run in {seat}")))?;
1579 let mut command = std::process::Command::new(&executable);
1580 for var in GIT_HOOK_VARS {
1581 command.env_remove(var);
1582 }
1583 command
1584 .current_dir(seat.as_std_path())
1585 .args(["run", "--hook-stage", "manual", "--all-files"])
1586 .output()
1587 .map(|output| (runner, output))
1588 .map_err(|source| unrunnable(format!("{runner} did not run in {seat}: {source}")))
1589}
1590
1591fn report(
1593 args: &IntegrateArgs,
1594 out: Output,
1595 run: &LocalRun<'_>,
1596 trunk_before: &str,
1597 trunk_commit: Option<String>,
1598 steps: Vec<String>,
1599 next: &[String],
1600) -> Result<(), RkError> {
1601 for step in &steps {
1602 out.result_line(step);
1603 }
1604 out.emit(&Report {
1605 schema: "rk.integrate/1",
1606 mode: if args.apply { "apply" } else { "preview" },
1607 target: run.target.to_string(),
1608 integration: Integration::Local.as_str(),
1609 authority_source: run.authority_source,
1610 merge_form: run.merge_form.as_str(),
1611 branch: args.branch.clone(),
1612 trunk: run.trunk.to_owned(),
1613 seat: run.seat.to_string(),
1614 trunk_before: trunk_before.to_owned(),
1615 trunk_commit,
1616 steps,
1617 next: next.to_vec(),
1618 })?;
1619 out.next(next);
1620 Ok(())
1621}
1622
1623const SHAPE: &str = "a scoped Conventional Commit: <type>(<scope>): <description>";
1625
1626const SHAPE_SCOPE_PREFERRED: &str =
1629 "a Conventional Commit: <type>(<scope>): <description>, or <type>: <description>";
1630
1631const TYPES: [&str; 11] = [
1634 "build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test",
1635];
1636
1637fn refuse_message(target: &Utf8Path, text: &str, scope_required: bool) -> Result<(), RkError> {
1658 let subject = text.lines().next().unwrap_or("").trim();
1659 if subject.is_empty() {
1660 return Err(refuse(
1661 Reason::Usage,
1662 "a local integration writes the trunk's commit message, so --message is required",
1663 ));
1664 }
1665 if let Some(reason) = misshapen_subject(subject, scope_required) {
1666 return Err(refuse(Reason::Usage, reason));
1667 }
1668 let (findings, _) = crate::commands::message::judge(
1669 text,
1670 crate::cli::message::MessageKind::Commit,
1671 target,
1672 subject,
1673 crate::commands::message::Judgment {
1674 exempt: crate::commands::message::exempt_title(subject),
1675 scope_required,
1676 },
1677 );
1678 if findings.is_empty() {
1679 return Ok(());
1680 }
1681 let named: Vec<String> = findings
1682 .iter()
1683 .map(|finding| format!("{}:{} {}", finding.class, finding.line, finding.detail))
1684 .collect();
1685 Err(refuse(
1686 Reason::Usage,
1687 format!(
1688 "the trunk message carries {} finding{} the landed commit-msg stage would refuse, and git commit-tree fires no hook: {}",
1689 findings.len(),
1690 if findings.len() == 1 { "" } else { "s" },
1691 named.join("; ")
1692 ),
1693 ))
1694}
1695
1696#[must_use]
1700fn misshapen_subject(subject: &str, scope_required: bool) -> Option<String> {
1701 let shape = if scope_required {
1702 SHAPE
1703 } else {
1704 SHAPE_SCOPE_PREFERRED
1705 };
1706 let Some((head, description)) = subject.split_once(": ") else {
1707 return Some(format!("'{subject}' is not {shape}"));
1708 };
1709 if description.trim().is_empty() {
1710 return Some(format!(
1711 "'{subject}' is not {shape}: it states no description"
1712 ));
1713 }
1714 let head = head.strip_suffix('!').unwrap_or(head);
1717 let (kind, scope) = match head.split_once('(') {
1718 None if scope_required => {
1719 return Some(format!("'{subject}' is not {shape}: it names no scope"));
1720 }
1721 None => (head, None),
1722 Some((kind, scope)) => {
1723 let Some(scope) = scope.strip_suffix(')') else {
1724 return Some(format!("'{subject}' is not {shape}: its scope is unclosed"));
1725 };
1726 (kind, Some(scope))
1727 }
1728 };
1729 if !TYPES.contains(&kind) {
1730 return Some(format!(
1731 "'{kind}' is not a Conventional Commit type; the types are: {}",
1732 TYPES.join(", ")
1733 ));
1734 }
1735 if let Some(scope) = scope
1736 && !crate::projection::scope_is_shaped(scope)
1737 {
1738 return Some(format!(
1739 "the scope '{scope}' is outside {}: lowercase letters, digits, and _ . / -",
1740 crate::projection::SCOPE_SHAPE
1741 ));
1742 }
1743 None
1744}
1745
1746fn seats(target: &Utf8Path) -> Result<Vec<crate::worktree::Worktree>, RkError> {
1748 let listed = git(target, &["worktree", "list", "--porcelain", "-z"])?;
1749 if !listed.status.success() {
1750 return Err(RkError::missing(
1751 Diagnostic::new(
1752 Reason::TargetNotFound,
1753 format!("target {target} is not a git repository"),
1754 )
1755 .expected("a repository whose worktrees git can list"),
1756 ));
1757 }
1758 crate::worktree::parse_worktrees(&listed.stdout)
1759 .map_err(|detail| refuse(Reason::PrerequisiteUnmet, detail))
1760}
1761
1762fn seat_for(
1764 seats: &[crate::worktree::Worktree],
1765 branch: &str,
1766 target: &Utf8Path,
1767) -> Result<Utf8PathBuf, RkError> {
1768 seats
1769 .iter()
1770 .find(|seat| seat.branch.as_deref() == Some(branch))
1771 .map(|seat| seat.path.clone())
1772 .ok_or_else(|| {
1773 refuse(
1774 Reason::StateDrift,
1775 format!(
1776 "no worktree of {target} has {branch} checked out; rk worktree add {branch} --apply seats it"
1777 ),
1778 )
1779 })
1780}
1781
1782fn is_dirty(seat: &Utf8Path) -> Result<bool, RkError> {
1784 let status = git(seat, &["status", "--porcelain"])?;
1785 Ok(!status.stdout.is_empty())
1786}
1787
1788fn rev_parse(seat: &Utf8Path, revision: &str) -> Result<String, RkError> {
1790 let answer = git(seat, &["rev-parse", "--verify", "--quiet", revision])?;
1791 if !answer.status.success() {
1792 return Err(refuse(
1793 Reason::StateDrift,
1794 format!("{revision} does not resolve in {seat}"),
1795 ));
1796 }
1797 Ok(String::from_utf8_lossy(&answer.stdout).trim().to_owned())
1798}
1799
1800fn is_ancestor(seat: &Utf8Path, ancestor: &str, descendant: &str) -> bool {
1802 git(seat, &["merge-base", "--is-ancestor", ancestor, descendant])
1803 .is_ok_and(|answer| answer.status.success())
1804}
1805
1806fn common_git_dir(target: &Utf8Path) -> Result<Utf8PathBuf, RkError> {
1808 let answer = git(
1809 target,
1810 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
1811 )?;
1812 if !answer.status.success() {
1813 return Err(RkError::missing(
1814 Diagnostic::new(
1815 Reason::TargetNotFound,
1816 format!("target {target} is not a git repository"),
1817 )
1818 .expected("a repository whose common git directory git can name"),
1819 ));
1820 }
1821 let path = String::from_utf8_lossy(&answer.stdout).trim().to_owned();
1822 Utf8PathBuf::from_path_buf(std::path::PathBuf::from(path)).map_err(|path| {
1823 refuse(
1824 Reason::PrerequisiteUnmet,
1825 format!("the common git directory {} is not UTF-8", path.display()),
1826 )
1827 })
1828}
1829
1830fn read_ledger(path: &Utf8Path) -> Result<Ledger, RkError> {
1832 match std::fs::read_to_string(path) {
1833 Ok(text) => {
1834 Ledger::parse(&text).map_err(|detail| refuse(Reason::UnsupportedSchema, detail))
1835 }
1836 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Ledger::default()),
1837 Err(error) => Err(RkError::Io(error)),
1838 }
1839}
1840
1841fn write_ledger(path: &Utf8Path, ledger: &Ledger) -> Result<(), RkError> {
1843 let text = ledger
1844 .render()
1845 .map_err(|detail| refuse(Reason::Internal, detail))?;
1846 if let Some(parent) = path.parent() {
1847 std::fs::create_dir_all(parent)?;
1848 }
1849 crate::atomic::write(path.as_std_path(), text.as_bytes())?;
1850 Ok(())
1851}
1852
1853fn refuse(reason: Reason, message: impl Into<String>) -> RkError {
1855 RkError::refusal(
1856 Diagnostic::new(reason, message).target_state("unchanged; the trunk stands where it stood"),
1857 )
1858}
1859
1860fn git_in_c_locale(at: &Utf8Path, args: &[&str]) -> Result<std::process::Output, RkError> {
1863 let mut command = std::process::Command::new(crate::probes::git_bin());
1864 for var in GIT_HOOK_VARS {
1865 command.env_remove(var);
1866 }
1867 command
1868 .env("LC_ALL", "C")
1869 .arg("-C")
1870 .arg(at.as_std_path())
1871 .args(args)
1872 .output()
1873 .map_err(|source| {
1874 RkError::subprocess(
1875 Diagnostic::new(
1876 Reason::SubprocessSpawn,
1877 format!("git did not run in {at}: {source}"),
1878 )
1879 .target_state("unchanged"),
1880 )
1881 })
1882}
1883
1884fn git(at: &Utf8Path, args: &[&str]) -> Result<std::process::Output, RkError> {
1886 let mut command = std::process::Command::new(crate::probes::git_bin());
1887 for var in GIT_HOOK_VARS {
1888 command.env_remove(var);
1889 }
1890 command
1891 .arg("-C")
1892 .arg(at.as_std_path())
1893 .args(args)
1894 .output()
1895 .map_err(|source| {
1896 RkError::subprocess(
1897 Diagnostic::new(
1898 Reason::SubprocessSpawn,
1899 format!("git did not run in {at}: {source}"),
1900 )
1901 .target_state("unchanged"),
1902 )
1903 })
1904}
1905
1906#[cfg(test)]
1907mod tests {
1908 use camino::Utf8Path;
1909
1910 use super::{
1911 BranchCommit, misshapen_subject, refuse_commit_messages, refuse_message, request_commands,
1912 };
1913 use crate::landing::MergeForm;
1914
1915 #[test]
1917 fn merge_form_rebase_asks_gitlab_for_no_squash() {
1918 let gitlab = "git@gitlab.com:acme/widget.git";
1919 let squash = request_commands(gitlab, "master", "feat/x", MergeForm::Squash, true);
1920 assert_eq!(
1921 squash[0],
1922 "glab mr create --source-branch feat/x --target-branch master --squash-before-merge"
1923 );
1924 assert!(squash[1].contains("the title is the trunk's commit message"));
1925 let rebase = request_commands(gitlab, "master", "feat/x", MergeForm::Rebase, true);
1926 assert_eq!(
1927 rebase[0],
1928 "glab mr create --source-branch feat/x --target-branch master"
1929 );
1930 assert!(
1931 !rebase.iter().any(|line| line.contains("squash")),
1932 "{rebase:?}"
1933 );
1934 assert!(
1935 rebase[1].contains("every commit of the branch reaches the trunk with its own message"),
1936 "{rebase:?}"
1937 );
1938 assert!(!rebase[1].contains("title"), "{rebase:?}");
1939 }
1940
1941 #[test]
1942 fn merge_form_keeps_the_github_request_command() {
1943 let github = "git@github.com:acme/widget.git";
1944 for form in [MergeForm::Squash, MergeForm::Rebase] {
1945 assert_eq!(
1946 request_commands(github, "master", "feat/x", form, true)[0],
1947 "gh pr create --base master --head feat/x"
1948 );
1949 }
1950 }
1951
1952 #[test]
1953 fn a_trunk_subject_is_held_to_the_landed_convention() {
1954 assert_eq!(
1955 misshapen_subject("feat(integrate): land the verb", true),
1956 None
1957 );
1958 assert_eq!(misshapen_subject("feat(a/b)!: break it", true), None);
1959 assert!(
1960 misshapen_subject("land the verb", true)
1961 .expect("an unscoped subject refuses")
1962 .contains("Conventional Commit")
1963 );
1964 assert!(
1965 misshapen_subject("feat: land the verb", true)
1966 .expect("a missing scope refuses")
1967 .contains("names no scope")
1968 );
1969 assert!(
1970 misshapen_subject("feat(integrate): ", true)
1971 .expect("an empty description refuses")
1972 .contains("no description")
1973 );
1974 assert!(
1975 misshapen_subject("wat(integrate): land it", true)
1976 .expect("an unknown type refuses")
1977 .contains("not a Conventional Commit type")
1978 );
1979 assert!(
1980 misshapen_subject("feat(Integrate): land it", true)
1981 .expect("a misshapen scope refuses")
1982 .contains("outside")
1983 );
1984 }
1985
1986 #[test]
1990 fn a_trunk_body_is_judged_by_the_one_message_owner() {
1991 let dir = tempfile::tempdir().expect("a scratch dir exists");
1992 let target = Utf8Path::from_path(dir.path()).expect("utf-8");
1993 refuse_message(
1994 target,
1995 "feat(integrate): land the verb\n\nThe context.\n",
1996 true,
1997 )
1998 .expect("a clean message passes");
1999 let error = refuse_message(
2000 target,
2001 "feat(integrate): land the verb\n\nCo-Authored-By: Claude <noreply@anthropic.com>\n",
2002 true,
2003 )
2004 .expect_err("agent attribution refuses")
2005 .to_string();
2006 assert!(error.contains("attribution"), "{error}");
2007 assert!(error.contains("commit-tree fires no hook"), "{error}");
2008 let error = refuse_message(
2009 target,
2010 "feat(integrate): land the verb\n\nSee .draft/plan.md for the rest.\n",
2011 true,
2012 )
2013 .expect_err("an internal path refuses")
2014 .to_string();
2015 assert!(error.contains("internal-path"), "{error}");
2016 let error = refuse_message(target, "", true)
2017 .expect_err("an empty message refuses")
2018 .to_string();
2019 assert!(error.contains("--message is required"), "{error}");
2020 }
2021
2022 #[test]
2026 fn rebase_integrate_judges_every_commit_message() {
2027 let dir = tempfile::tempdir().expect("a scratch dir exists");
2028 let target = Utf8Path::from_path(dir.path()).expect("utf-8");
2029 let commit = |id: &str, message: &str| BranchCommit {
2030 id: id.to_owned(),
2031 message: message.to_owned(),
2032 };
2033 refuse_commit_messages(
2034 target,
2035 &[
2036 commit("1111111aaaa", "feat(greeting): add it\n"),
2037 commit("2222222bbbb", "fix(greeting): mend it\n\nThe context.\n"),
2038 ],
2039 true,
2040 )
2041 .expect("clean messages pass");
2042 let error = refuse_commit_messages(
2043 target,
2044 &[
2045 commit("1111111aaaa", "feat(greeting): add it\n"),
2046 commit(
2047 "2222222bbbb",
2048 "fix(greeting): mend it\n\nCo-Authored-By: Claude <noreply@anthropic.com>\n",
2049 ),
2050 ],
2051 true,
2052 )
2053 .expect_err("agent attribution refuses")
2054 .to_string();
2055 assert!(error.contains("commit 2222222"), "{error}");
2056 assert!(error.contains("attribution"), "{error}");
2057 let error = refuse_commit_messages(target, &[commit("3333333cccc", "mend it\n")], true)
2058 .expect_err("an unscoped subject refuses")
2059 .to_string();
2060 assert!(error.contains("commit 3333333"), "{error}");
2061 assert!(error.contains("Conventional Commit"), "{error}");
2062 let error = refuse_commit_messages(target, &[commit("4444444dddd", "\n")], true)
2063 .expect_err("an empty message refuses")
2064 .to_string();
2065 assert!(error.contains("carries no message"), "{error}");
2066 }
2067
2068 #[test]
2074 fn workflow_only_a_trunk_subject_may_name_no_scope() {
2075 assert_eq!(misshapen_subject("fix: drop the stale path", false), None);
2076 assert_eq!(misshapen_subject("feat!: break it", false), None);
2077 assert_eq!(misshapen_subject("feat(a/b): add it", false), None);
2078 assert!(
2079 misshapen_subject("fix: drop the stale path", true)
2080 .expect("a release requires the scope")
2081 .contains("names no scope")
2082 );
2083 assert!(
2084 misshapen_subject("wat: drop it", false)
2085 .expect("an unknown type refuses")
2086 .contains("not a Conventional Commit type")
2087 );
2088 assert!(
2089 misshapen_subject("drop the stale path", false)
2090 .expect("a subject with no type refuses")
2091 .contains("<type>: <description>")
2092 );
2093 assert!(
2094 misshapen_subject("fix(Bad): mend it", false)
2095 .expect("a misshapen scope refuses")
2096 .contains("outside")
2097 );
2098 let dir = tempfile::tempdir().expect("a scratch dir exists");
2099 let target = Utf8Path::from_path(dir.path()).expect("utf-8");
2100 refuse_message(target, "fix: drop the stale path\n", false)
2101 .expect("a release-less trunk message names no scope");
2102 refuse_commit_messages(
2103 target,
2104 &[BranchCommit {
2105 id: "5555555eeee".to_owned(),
2106 message: "fix: drop the stale path\n".to_owned(),
2107 }],
2108 false,
2109 )
2110 .expect("a release-less branch commit names no scope");
2111 }
2112
2113 #[test]
2119 fn workflow_only_request_guidance_prefers_the_scope() {
2120 for remote in [
2121 "git@gitlab.com:acme/widget.git",
2122 "git@github.com:acme/widget.git",
2123 ] {
2124 for form in [MergeForm::Squash, MergeForm::Rebase] {
2125 let lines = request_commands(remote, "master", "feat/x", form, false);
2126 assert!(!lines[1].contains("scoped"), "{lines:?}");
2127 assert!(
2128 lines[1].contains("scope is preferred and not required"),
2129 "{lines:?}"
2130 );
2131 let releasing = request_commands(remote, "master", "feat/x", form, true);
2132 assert!(
2133 releasing[1].contains("scoped Conventional Commit"),
2134 "{releasing:?}"
2135 );
2136 assert_eq!(lines[0], releasing[0], "the command itself does not change");
2137 }
2138 }
2139 }
2140
2141 #[test]
2149 fn integrate_reports_a_raced_publication_as_published() {
2150 let drift = super::Drift {
2151 expected: "a".repeat(40),
2152 found_old: "b".repeat(40),
2153 found_new: "c".repeat(40),
2154 };
2155 assert!(drift.step("master").contains("bbbbbbb to ccccccc"));
2156 let error = drift.into_error("master", &"c".repeat(40));
2157 assert_eq!(error.exit_code(), 1);
2158 let diagnostic = error.diagnostic();
2159 assert_eq!(diagnostic.reason, crate::diagnostic::Reason::StateDrift);
2160 for named in [
2161 "published ccccccc",
2162 "bbbbbbb",
2163 "observed aaaaaaa",
2164 "outside rk",
2165 ] {
2166 assert!(
2167 diagnostic.message.contains(named),
2168 "{named}: {}",
2169 diagnostic.message
2170 );
2171 }
2172 assert!(
2173 !diagnostic.message.contains("nothing"),
2174 "{}",
2175 diagnostic.message
2176 );
2177 let state = diagnostic.target_state.unwrap_or_default();
2178 assert!(state.contains("published at ccccccc"), "{state}");
2179 assert!(state.contains("no rollback"), "{state}");
2180 assert!(!state.contains("where it stood"), "{state}");
2181 let action = diagnostic.action.unwrap_or_default();
2182 assert!(action.contains("git log aaaaaaa..bbbbbbb"), "{action}");
2183 }
2184
2185 #[test]
2190 fn integrate_refuses_a_changed_trunk_holder() {
2191 let main = Utf8Path::new("/repo");
2192 let other = Utf8Path::new("/repo@trunk");
2193 assert_eq!(
2194 super::holder_change("master", None, None, "integration"),
2195 None
2196 );
2197 assert_eq!(
2198 super::holder_change("master", Some(main), Some(main), "integration"),
2199 None
2200 );
2201 for (observed, live) in [
2202 (None, Some(other)),
2203 (Some(main), None),
2204 (Some(main), Some(other)),
2205 ] {
2206 let reason = super::holder_change("master", observed, live, "integration")
2207 .expect("a changed holder refuses");
2208 assert!(reason.contains("checked out"), "{reason}");
2209 assert!(reason.contains("nothing was published"), "{reason}");
2210 }
2211 }
2212
2213 #[test]
2222 fn integrate_reports_an_unconfirmed_move_as_published() {
2223 let expected = "a".repeat(40);
2224 let published = "c".repeat(40);
2225 let unsettled = super::Unsettled::Unconfirmed {
2226 expected: expected.clone(),
2227 reason: "the reflog holds one entry".to_owned(),
2228 };
2229 assert!(unsettled.step("master").contains("is not confirmed"));
2230 let error = unsettled.clone().into_error("master", &published);
2231 assert_eq!(error.exit_code(), 1);
2232 let diagnostic = error.diagnostic();
2233 assert_eq!(diagnostic.reason, crate::diagnostic::Reason::StateDrift);
2234 assert_eq!(
2235 diagnostic.message,
2236 "master now stands at the published ccccccc, but rk could not confirm that the move started at the observed aaaaaaa: the reflog holds one entry"
2237 );
2238 assert_eq!(
2239 diagnostic.expected.as_deref(),
2240 Some("a confirmed move of master from aaaaaaa to ccccccc")
2241 );
2242 assert_eq!(
2243 diagnostic.action.as_deref(),
2244 Some(
2245 "read git reflog master and git log aaaaaaa..ccccccc, and decide whether anything else moved master; rk moves nothing back"
2246 )
2247 );
2248 assert_eq!(
2249 diagnostic.target_state.as_deref(),
2250 Some(
2251 "master is published at ccccccc, with no rollback and no reset; the staged evidence names ccccccc and the trunk reaches it"
2252 )
2253 );
2254
2255 let raced = super::Unsettled::Raced(super::Drift {
2256 expected: expected.clone(),
2257 found_old: "b".repeat(40),
2258 found_new: published.clone(),
2259 });
2260 for refresh in [unsettled, raced] {
2261 let diagnostic = refresh
2262 .into_refresh_error("master", &expected, &published)
2263 .diagnostic();
2264 assert!(
2265 diagnostic
2266 .message
2267 .contains("was fast-forwarded to its remote tip ccccccc")
2268 );
2269 assert!(diagnostic.message.contains("no integration was published"));
2270 let state = diagnostic.target_state.unwrap_or_default();
2271 assert!(
2272 state.contains("refreshed to its remote tip ccccccc"),
2273 "{state}"
2274 );
2275 for unchanged in ["unchanged", "where it stood", "now stands at the published"] {
2276 assert!(!diagnostic.message.contains(unchanged), "{unchanged}");
2277 assert!(!state.contains(unchanged), "{unchanged}");
2278 }
2279 }
2280 }
2281
2282 #[test]
2287 fn integrate_parses_only_a_full_updating_line() {
2288 let old = "a".repeat(40);
2289 let new = "b".repeat(40);
2290 assert_eq!(
2291 super::parse_updating(&format!("Updating {old}..{new}\nFast-forward\n"), 40),
2292 Ok((old.clone(), new.clone()))
2293 );
2294 for report in [
2295 "Fast-forward\n".to_owned(),
2296 format!("Updating {}..{}\n", &old[..7], &new[..7]),
2297 format!("Updating {old}..{}\n", &new[..12]),
2298 format!("Updating {old} {new}\n"),
2299 format!("Updating {old}..{new}\nUpdating {new}..{old}\n"),
2300 format!("Updating {}..{new}\n", "g".repeat(40)),
2301 format!("Updating {old}..{new}\n").replace("Updating", "Mise à jour"),
2302 ] {
2303 assert!(super::parse_updating(&report, 40).is_err(), "{report}");
2304 }
2305 }
2306
2307 #[test]
2313 fn integrate_judges_the_read_backs_of_a_held_move() {
2314 use super::{PostPublication, Unsettled, judge_move};
2315 let expected = "a".repeat(40);
2316 let other = "b".repeat(40);
2317 let to = "c".repeat(40);
2318 let pair = |old: &str, new: &str| Ok((old.to_owned(), new.to_owned()));
2319 let none = || Err::<(String, String), String>("unavailable".to_owned());
2320 assert_eq!(
2321 judge_move(&expected, &to, pair(&expected, &to), none()),
2322 PostPublication::Confirmed
2323 );
2324 assert_eq!(
2325 judge_move(&expected, &to, none(), pair(&expected, &to)),
2326 PostPublication::Confirmed
2327 );
2328 assert_eq!(
2329 judge_move(&expected, &to, pair(&expected, &to), pair(&expected, &to)),
2330 PostPublication::Confirmed
2331 );
2332 assert!(matches!(
2333 judge_move(&expected, &to, pair(&other, &to), none()),
2334 PostPublication::Unsettled(Unsettled::Raced(_))
2335 ));
2336 for judged in [
2337 judge_move(&expected, &to, pair(&expected, &to), pair(&other, &to)),
2338 judge_move(&expected, &to, none(), pair(&expected, &other)),
2339 judge_move(&expected, &to, none(), none()),
2340 ] {
2341 assert!(
2342 matches!(
2343 judged,
2344 PostPublication::Unsettled(Unsettled::Unconfirmed { .. })
2345 ),
2346 "{judged:?}"
2347 );
2348 }
2349 }
2350}