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 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 {}, so nothing reached {}",
494 run.seat, run.trunk
495 ),
496 ));
497 }
498 steps.push("the manual stage passed".to_owned());
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 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 {}, so nothing reached {}",
617 run.seat, run.trunk
618 ),
619 ));
620 }
621 steps.push("the manual stage passed".to_owned());
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 gate(seat: &Utf8Path) -> Result<std::process::Output, RkError> {
1526 let mut command = std::process::Command::new("pre-commit");
1527 for var in GIT_HOOK_VARS {
1528 command.env_remove(var);
1529 }
1530 command
1531 .current_dir(seat.as_std_path())
1532 .args(["run", "--hook-stage", "manual", "--all-files"])
1533 .output()
1534 .map_err(|source| {
1535 RkError::subprocess(
1536 Diagnostic::new(
1537 Reason::SubprocessSpawn,
1538 format!("pre-commit did not run in {seat}: {source}"),
1539 )
1540 .expected(
1541 "pre-commit on PATH, which is what installs and runs this project's hooks",
1542 )
1543 .action("install pre-commit, or enter the project's devshell, and run it again")
1544 .target_state("unchanged"),
1545 )
1546 })
1547}
1548
1549fn report(
1551 args: &IntegrateArgs,
1552 out: Output,
1553 run: &LocalRun<'_>,
1554 trunk_before: &str,
1555 trunk_commit: Option<String>,
1556 steps: Vec<String>,
1557 next: &[String],
1558) -> Result<(), RkError> {
1559 for step in &steps {
1560 out.result_line(step);
1561 }
1562 out.emit(&Report {
1563 schema: "rk.integrate/1",
1564 mode: if args.apply { "apply" } else { "preview" },
1565 target: run.target.to_string(),
1566 integration: Integration::Local.as_str(),
1567 authority_source: run.authority_source,
1568 merge_form: run.merge_form.as_str(),
1569 branch: args.branch.clone(),
1570 trunk: run.trunk.to_owned(),
1571 seat: run.seat.to_string(),
1572 trunk_before: trunk_before.to_owned(),
1573 trunk_commit,
1574 steps,
1575 next: next.to_vec(),
1576 })?;
1577 out.next(next);
1578 Ok(())
1579}
1580
1581const SHAPE: &str = "a scoped Conventional Commit: <type>(<scope>): <description>";
1583
1584const SHAPE_SCOPE_PREFERRED: &str =
1587 "a Conventional Commit: <type>(<scope>): <description>, or <type>: <description>";
1588
1589const TYPES: [&str; 11] = [
1592 "build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test",
1593];
1594
1595fn refuse_message(target: &Utf8Path, text: &str, scope_required: bool) -> Result<(), RkError> {
1616 let subject = text.lines().next().unwrap_or("").trim();
1617 if subject.is_empty() {
1618 return Err(refuse(
1619 Reason::Usage,
1620 "a local integration writes the trunk's commit message, so --message is required",
1621 ));
1622 }
1623 if let Some(reason) = misshapen_subject(subject, scope_required) {
1624 return Err(refuse(Reason::Usage, reason));
1625 }
1626 let (findings, _) = crate::commands::message::judge(
1627 text,
1628 crate::cli::message::MessageKind::Commit,
1629 target,
1630 subject,
1631 crate::commands::message::Judgment {
1632 exempt: crate::commands::message::exempt_title(subject),
1633 scope_required,
1634 },
1635 );
1636 if findings.is_empty() {
1637 return Ok(());
1638 }
1639 let named: Vec<String> = findings
1640 .iter()
1641 .map(|finding| format!("{}:{} {}", finding.class, finding.line, finding.detail))
1642 .collect();
1643 Err(refuse(
1644 Reason::Usage,
1645 format!(
1646 "the trunk message carries {} finding{} the landed commit-msg stage would refuse, and git commit-tree fires no hook: {}",
1647 findings.len(),
1648 if findings.len() == 1 { "" } else { "s" },
1649 named.join("; ")
1650 ),
1651 ))
1652}
1653
1654#[must_use]
1658fn misshapen_subject(subject: &str, scope_required: bool) -> Option<String> {
1659 let shape = if scope_required {
1660 SHAPE
1661 } else {
1662 SHAPE_SCOPE_PREFERRED
1663 };
1664 let Some((head, description)) = subject.split_once(": ") else {
1665 return Some(format!("'{subject}' is not {shape}"));
1666 };
1667 if description.trim().is_empty() {
1668 return Some(format!(
1669 "'{subject}' is not {shape}: it states no description"
1670 ));
1671 }
1672 let head = head.strip_suffix('!').unwrap_or(head);
1675 let (kind, scope) = match head.split_once('(') {
1676 None if scope_required => {
1677 return Some(format!("'{subject}' is not {shape}: it names no scope"));
1678 }
1679 None => (head, None),
1680 Some((kind, scope)) => {
1681 let Some(scope) = scope.strip_suffix(')') else {
1682 return Some(format!("'{subject}' is not {shape}: its scope is unclosed"));
1683 };
1684 (kind, Some(scope))
1685 }
1686 };
1687 if !TYPES.contains(&kind) {
1688 return Some(format!(
1689 "'{kind}' is not a Conventional Commit type; the types are: {}",
1690 TYPES.join(", ")
1691 ));
1692 }
1693 if let Some(scope) = scope
1694 && !crate::projection::scope_is_shaped(scope)
1695 {
1696 return Some(format!(
1697 "the scope '{scope}' is outside {}: lowercase letters, digits, and _ . / -",
1698 crate::projection::SCOPE_SHAPE
1699 ));
1700 }
1701 None
1702}
1703
1704fn seats(target: &Utf8Path) -> Result<Vec<crate::worktree::Worktree>, RkError> {
1706 let listed = git(target, &["worktree", "list", "--porcelain", "-z"])?;
1707 if !listed.status.success() {
1708 return Err(RkError::missing(
1709 Diagnostic::new(
1710 Reason::TargetNotFound,
1711 format!("target {target} is not a git repository"),
1712 )
1713 .expected("a repository whose worktrees git can list"),
1714 ));
1715 }
1716 crate::worktree::parse_worktrees(&listed.stdout)
1717 .map_err(|detail| refuse(Reason::PrerequisiteUnmet, detail))
1718}
1719
1720fn seat_for(
1722 seats: &[crate::worktree::Worktree],
1723 branch: &str,
1724 target: &Utf8Path,
1725) -> Result<Utf8PathBuf, RkError> {
1726 seats
1727 .iter()
1728 .find(|seat| seat.branch.as_deref() == Some(branch))
1729 .map(|seat| seat.path.clone())
1730 .ok_or_else(|| {
1731 refuse(
1732 Reason::StateDrift,
1733 format!(
1734 "no worktree of {target} has {branch} checked out; rk worktree add {branch} --apply seats it"
1735 ),
1736 )
1737 })
1738}
1739
1740fn is_dirty(seat: &Utf8Path) -> Result<bool, RkError> {
1742 let status = git(seat, &["status", "--porcelain"])?;
1743 Ok(!status.stdout.is_empty())
1744}
1745
1746fn rev_parse(seat: &Utf8Path, revision: &str) -> Result<String, RkError> {
1748 let answer = git(seat, &["rev-parse", "--verify", "--quiet", revision])?;
1749 if !answer.status.success() {
1750 return Err(refuse(
1751 Reason::StateDrift,
1752 format!("{revision} does not resolve in {seat}"),
1753 ));
1754 }
1755 Ok(String::from_utf8_lossy(&answer.stdout).trim().to_owned())
1756}
1757
1758fn is_ancestor(seat: &Utf8Path, ancestor: &str, descendant: &str) -> bool {
1760 git(seat, &["merge-base", "--is-ancestor", ancestor, descendant])
1761 .is_ok_and(|answer| answer.status.success())
1762}
1763
1764fn common_git_dir(target: &Utf8Path) -> Result<Utf8PathBuf, RkError> {
1766 let answer = git(
1767 target,
1768 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
1769 )?;
1770 if !answer.status.success() {
1771 return Err(RkError::missing(
1772 Diagnostic::new(
1773 Reason::TargetNotFound,
1774 format!("target {target} is not a git repository"),
1775 )
1776 .expected("a repository whose common git directory git can name"),
1777 ));
1778 }
1779 let path = String::from_utf8_lossy(&answer.stdout).trim().to_owned();
1780 Utf8PathBuf::from_path_buf(std::path::PathBuf::from(path)).map_err(|path| {
1781 refuse(
1782 Reason::PrerequisiteUnmet,
1783 format!("the common git directory {} is not UTF-8", path.display()),
1784 )
1785 })
1786}
1787
1788fn read_ledger(path: &Utf8Path) -> Result<Ledger, RkError> {
1790 match std::fs::read_to_string(path) {
1791 Ok(text) => {
1792 Ledger::parse(&text).map_err(|detail| refuse(Reason::UnsupportedSchema, detail))
1793 }
1794 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Ledger::default()),
1795 Err(error) => Err(RkError::Io(error)),
1796 }
1797}
1798
1799fn write_ledger(path: &Utf8Path, ledger: &Ledger) -> Result<(), RkError> {
1801 let text = ledger
1802 .render()
1803 .map_err(|detail| refuse(Reason::Internal, detail))?;
1804 if let Some(parent) = path.parent() {
1805 std::fs::create_dir_all(parent)?;
1806 }
1807 crate::atomic::write(path.as_std_path(), text.as_bytes())?;
1808 Ok(())
1809}
1810
1811fn refuse(reason: Reason, message: impl Into<String>) -> RkError {
1813 RkError::refusal(
1814 Diagnostic::new(reason, message).target_state("unchanged; the trunk stands where it stood"),
1815 )
1816}
1817
1818fn git_in_c_locale(at: &Utf8Path, args: &[&str]) -> Result<std::process::Output, RkError> {
1821 let mut command = std::process::Command::new(crate::probes::git_bin());
1822 for var in GIT_HOOK_VARS {
1823 command.env_remove(var);
1824 }
1825 command
1826 .env("LC_ALL", "C")
1827 .arg("-C")
1828 .arg(at.as_std_path())
1829 .args(args)
1830 .output()
1831 .map_err(|source| {
1832 RkError::subprocess(
1833 Diagnostic::new(
1834 Reason::SubprocessSpawn,
1835 format!("git did not run in {at}: {source}"),
1836 )
1837 .target_state("unchanged"),
1838 )
1839 })
1840}
1841
1842fn git(at: &Utf8Path, args: &[&str]) -> Result<std::process::Output, RkError> {
1844 let mut command = std::process::Command::new(crate::probes::git_bin());
1845 for var in GIT_HOOK_VARS {
1846 command.env_remove(var);
1847 }
1848 command
1849 .arg("-C")
1850 .arg(at.as_std_path())
1851 .args(args)
1852 .output()
1853 .map_err(|source| {
1854 RkError::subprocess(
1855 Diagnostic::new(
1856 Reason::SubprocessSpawn,
1857 format!("git did not run in {at}: {source}"),
1858 )
1859 .target_state("unchanged"),
1860 )
1861 })
1862}
1863
1864#[cfg(test)]
1865mod tests {
1866 use camino::Utf8Path;
1867
1868 use super::{
1869 BranchCommit, misshapen_subject, refuse_commit_messages, refuse_message, request_commands,
1870 };
1871 use crate::landing::MergeForm;
1872
1873 #[test]
1875 fn merge_form_rebase_asks_gitlab_for_no_squash() {
1876 let gitlab = "git@gitlab.com:acme/widget.git";
1877 let squash = request_commands(gitlab, "master", "feat/x", MergeForm::Squash, true);
1878 assert_eq!(
1879 squash[0],
1880 "glab mr create --source-branch feat/x --target-branch master --squash-before-merge"
1881 );
1882 assert!(squash[1].contains("the title is the trunk's commit message"));
1883 let rebase = request_commands(gitlab, "master", "feat/x", MergeForm::Rebase, true);
1884 assert_eq!(
1885 rebase[0],
1886 "glab mr create --source-branch feat/x --target-branch master"
1887 );
1888 assert!(
1889 !rebase.iter().any(|line| line.contains("squash")),
1890 "{rebase:?}"
1891 );
1892 assert!(
1893 rebase[1].contains("every commit of the branch reaches the trunk with its own message"),
1894 "{rebase:?}"
1895 );
1896 assert!(!rebase[1].contains("title"), "{rebase:?}");
1897 }
1898
1899 #[test]
1900 fn merge_form_keeps_the_github_request_command() {
1901 let github = "git@github.com:acme/widget.git";
1902 for form in [MergeForm::Squash, MergeForm::Rebase] {
1903 assert_eq!(
1904 request_commands(github, "master", "feat/x", form, true)[0],
1905 "gh pr create --base master --head feat/x"
1906 );
1907 }
1908 }
1909
1910 #[test]
1911 fn a_trunk_subject_is_held_to_the_landed_convention() {
1912 assert_eq!(
1913 misshapen_subject("feat(integrate): land the verb", true),
1914 None
1915 );
1916 assert_eq!(misshapen_subject("feat(a/b)!: break it", true), None);
1917 assert!(
1918 misshapen_subject("land the verb", true)
1919 .expect("an unscoped subject refuses")
1920 .contains("Conventional Commit")
1921 );
1922 assert!(
1923 misshapen_subject("feat: land the verb", true)
1924 .expect("a missing scope refuses")
1925 .contains("names no scope")
1926 );
1927 assert!(
1928 misshapen_subject("feat(integrate): ", true)
1929 .expect("an empty description refuses")
1930 .contains("no description")
1931 );
1932 assert!(
1933 misshapen_subject("wat(integrate): land it", true)
1934 .expect("an unknown type refuses")
1935 .contains("not a Conventional Commit type")
1936 );
1937 assert!(
1938 misshapen_subject("feat(Integrate): land it", true)
1939 .expect("a misshapen scope refuses")
1940 .contains("outside")
1941 );
1942 }
1943
1944 #[test]
1948 fn a_trunk_body_is_judged_by_the_one_message_owner() {
1949 let dir = tempfile::tempdir().expect("a scratch dir exists");
1950 let target = Utf8Path::from_path(dir.path()).expect("utf-8");
1951 refuse_message(
1952 target,
1953 "feat(integrate): land the verb\n\nThe context.\n",
1954 true,
1955 )
1956 .expect("a clean message passes");
1957 let error = refuse_message(
1958 target,
1959 "feat(integrate): land the verb\n\nCo-Authored-By: Claude <noreply@anthropic.com>\n",
1960 true,
1961 )
1962 .expect_err("agent attribution refuses")
1963 .to_string();
1964 assert!(error.contains("attribution"), "{error}");
1965 assert!(error.contains("commit-tree fires no hook"), "{error}");
1966 let error = refuse_message(
1967 target,
1968 "feat(integrate): land the verb\n\nSee .draft/plan.md for the rest.\n",
1969 true,
1970 )
1971 .expect_err("an internal path refuses")
1972 .to_string();
1973 assert!(error.contains("internal-path"), "{error}");
1974 let error = refuse_message(target, "", true)
1975 .expect_err("an empty message refuses")
1976 .to_string();
1977 assert!(error.contains("--message is required"), "{error}");
1978 }
1979
1980 #[test]
1984 fn rebase_integrate_judges_every_commit_message() {
1985 let dir = tempfile::tempdir().expect("a scratch dir exists");
1986 let target = Utf8Path::from_path(dir.path()).expect("utf-8");
1987 let commit = |id: &str, message: &str| BranchCommit {
1988 id: id.to_owned(),
1989 message: message.to_owned(),
1990 };
1991 refuse_commit_messages(
1992 target,
1993 &[
1994 commit("1111111aaaa", "feat(greeting): add it\n"),
1995 commit("2222222bbbb", "fix(greeting): mend it\n\nThe context.\n"),
1996 ],
1997 true,
1998 )
1999 .expect("clean messages pass");
2000 let error = refuse_commit_messages(
2001 target,
2002 &[
2003 commit("1111111aaaa", "feat(greeting): add it\n"),
2004 commit(
2005 "2222222bbbb",
2006 "fix(greeting): mend it\n\nCo-Authored-By: Claude <noreply@anthropic.com>\n",
2007 ),
2008 ],
2009 true,
2010 )
2011 .expect_err("agent attribution refuses")
2012 .to_string();
2013 assert!(error.contains("commit 2222222"), "{error}");
2014 assert!(error.contains("attribution"), "{error}");
2015 let error = refuse_commit_messages(target, &[commit("3333333cccc", "mend it\n")], true)
2016 .expect_err("an unscoped subject refuses")
2017 .to_string();
2018 assert!(error.contains("commit 3333333"), "{error}");
2019 assert!(error.contains("Conventional Commit"), "{error}");
2020 let error = refuse_commit_messages(target, &[commit("4444444dddd", "\n")], true)
2021 .expect_err("an empty message refuses")
2022 .to_string();
2023 assert!(error.contains("carries no message"), "{error}");
2024 }
2025
2026 #[test]
2032 fn workflow_only_a_trunk_subject_may_name_no_scope() {
2033 assert_eq!(misshapen_subject("fix: drop the stale path", false), None);
2034 assert_eq!(misshapen_subject("feat!: break it", false), None);
2035 assert_eq!(misshapen_subject("feat(a/b): add it", false), None);
2036 assert!(
2037 misshapen_subject("fix: drop the stale path", true)
2038 .expect("a release requires the scope")
2039 .contains("names no scope")
2040 );
2041 assert!(
2042 misshapen_subject("wat: drop it", false)
2043 .expect("an unknown type refuses")
2044 .contains("not a Conventional Commit type")
2045 );
2046 assert!(
2047 misshapen_subject("drop the stale path", false)
2048 .expect("a subject with no type refuses")
2049 .contains("<type>: <description>")
2050 );
2051 assert!(
2052 misshapen_subject("fix(Bad): mend it", false)
2053 .expect("a misshapen scope refuses")
2054 .contains("outside")
2055 );
2056 let dir = tempfile::tempdir().expect("a scratch dir exists");
2057 let target = Utf8Path::from_path(dir.path()).expect("utf-8");
2058 refuse_message(target, "fix: drop the stale path\n", false)
2059 .expect("a release-less trunk message names no scope");
2060 refuse_commit_messages(
2061 target,
2062 &[BranchCommit {
2063 id: "5555555eeee".to_owned(),
2064 message: "fix: drop the stale path\n".to_owned(),
2065 }],
2066 false,
2067 )
2068 .expect("a release-less branch commit names no scope");
2069 }
2070
2071 #[test]
2077 fn workflow_only_request_guidance_prefers_the_scope() {
2078 for remote in [
2079 "git@gitlab.com:acme/widget.git",
2080 "git@github.com:acme/widget.git",
2081 ] {
2082 for form in [MergeForm::Squash, MergeForm::Rebase] {
2083 let lines = request_commands(remote, "master", "feat/x", form, false);
2084 assert!(!lines[1].contains("scoped"), "{lines:?}");
2085 assert!(
2086 lines[1].contains("scope is preferred and not required"),
2087 "{lines:?}"
2088 );
2089 let releasing = request_commands(remote, "master", "feat/x", form, true);
2090 assert!(
2091 releasing[1].contains("scoped Conventional Commit"),
2092 "{releasing:?}"
2093 );
2094 assert_eq!(lines[0], releasing[0], "the command itself does not change");
2095 }
2096 }
2097 }
2098
2099 #[test]
2107 fn integrate_reports_a_raced_publication_as_published() {
2108 let drift = super::Drift {
2109 expected: "a".repeat(40),
2110 found_old: "b".repeat(40),
2111 found_new: "c".repeat(40),
2112 };
2113 assert!(drift.step("master").contains("bbbbbbb to ccccccc"));
2114 let error = drift.into_error("master", &"c".repeat(40));
2115 assert_eq!(error.exit_code(), 1);
2116 let diagnostic = error.diagnostic();
2117 assert_eq!(diagnostic.reason, crate::diagnostic::Reason::StateDrift);
2118 for named in [
2119 "published ccccccc",
2120 "bbbbbbb",
2121 "observed aaaaaaa",
2122 "outside rk",
2123 ] {
2124 assert!(
2125 diagnostic.message.contains(named),
2126 "{named}: {}",
2127 diagnostic.message
2128 );
2129 }
2130 assert!(
2131 !diagnostic.message.contains("nothing"),
2132 "{}",
2133 diagnostic.message
2134 );
2135 let state = diagnostic.target_state.unwrap_or_default();
2136 assert!(state.contains("published at ccccccc"), "{state}");
2137 assert!(state.contains("no rollback"), "{state}");
2138 assert!(!state.contains("where it stood"), "{state}");
2139 let action = diagnostic.action.unwrap_or_default();
2140 assert!(action.contains("git log aaaaaaa..bbbbbbb"), "{action}");
2141 }
2142
2143 #[test]
2148 fn integrate_refuses_a_changed_trunk_holder() {
2149 let main = Utf8Path::new("/repo");
2150 let other = Utf8Path::new("/repo@trunk");
2151 assert_eq!(
2152 super::holder_change("master", None, None, "integration"),
2153 None
2154 );
2155 assert_eq!(
2156 super::holder_change("master", Some(main), Some(main), "integration"),
2157 None
2158 );
2159 for (observed, live) in [
2160 (None, Some(other)),
2161 (Some(main), None),
2162 (Some(main), Some(other)),
2163 ] {
2164 let reason = super::holder_change("master", observed, live, "integration")
2165 .expect("a changed holder refuses");
2166 assert!(reason.contains("checked out"), "{reason}");
2167 assert!(reason.contains("nothing was published"), "{reason}");
2168 }
2169 }
2170
2171 #[test]
2180 fn integrate_reports_an_unconfirmed_move_as_published() {
2181 let expected = "a".repeat(40);
2182 let published = "c".repeat(40);
2183 let unsettled = super::Unsettled::Unconfirmed {
2184 expected: expected.clone(),
2185 reason: "the reflog holds one entry".to_owned(),
2186 };
2187 assert!(unsettled.step("master").contains("is not confirmed"));
2188 let error = unsettled.clone().into_error("master", &published);
2189 assert_eq!(error.exit_code(), 1);
2190 let diagnostic = error.diagnostic();
2191 assert_eq!(diagnostic.reason, crate::diagnostic::Reason::StateDrift);
2192 assert_eq!(
2193 diagnostic.message,
2194 "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"
2195 );
2196 assert_eq!(
2197 diagnostic.expected.as_deref(),
2198 Some("a confirmed move of master from aaaaaaa to ccccccc")
2199 );
2200 assert_eq!(
2201 diagnostic.action.as_deref(),
2202 Some(
2203 "read git reflog master and git log aaaaaaa..ccccccc, and decide whether anything else moved master; rk moves nothing back"
2204 )
2205 );
2206 assert_eq!(
2207 diagnostic.target_state.as_deref(),
2208 Some(
2209 "master is published at ccccccc, with no rollback and no reset; the staged evidence names ccccccc and the trunk reaches it"
2210 )
2211 );
2212
2213 let raced = super::Unsettled::Raced(super::Drift {
2214 expected: expected.clone(),
2215 found_old: "b".repeat(40),
2216 found_new: published.clone(),
2217 });
2218 for refresh in [unsettled, raced] {
2219 let diagnostic = refresh
2220 .into_refresh_error("master", &expected, &published)
2221 .diagnostic();
2222 assert!(
2223 diagnostic
2224 .message
2225 .contains("was fast-forwarded to its remote tip ccccccc")
2226 );
2227 assert!(diagnostic.message.contains("no integration was published"));
2228 let state = diagnostic.target_state.unwrap_or_default();
2229 assert!(
2230 state.contains("refreshed to its remote tip ccccccc"),
2231 "{state}"
2232 );
2233 for unchanged in ["unchanged", "where it stood", "now stands at the published"] {
2234 assert!(!diagnostic.message.contains(unchanged), "{unchanged}");
2235 assert!(!state.contains(unchanged), "{unchanged}");
2236 }
2237 }
2238 }
2239
2240 #[test]
2245 fn integrate_parses_only_a_full_updating_line() {
2246 let old = "a".repeat(40);
2247 let new = "b".repeat(40);
2248 assert_eq!(
2249 super::parse_updating(&format!("Updating {old}..{new}\nFast-forward\n"), 40),
2250 Ok((old.clone(), new.clone()))
2251 );
2252 for report in [
2253 "Fast-forward\n".to_owned(),
2254 format!("Updating {}..{}\n", &old[..7], &new[..7]),
2255 format!("Updating {old}..{}\n", &new[..12]),
2256 format!("Updating {old} {new}\n"),
2257 format!("Updating {old}..{new}\nUpdating {new}..{old}\n"),
2258 format!("Updating {}..{new}\n", "g".repeat(40)),
2259 format!("Updating {old}..{new}\n").replace("Updating", "Mise à jour"),
2260 ] {
2261 assert!(super::parse_updating(&report, 40).is_err(), "{report}");
2262 }
2263 }
2264
2265 #[test]
2271 fn integrate_judges_the_read_backs_of_a_held_move() {
2272 use super::{PostPublication, Unsettled, judge_move};
2273 let expected = "a".repeat(40);
2274 let other = "b".repeat(40);
2275 let to = "c".repeat(40);
2276 let pair = |old: &str, new: &str| Ok((old.to_owned(), new.to_owned()));
2277 let none = || Err::<(String, String), String>("unavailable".to_owned());
2278 assert_eq!(
2279 judge_move(&expected, &to, pair(&expected, &to), none()),
2280 PostPublication::Confirmed
2281 );
2282 assert_eq!(
2283 judge_move(&expected, &to, none(), pair(&expected, &to)),
2284 PostPublication::Confirmed
2285 );
2286 assert_eq!(
2287 judge_move(&expected, &to, pair(&expected, &to), pair(&expected, &to)),
2288 PostPublication::Confirmed
2289 );
2290 assert!(matches!(
2291 judge_move(&expected, &to, pair(&other, &to), none()),
2292 PostPublication::Unsettled(Unsettled::Raced(_))
2293 ));
2294 for judged in [
2295 judge_move(&expected, &to, pair(&expected, &to), pair(&other, &to)),
2296 judge_move(&expected, &to, none(), pair(&expected, &other)),
2297 judge_move(&expected, &to, none(), none()),
2298 ] {
2299 assert!(
2300 matches!(
2301 judged,
2302 PostPublication::Unsettled(Unsettled::Unconfirmed { .. })
2303 ),
2304 "{judged:?}"
2305 );
2306 }
2307 }
2308}