1use camino::{Utf8Path, Utf8PathBuf};
22use serde::Serialize;
23
24use crate::cli::integrate::IntegrateArgs;
25use crate::diagnostic::{Diagnostic, Reason};
26use crate::error::RkError;
27use crate::integrate::{self, Entry, Ledger};
28use crate::landing::Integration;
29use crate::maintenance::{GIT_HOOK_VARS, last_line};
30use crate::output::Output;
31
32#[derive(Debug, Serialize)]
34struct Report {
35 schema: &'static str,
37 mode: &'static str,
39 target: String,
41 integration: &'static str,
43 authority_source: &'static str,
45 branch: String,
47 trunk: String,
49 seat: String,
51 trunk_before: String,
53 #[serde(skip_serializing_if = "Option::is_none")]
55 trunk_commit: Option<String>,
56 steps: Vec<String>,
58 next: Vec<String>,
60}
61
62pub fn run(args: &IntegrateArgs) -> Result<(), RkError> {
70 let out = Output::new(args.json);
71 let target = &args.target;
72 let trunk = crate::config::trunk_of(target.as_std_path())?;
73
74 let (integration, authority_source) = authority(args, target)?;
75 if let Some(reason) = integrate::refuse_branch_name(&args.branch, &trunk) {
76 return Err(refuse(Reason::Usage, reason));
77 }
78
79 let git_dir = common_git_dir(target)?;
80 let seats = seats(target)?;
81 let seat = seat_for(&seats, &args.branch, target)?;
82 let dirty = is_dirty(&seat)?;
83 if dirty {
84 return Err(refuse(
85 Reason::StateDrift,
86 format!(
87 "{seat} has uncommitted changes, so the gate would judge a tree nobody reviewed"
88 ),
89 ));
90 }
91
92 match integration {
93 Integration::Forge => forge_path(args, out, &seat, &trunk, authority_source),
94 Integration::Local => local_path(
95 args,
96 out,
97 &LocalRun {
98 target,
99 git_dir: &git_dir,
100 seat: &seat,
101 seats: &seats,
102 trunk: &trunk,
103 authority_source,
104 },
105 ),
106 }
107}
108
109struct LocalRun<'a> {
111 target: &'a Utf8Path,
112 git_dir: &'a Utf8Path,
113 seat: &'a Utf8Path,
114 seats: &'a [crate::worktree::Worktree],
115 trunk: &'a str,
116 authority_source: &'static str,
117}
118
119fn authority(
131 args: &IntegrateArgs,
132 target: &Utf8Path,
133) -> Result<(Integration, &'static str), RkError> {
134 if args.local {
135 return Ok((Integration::Local, "flag"));
136 }
137 if args.forge {
138 return Ok((Integration::Forge, "flag"));
139 }
140 let recorded = crate::landing::manifest::load(target)?.map(|record| record.git.integration);
141 Ok(recorded.map_or(
142 (crate::landing::manifest::integration_forge(), "default"),
143 |mode| (mode, "record"),
144 ))
145}
146
147fn forge_path(
149 args: &IntegrateArgs,
150 out: Output,
151 seat: &Utf8Path,
152 trunk: &str,
153 authority_source: &'static str,
154) -> Result<(), RkError> {
155 let mut steps = Vec::new();
156 if args.apply {
157 let pushed = git(seat, &["push", "--set-upstream", "origin", &args.branch])?;
161 if !pushed.status.success() {
162 return Err(refuse(
163 Reason::RemoteConflict,
164 format!(
165 "pushing {} refused: {}",
166 args.branch,
167 last_line(&pushed.stderr)
168 ),
169 ));
170 }
171 steps.push(format!("pushed {} to origin", args.branch));
172 } else {
173 steps.push(format!("would push {} to origin", args.branch));
174 }
175 let next = request_commands(seat, trunk, &args.branch);
176 for line in &steps {
177 out.result_line(line);
178 }
179 out.emit(&Report {
180 schema: "rk.integrate/1",
181 mode: if args.apply { "apply" } else { "preview" },
182 target: args.target.to_string(),
183 integration: Integration::Forge.as_str(),
184 authority_source,
185 branch: args.branch.clone(),
186 trunk: trunk.to_owned(),
187 seat: seat.to_string(),
188 trunk_before: String::new(),
189 trunk_commit: None,
190 steps,
191 next: next.clone(),
192 })?;
193 out.next(&next);
194 Ok(())
195}
196
197fn request_commands(seat: &Utf8Path, trunk: &str, branch: &str) -> Vec<String> {
200 let remote = git(seat, &["remote", "get-url", "origin"])
201 .ok()
202 .filter(|answer| answer.status.success())
203 .map(|answer| String::from_utf8_lossy(&answer.stdout).trim().to_owned())
204 .unwrap_or_default();
205 let create = if remote.contains("gitlab") {
206 format!(
207 "glab mr create --source-branch {branch} --target-branch {trunk} --squash-before-merge"
208 )
209 } else {
210 format!("gh pr create --base {trunk} --head {branch}")
211 };
212 vec![
213 create,
214 "the title is the trunk's commit message, so it is a scoped Conventional Commit".to_owned(),
215 "the body is yours; rk message --check --kind body judges it before you post it".to_owned(),
216 ]
217}
218
219fn preview(args: &IntegrateArgs, out: Output, run: &LocalRun<'_>) -> Result<(), RkError> {
225 let mut steps = Vec::new();
226 let trunk_before = rev_parse(run.seat, run.trunk)?;
227 warn_if_uncounted(
228 out,
229 run,
230 args.message.as_deref().unwrap_or_default(),
231 &[&format!("{trunk_before}...{}", args.branch)],
232 true,
233 );
234 steps.push("no fetch and no lock: a preview refreshes nothing".to_owned());
235 steps.push(format!(
236 "would rebase {} onto {}, or onto whatever the fetch brings",
237 args.branch,
238 integrate::short(&trunk_before)
239 ));
240 steps.push("would run the manual stage in the seat".to_owned());
241 steps.push(format!("would write one squash commit onto {}", run.trunk));
242 report(
243 args,
244 out,
245 run,
246 &trunk_before,
247 None,
248 steps,
249 &[format!(
250 "rk integrate {} --target {} --apply performs it",
251 args.branch, run.target
252 )],
253 )
254}
255
256fn local_path(args: &IntegrateArgs, out: Output, run: &LocalRun<'_>) -> Result<(), RkError> {
264 let message = args.message.as_deref().unwrap_or_default();
265 refuse_message(run.seat, message)?;
266 let mut steps = Vec::new();
267
268 if !args.apply {
269 return preview(args, out, run);
270 }
271
272 let _held = crate::landing::lock::acquire(run.git_dir)?;
275
276 let ledger_path = run.git_dir.join(integrate::LEDGER_PATH);
280 let mut ledger = read_ledger(&ledger_path)?;
281
282 refresh_trunk(run, &mut steps)?;
283
284 let trunk_before = rev_parse(run.seat, run.trunk)?;
285
286 let rebased = git(run.seat, &["rebase", &trunk_before])?;
289 if !rebased.status.success() {
290 let _ = git(run.seat, &["rebase", "--abort"]);
291 return Err(refuse(
292 Reason::StateDrift,
293 format!(
294 "{} does not rebase onto {} cleanly: {}",
295 args.branch,
296 integrate::short(&trunk_before),
297 last_line(&rebased.stderr)
298 ),
299 ));
300 }
301 steps.push(format!(
302 "rebased {} onto {}",
303 args.branch,
304 integrate::short(&trunk_before)
305 ));
306
307 let branch_tip = rev_parse(run.seat, &args.branch)?;
312
313 warn_if_uncounted(out, run, message, &[&trunk_before, &branch_tip], false);
316
317 let gate = gate(run.seat)?;
319 if !gate.status.success() {
320 out.child_passthrough(crate::events::ChildStream::Stderr, &gate.stderr);
321 out.child_passthrough(crate::events::ChildStream::Stdout, &gate.stdout);
322 return Err(refuse(
323 Reason::StateDrift,
324 format!(
325 "the manual stage failed in {}, so nothing reached {}",
326 run.seat, run.trunk
327 ),
328 ));
329 }
330 steps.push("the manual stage passed".to_owned());
331
332 let now = rev_parse(run.seat, &args.branch)?;
338 if now != branch_tip {
339 return Err(refuse(
340 Reason::StateDrift,
341 format!(
342 "{} moved from {} to {} while the gate ran, so the gate judged a tree that is no longer the branch's",
343 args.branch,
344 integrate::short(&branch_tip),
345 integrate::short(&now)
346 ),
347 ));
348 }
349 let commit = build_squash(run, &branch_tip, &trunk_before, message)?;
350 ledger.record(Entry {
360 branch: args.branch.clone(),
361 branch_tip,
362 trunk_commit: commit.clone(),
363 at: crate::landing::manifest::now(),
364 });
365 write_ledger(&ledger_path, &ledger)?;
366
367 publish(run, &commit, &trunk_before)?;
368 steps.push(format!(
369 "{} now carries {}",
370 run.trunk,
371 integrate::short(&commit)
372 ));
373 steps.push("recorded the integration".to_owned());
374
375 report(
376 args,
377 out,
378 run,
379 &trunk_before,
380 Some(commit),
381 steps,
382 &[
383 format!(
384 "git -C {} push origin {} pushes the trunk; it is a fast-forward and never a force",
385 run.target, run.trunk
386 ),
387 format!(
388 "rk worktree prune --target {} retires the seat, then its branch",
389 run.target
390 ),
391 ],
392 )
393}
394
395fn build_squash(
402 run: &LocalRun<'_>,
403 branch_tip: &str,
404 parent: &str,
405 message: &str,
406) -> Result<String, RkError> {
407 let tree = rev_parse(run.seat, &format!("{branch_tip}^{{tree}}"))?;
408 let built = git(
409 run.seat,
410 &["commit-tree", &tree, "-p", parent, "-m", message],
411 )?;
412 if !built.status.success() {
413 return Err(refuse(
414 Reason::Internal,
415 format!(
416 "the squash commit could not be built: {}",
417 last_line(&built.stderr)
418 ),
419 ));
420 }
421 Ok(String::from_utf8_lossy(&built.stdout).trim().to_owned())
422}
423
424fn refresh_trunk(run: &LocalRun<'_>, steps: &mut Vec<String>) -> Result<(), RkError> {
431 let named = git(run.seat, &["remote", "get-url", "origin"])?;
437 if !named.status.success() {
438 steps.push("no origin remote; the local trunk stands alone".to_owned());
439 return Ok(());
440 }
441 let fetched = git(run.seat, &["fetch", "--quiet", "origin"])?;
442 if !fetched.status.success() {
443 return Err(refuse(
444 Reason::ForgeTemporary,
445 format!(
446 "origin is configured and did not answer, so the trunk could not be refreshed: {}",
447 last_line(&fetched.stderr)
448 ),
449 ));
450 }
451 steps.push("fetched origin".to_owned());
452 let Some(remote) = rev_parse(run.seat, &format!("refs/remotes/origin/{}", run.trunk)).ok()
453 else {
454 steps.push(format!(
455 "origin carries no {} yet; the local trunk stands alone",
456 run.trunk
457 ));
458 return Ok(());
459 };
460 let local = rev_parse(run.seat, run.trunk)?;
461 let state = integrate::trunk_state(
462 local == remote,
463 is_ancestor(run.seat, &local, &remote),
464 is_ancestor(run.seat, &remote, &local),
465 );
466 if let Some(reason) = integrate::refuse_trunk_state(state) {
467 return Err(refuse(Reason::RemoteConflict, reason));
468 }
469 match state {
470 integrate::TrunkState::Behind => {
471 fast_forward_trunk(run, &remote, &local)?;
472 steps.push(format!(
473 "fast-forwarded {} to origin/{}",
474 run.trunk, run.trunk
475 ));
476 }
477 integrate::TrunkState::Ahead => {
478 steps.push(format!(
479 "{} carries integrations nobody pushed yet",
480 run.trunk
481 ));
482 }
483 integrate::TrunkState::Level | integrate::TrunkState::Diverged => {}
484 }
485 Ok(())
486}
487
488fn fast_forward_trunk(run: &LocalRun<'_>, to: &str, from: &str) -> Result<(), RkError> {
491 trunk_move(run, to, from, "fast-forward")
492}
493
494fn publish(run: &LocalRun<'_>, commit: &str, expected: &str) -> Result<(), RkError> {
496 trunk_move(run, commit, expected, "integration")
497}
498
499fn trunk_move(run: &LocalRun<'_>, to: &str, expected: &str, what: &str) -> Result<(), RkError> {
507 let holder = run
508 .seats
509 .iter()
510 .find(|seat| seat.branch.as_deref() == Some(run.trunk));
511 if let Some(holder) = holder {
512 if holder.head != expected {
513 return Err(moved(run.trunk, expected, &holder.head));
514 }
515 if is_dirty(&holder.path)? {
516 return Err(refuse(
517 Reason::StateDrift,
518 format!(
519 "{} has the trunk checked out and carries uncommitted changes, so the {what} would leave it behind its own HEAD",
520 holder.path
521 ),
522 ));
523 }
524 let merged = git(&holder.path, &["merge", "--ff-only", to])?;
525 if !merged.status.success() {
526 return Err(refuse(
527 Reason::StateDrift,
528 format!(
529 "the {what} is not a fast-forward of {}: {}",
530 holder.path,
531 last_line(&merged.stderr)
532 ),
533 ));
534 }
535 return Ok(());
536 }
537 let reference = format!("refs/heads/{}", run.trunk);
538 let swapped = git(run.seat, &["update-ref", &reference, to, expected])?;
539 if !swapped.status.success() {
540 let now = rev_parse(run.seat, run.trunk).unwrap_or_else(|_| "an unreadable tip".to_owned());
541 return Err(moved(run.trunk, expected, &now));
542 }
543 Ok(())
544}
545
546fn moved(trunk: &str, expected: &str, now: &str) -> RkError {
553 RkError::refusal(
554 Diagnostic::new(
555 Reason::StateDrift,
556 integrate::refuse_moved_trunk(expected, now).unwrap_or_else(|| {
557 format!(
558 "{trunk} could not be moved and stands at {}",
559 integrate::short(now)
560 )
561 }),
562 )
563 .target_state(format!(
564 "{trunk} stands where it stood; the staged evidence names a commit it does not reach, which every prune ignores"
565 )),
566 )
567}
568
569fn warn_if_uncounted(
583 out: Output,
584 run: &LocalRun<'_>,
585 message: &str,
586 range: &[&str],
587 previewed: bool,
588) {
589 let Some(intent) = integrate::release_intent(message) else {
590 return;
591 };
592 let rust_release = crate::landing::manifest::load(run.target)
593 .ok()
594 .flatten()
595 .is_some_and(|record| {
596 let release = &record.profile.release;
597 release.mode == crate::profile::ReleaseMode::Automatic
598 && release.driver.as_deref() == Some("rust")
599 });
600 if !rust_release {
601 return;
602 }
603 let mut diff = vec!["diff", "--name-only", "--no-renames", "-z"];
604 diff.extend_from_slice(range);
605 let Ok(changed) = git(run.seat, &diff) else {
606 return;
607 };
608 if !changed.status.success() {
609 return;
610 }
611 let changed = String::from_utf8_lossy(&changed.stdout).into_owned();
612 let changed: Vec<&str> = changed
613 .split('\0')
614 .filter(|path| !path.is_empty())
615 .collect();
616 let root_manifest = run.seat.as_std_path().join("Cargo.toml");
617 let probe = crate::cargo_package::probe(&root_manifest, &["--locked", "--offline"], |args| {
618 cargo_in(run.seat, args)
619 });
620 let Ok(crate::cargo_package::Probe::Listed(listing)) = probe else {
621 return;
622 };
623 if !listing.touched_by(changed.iter().copied()) {
624 out.warn(integrate::uncounted_release(intent, previewed));
625 }
626}
627
628fn cargo_in(seat: &Utf8Path, args: &[&str]) -> Result<crate::cargo_package::Answer, ()> {
630 let mut command = std::process::Command::new("cargo");
631 for var in GIT_HOOK_VARS {
632 command.env_remove(var);
633 }
634 command
635 .current_dir(seat.as_std_path())
636 .args(args)
637 .stdin(std::process::Stdio::null())
638 .output()
639 .map(|output| crate::cargo_package::Answer {
640 success: output.status.success(),
641 stdout: output.stdout,
642 stderr: output.stderr,
643 })
644 .map_err(|_| ())
645}
646
647fn gate(seat: &Utf8Path) -> Result<std::process::Output, RkError> {
649 let mut command = std::process::Command::new("pre-commit");
650 for var in GIT_HOOK_VARS {
651 command.env_remove(var);
652 }
653 command
654 .current_dir(seat.as_std_path())
655 .args(["run", "--hook-stage", "manual", "--all-files"])
656 .output()
657 .map_err(|source| {
658 RkError::subprocess(
659 Diagnostic::new(
660 Reason::SubprocessSpawn,
661 format!("pre-commit did not run in {seat}: {source}"),
662 )
663 .expected(
664 "pre-commit on PATH, which is what installs and runs this project's hooks",
665 )
666 .action("install pre-commit, or enter the project's devshell, and run it again")
667 .target_state("unchanged"),
668 )
669 })
670}
671
672fn report(
674 args: &IntegrateArgs,
675 out: Output,
676 run: &LocalRun<'_>,
677 trunk_before: &str,
678 trunk_commit: Option<String>,
679 steps: Vec<String>,
680 next: &[String],
681) -> Result<(), RkError> {
682 for step in &steps {
683 out.result_line(step);
684 }
685 out.emit(&Report {
686 schema: "rk.integrate/1",
687 mode: if args.apply { "apply" } else { "preview" },
688 target: run.target.to_string(),
689 integration: Integration::Local.as_str(),
690 authority_source: run.authority_source,
691 branch: args.branch.clone(),
692 trunk: run.trunk.to_owned(),
693 seat: run.seat.to_string(),
694 trunk_before: trunk_before.to_owned(),
695 trunk_commit,
696 steps,
697 next: next.to_vec(),
698 })?;
699 out.next(next);
700 Ok(())
701}
702
703const SHAPE: &str = "a scoped Conventional Commit: <type>(<scope>): <description>";
705
706const TYPES: [&str; 11] = [
709 "build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test",
710];
711
712fn refuse_message(target: &Utf8Path, text: &str) -> Result<(), RkError> {
733 let subject = text.lines().next().unwrap_or("").trim();
734 if subject.is_empty() {
735 return Err(refuse(
736 Reason::Usage,
737 "a local integration writes the trunk's commit message, so --message is required",
738 ));
739 }
740 if let Some(reason) = misshapen_subject(subject) {
741 return Err(refuse(Reason::Usage, reason));
742 }
743 let (findings, _) = crate::commands::message::judge(
744 text,
745 crate::cli::message::MessageKind::Commit,
746 target,
747 subject,
748 crate::commands::message::exempt_title(subject),
749 );
750 if findings.is_empty() {
751 return Ok(());
752 }
753 let named: Vec<String> = findings
754 .iter()
755 .map(|finding| format!("{}:{} {}", finding.class, finding.line, finding.detail))
756 .collect();
757 Err(refuse(
758 Reason::Usage,
759 format!(
760 "the trunk message carries {} finding{} the landed commit-msg stage would refuse, and git commit-tree fires no hook: {}",
761 findings.len(),
762 if findings.len() == 1 { "" } else { "s" },
763 named.join("; ")
764 ),
765 ))
766}
767
768#[must_use]
770fn misshapen_subject(subject: &str) -> Option<String> {
771 let Some((head, description)) = subject.split_once(": ") else {
772 return Some(format!("'{subject}' is not {SHAPE}"));
773 };
774 if description.trim().is_empty() {
775 return Some(format!(
776 "'{subject}' is not {SHAPE}: it states no description"
777 ));
778 }
779 let head = head.strip_suffix('!').unwrap_or(head);
782 let Some((kind, scope)) = head.split_once('(') else {
783 return Some(format!("'{subject}' is not {SHAPE}: it names no scope"));
784 };
785 let Some(scope) = scope.strip_suffix(')') else {
786 return Some(format!("'{subject}' is not {SHAPE}: its scope is unclosed"));
787 };
788 if !TYPES.contains(&kind) {
789 return Some(format!(
790 "'{kind}' is not a Conventional Commit type; the types are: {}",
791 TYPES.join(", ")
792 ));
793 }
794 if !crate::projection::scope_is_shaped(scope) {
795 return Some(format!(
796 "the scope '{scope}' is outside {}: lowercase letters, digits, and _ . / -",
797 crate::projection::SCOPE_SHAPE
798 ));
799 }
800 None
801}
802
803fn seats(target: &Utf8Path) -> Result<Vec<crate::worktree::Worktree>, RkError> {
805 let listed = git(target, &["worktree", "list", "--porcelain", "-z"])?;
806 if !listed.status.success() {
807 return Err(RkError::missing(
808 Diagnostic::new(
809 Reason::TargetNotFound,
810 format!("target {target} is not a git repository"),
811 )
812 .expected("a repository whose worktrees git can list"),
813 ));
814 }
815 crate::worktree::parse_worktrees(&listed.stdout)
816 .map_err(|detail| refuse(Reason::PrerequisiteUnmet, detail))
817}
818
819fn seat_for(
821 seats: &[crate::worktree::Worktree],
822 branch: &str,
823 target: &Utf8Path,
824) -> Result<Utf8PathBuf, RkError> {
825 seats
826 .iter()
827 .find(|seat| seat.branch.as_deref() == Some(branch))
828 .map(|seat| seat.path.clone())
829 .ok_or_else(|| {
830 refuse(
831 Reason::StateDrift,
832 format!(
833 "no worktree of {target} has {branch} checked out; rk worktree add {branch} --apply seats it"
834 ),
835 )
836 })
837}
838
839fn is_dirty(seat: &Utf8Path) -> Result<bool, RkError> {
841 let status = git(seat, &["status", "--porcelain"])?;
842 Ok(!status.stdout.is_empty())
843}
844
845fn rev_parse(seat: &Utf8Path, revision: &str) -> Result<String, RkError> {
847 let answer = git(seat, &["rev-parse", "--verify", "--quiet", revision])?;
848 if !answer.status.success() {
849 return Err(refuse(
850 Reason::StateDrift,
851 format!("{revision} does not resolve in {seat}"),
852 ));
853 }
854 Ok(String::from_utf8_lossy(&answer.stdout).trim().to_owned())
855}
856
857fn is_ancestor(seat: &Utf8Path, ancestor: &str, descendant: &str) -> bool {
859 git(seat, &["merge-base", "--is-ancestor", ancestor, descendant])
860 .is_ok_and(|answer| answer.status.success())
861}
862
863fn common_git_dir(target: &Utf8Path) -> Result<Utf8PathBuf, RkError> {
865 let answer = git(
866 target,
867 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
868 )?;
869 if !answer.status.success() {
870 return Err(RkError::missing(
871 Diagnostic::new(
872 Reason::TargetNotFound,
873 format!("target {target} is not a git repository"),
874 )
875 .expected("a repository whose common git directory git can name"),
876 ));
877 }
878 let path = String::from_utf8_lossy(&answer.stdout).trim().to_owned();
879 Utf8PathBuf::from_path_buf(std::path::PathBuf::from(path)).map_err(|path| {
880 refuse(
881 Reason::PrerequisiteUnmet,
882 format!("the common git directory {} is not UTF-8", path.display()),
883 )
884 })
885}
886
887fn read_ledger(path: &Utf8Path) -> Result<Ledger, RkError> {
889 match std::fs::read_to_string(path) {
890 Ok(text) => {
891 Ledger::parse(&text).map_err(|detail| refuse(Reason::UnsupportedSchema, detail))
892 }
893 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Ledger::default()),
894 Err(error) => Err(RkError::Io(error)),
895 }
896}
897
898fn write_ledger(path: &Utf8Path, ledger: &Ledger) -> Result<(), RkError> {
900 let text = ledger
901 .render()
902 .map_err(|detail| refuse(Reason::Internal, detail))?;
903 if let Some(parent) = path.parent() {
904 std::fs::create_dir_all(parent)?;
905 }
906 crate::atomic::write(path.as_std_path(), text.as_bytes())?;
907 Ok(())
908}
909
910fn refuse(reason: Reason, message: impl Into<String>) -> RkError {
912 RkError::refusal(
913 Diagnostic::new(reason, message).target_state("unchanged; the trunk stands where it stood"),
914 )
915}
916
917fn git(at: &Utf8Path, args: &[&str]) -> Result<std::process::Output, RkError> {
919 let mut command = std::process::Command::new(crate::probes::git_bin());
920 for var in GIT_HOOK_VARS {
921 command.env_remove(var);
922 }
923 command
924 .arg("-C")
925 .arg(at.as_std_path())
926 .args(args)
927 .output()
928 .map_err(|source| {
929 RkError::subprocess(
930 Diagnostic::new(
931 Reason::SubprocessSpawn,
932 format!("git did not run in {at}: {source}"),
933 )
934 .target_state("unchanged"),
935 )
936 })
937}
938
939#[cfg(test)]
940mod tests {
941 use camino::Utf8Path;
942
943 use super::{misshapen_subject, refuse_message};
944
945 #[test]
946 fn a_trunk_subject_is_held_to_the_landed_convention() {
947 assert_eq!(misshapen_subject("feat(integrate): land the verb"), None);
948 assert_eq!(misshapen_subject("feat(a/b)!: break it"), None);
949 assert!(
950 misshapen_subject("land the verb")
951 .expect("an unscoped subject refuses")
952 .contains("Conventional Commit")
953 );
954 assert!(
955 misshapen_subject("feat: land the verb")
956 .expect("a missing scope refuses")
957 .contains("names no scope")
958 );
959 assert!(
960 misshapen_subject("feat(integrate): ")
961 .expect("an empty description refuses")
962 .contains("no description")
963 );
964 assert!(
965 misshapen_subject("wat(integrate): land it")
966 .expect("an unknown type refuses")
967 .contains("not a Conventional Commit type")
968 );
969 assert!(
970 misshapen_subject("feat(Integrate): land it")
971 .expect("a misshapen scope refuses")
972 .contains("outside")
973 );
974 }
975
976 #[test]
980 fn a_trunk_body_is_judged_by_the_one_message_owner() {
981 let dir = tempfile::tempdir().expect("a scratch dir exists");
982 let target = Utf8Path::from_path(dir.path()).expect("utf-8");
983 refuse_message(target, "feat(integrate): land the verb\n\nThe context.\n")
984 .expect("a clean message passes");
985 let error = refuse_message(
986 target,
987 "feat(integrate): land the verb\n\nCo-Authored-By: Claude <noreply@anthropic.com>\n",
988 )
989 .expect_err("agent attribution refuses")
990 .to_string();
991 assert!(error.contains("attribution"), "{error}");
992 assert!(error.contains("commit-tree fires no hook"), "{error}");
993 let error = refuse_message(
994 target,
995 "feat(integrate): land the verb\n\nSee .draft/plan.md for the rest.\n",
996 )
997 .expect_err("an internal path refuses")
998 .to_string();
999 assert!(error.contains("internal-path"), "{error}");
1000 let error = refuse_message(target, "")
1001 .expect_err("an empty message refuses")
1002 .to_string();
1003 assert!(error.contains("--message is required"), "{error}");
1004 }
1005}