1use camino::{Utf8Path, Utf8PathBuf};
21use serde::Serialize;
22
23use crate::cli::integrate::IntegrateArgs;
24use crate::diagnostic::{Diagnostic, Reason};
25use crate::error::RkError;
26use crate::integrate::{self, Entry, Ledger};
27use crate::landing::Integration;
28use crate::maintenance::{GIT_HOOK_VARS, last_line};
29use crate::output::Output;
30
31#[derive(Debug, Serialize)]
33struct Report {
34 schema: &'static str,
36 mode: &'static str,
38 target: String,
40 integration: &'static str,
42 authority_source: &'static str,
44 branch: String,
46 trunk: String,
48 seat: String,
50 trunk_before: String,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 trunk_commit: Option<String>,
55 steps: Vec<String>,
57 next: Vec<String>,
59}
60
61pub fn run(args: &IntegrateArgs) -> Result<(), RkError> {
69 let out = Output::new(args.json);
70 let target = &args.target;
71 let trunk = crate::config::trunk_of(target.as_std_path())?;
72
73 let (integration, authority_source) = authority(args, target)?;
74 if let Some(reason) = integrate::refuse_branch_name(&args.branch, &trunk) {
75 return Err(refuse(Reason::Usage, reason));
76 }
77
78 let git_dir = common_git_dir(target)?;
79 let seats = seats(target)?;
80 let seat = seat_for(&seats, &args.branch, target)?;
81 let dirty = is_dirty(&seat)?;
82 if dirty {
83 return Err(refuse(
84 Reason::StateDrift,
85 format!(
86 "{seat} has uncommitted changes, so the gate would judge a tree nobody reviewed"
87 ),
88 ));
89 }
90
91 match integration {
92 Integration::Forge => forge_path(args, out, &seat, &trunk, authority_source),
93 Integration::Local => local_path(
94 args,
95 out,
96 &LocalRun {
97 target,
98 git_dir: &git_dir,
99 seat: &seat,
100 seats: &seats,
101 trunk: &trunk,
102 authority_source,
103 },
104 ),
105 }
106}
107
108struct LocalRun<'a> {
110 target: &'a Utf8Path,
111 git_dir: &'a Utf8Path,
112 seat: &'a Utf8Path,
113 seats: &'a [crate::worktree::Worktree],
114 trunk: &'a str,
115 authority_source: &'static str,
116}
117
118fn authority(
130 args: &IntegrateArgs,
131 target: &Utf8Path,
132) -> Result<(Integration, &'static str), RkError> {
133 if args.local {
134 return Ok((Integration::Local, "flag"));
135 }
136 if args.forge {
137 return Ok((Integration::Forge, "flag"));
138 }
139 let recorded = crate::landing::manifest::load(target)?.map(|record| record.git.integration);
140 Ok(recorded.map_or(
141 (crate::landing::manifest::integration_forge(), "default"),
142 |mode| (mode, "record"),
143 ))
144}
145
146fn forge_path(
148 args: &IntegrateArgs,
149 out: Output,
150 seat: &Utf8Path,
151 trunk: &str,
152 authority_source: &'static str,
153) -> Result<(), RkError> {
154 let mut steps = Vec::new();
155 if args.apply {
156 let pushed = git(seat, &["push", "--set-upstream", "origin", &args.branch])?;
160 if !pushed.status.success() {
161 return Err(refuse(
162 Reason::RemoteConflict,
163 format!(
164 "pushing {} refused: {}",
165 args.branch,
166 last_line(&pushed.stderr)
167 ),
168 ));
169 }
170 steps.push(format!("pushed {} to origin", args.branch));
171 } else {
172 steps.push(format!("would push {} to origin", args.branch));
173 }
174 let next = request_commands(seat, trunk, &args.branch);
175 for line in &steps {
176 out.result_line(line);
177 }
178 out.emit(&Report {
179 schema: "rk.integrate/1",
180 mode: if args.apply { "apply" } else { "preview" },
181 target: args.target.to_string(),
182 integration: Integration::Forge.as_str(),
183 authority_source,
184 branch: args.branch.clone(),
185 trunk: trunk.to_owned(),
186 seat: seat.to_string(),
187 trunk_before: String::new(),
188 trunk_commit: None,
189 steps,
190 next: next.clone(),
191 })?;
192 out.next(&next);
193 Ok(())
194}
195
196fn request_commands(seat: &Utf8Path, trunk: &str, branch: &str) -> Vec<String> {
199 let remote = git(seat, &["remote", "get-url", "origin"])
200 .ok()
201 .filter(|answer| answer.status.success())
202 .map(|answer| String::from_utf8_lossy(&answer.stdout).trim().to_owned())
203 .unwrap_or_default();
204 let create = if remote.contains("gitlab") {
205 format!(
206 "glab mr create --source-branch {branch} --target-branch {trunk} --squash-before-merge"
207 )
208 } else {
209 format!("gh pr create --base {trunk} --head {branch}")
210 };
211 vec![
212 create,
213 "the title is the trunk's commit message, so it is a scoped Conventional Commit".to_owned(),
214 "the body is yours; rk message --check --kind body judges it before you post it".to_owned(),
215 ]
216}
217
218fn preview(args: &IntegrateArgs, out: Output, run: &LocalRun<'_>) -> Result<(), RkError> {
224 let mut steps = Vec::new();
225 let trunk_before = rev_parse(run.seat, run.trunk)?;
226 steps.push("no fetch and no lock: a preview refreshes nothing".to_owned());
227 steps.push(format!(
228 "would rebase {} onto {}, or onto whatever the fetch brings",
229 args.branch,
230 integrate::short(&trunk_before)
231 ));
232 steps.push("would run the manual stage in the seat".to_owned());
233 steps.push(format!("would write one squash commit onto {}", run.trunk));
234 report(
235 args,
236 out,
237 run,
238 &trunk_before,
239 None,
240 steps,
241 &[format!(
242 "rk integrate {} --target {} --apply performs it",
243 args.branch, run.target
244 )],
245 )
246}
247
248fn local_path(args: &IntegrateArgs, out: Output, run: &LocalRun<'_>) -> Result<(), RkError> {
256 let message = args.message.as_deref().unwrap_or_default();
257 refuse_message(run.seat, message)?;
258 let mut steps = Vec::new();
259
260 if !args.apply {
261 return preview(args, out, run);
262 }
263
264 let _held = crate::landing::lock::acquire(run.git_dir)?;
267
268 let ledger_path = run.git_dir.join(integrate::LEDGER_PATH);
272 let mut ledger = read_ledger(&ledger_path)?;
273
274 refresh_trunk(run, &mut steps)?;
275
276 let trunk_before = rev_parse(run.seat, run.trunk)?;
277
278 let rebased = git(run.seat, &["rebase", &trunk_before])?;
281 if !rebased.status.success() {
282 let _ = git(run.seat, &["rebase", "--abort"]);
283 return Err(refuse(
284 Reason::StateDrift,
285 format!(
286 "{} does not rebase onto {} cleanly: {}",
287 args.branch,
288 integrate::short(&trunk_before),
289 last_line(&rebased.stderr)
290 ),
291 ));
292 }
293 steps.push(format!(
294 "rebased {} onto {}",
295 args.branch,
296 integrate::short(&trunk_before)
297 ));
298
299 let branch_tip = rev_parse(run.seat, &args.branch)?;
304
305 let gate = gate(run.seat)?;
307 if !gate.status.success() {
308 out.child_passthrough(crate::events::ChildStream::Stderr, &gate.stderr);
309 out.child_passthrough(crate::events::ChildStream::Stdout, &gate.stdout);
310 return Err(refuse(
311 Reason::StateDrift,
312 format!(
313 "the manual stage failed in {}, so nothing reached {}",
314 run.seat, run.trunk
315 ),
316 ));
317 }
318 steps.push("the manual stage passed".to_owned());
319
320 let now = rev_parse(run.seat, &args.branch)?;
326 if now != branch_tip {
327 return Err(refuse(
328 Reason::StateDrift,
329 format!(
330 "{} moved from {} to {} while the gate ran, so the gate judged a tree that is no longer the branch's",
331 args.branch,
332 integrate::short(&branch_tip),
333 integrate::short(&now)
334 ),
335 ));
336 }
337 let commit = build_squash(run, &branch_tip, &trunk_before, message)?;
338 ledger.record(Entry {
348 branch: args.branch.clone(),
349 branch_tip,
350 trunk_commit: commit.clone(),
351 at: crate::landing::manifest::now(),
352 });
353 write_ledger(&ledger_path, &ledger)?;
354
355 publish(run, &commit, &trunk_before)?;
356 steps.push(format!(
357 "{} now carries {}",
358 run.trunk,
359 integrate::short(&commit)
360 ));
361 steps.push("recorded the integration".to_owned());
362
363 report(
364 args,
365 out,
366 run,
367 &trunk_before,
368 Some(commit),
369 steps,
370 &[
371 format!(
372 "git -C {} push origin {} pushes the trunk; it is a fast-forward and never a force",
373 run.target, run.trunk
374 ),
375 format!(
376 "rk worktree prune --target {} retires the seat, then its branch",
377 run.target
378 ),
379 ],
380 )
381}
382
383fn build_squash(
390 run: &LocalRun<'_>,
391 branch_tip: &str,
392 parent: &str,
393 message: &str,
394) -> Result<String, RkError> {
395 let tree = rev_parse(run.seat, &format!("{branch_tip}^{{tree}}"))?;
396 let built = git(
397 run.seat,
398 &["commit-tree", &tree, "-p", parent, "-m", message],
399 )?;
400 if !built.status.success() {
401 return Err(refuse(
402 Reason::Internal,
403 format!(
404 "the squash commit could not be built: {}",
405 last_line(&built.stderr)
406 ),
407 ));
408 }
409 Ok(String::from_utf8_lossy(&built.stdout).trim().to_owned())
410}
411
412fn refresh_trunk(run: &LocalRun<'_>, steps: &mut Vec<String>) -> Result<(), RkError> {
419 let named = git(run.seat, &["remote", "get-url", "origin"])?;
425 if !named.status.success() {
426 steps.push("no origin remote; the local trunk stands alone".to_owned());
427 return Ok(());
428 }
429 let fetched = git(run.seat, &["fetch", "--quiet", "origin"])?;
430 if !fetched.status.success() {
431 return Err(refuse(
432 Reason::ForgeTemporary,
433 format!(
434 "origin is configured and did not answer, so the trunk could not be refreshed: {}",
435 last_line(&fetched.stderr)
436 ),
437 ));
438 }
439 steps.push("fetched origin".to_owned());
440 let Some(remote) = rev_parse(run.seat, &format!("refs/remotes/origin/{}", run.trunk)).ok()
441 else {
442 steps.push(format!(
443 "origin carries no {} yet; the local trunk stands alone",
444 run.trunk
445 ));
446 return Ok(());
447 };
448 let local = rev_parse(run.seat, run.trunk)?;
449 let state = integrate::trunk_state(
450 local == remote,
451 is_ancestor(run.seat, &local, &remote),
452 is_ancestor(run.seat, &remote, &local),
453 );
454 if let Some(reason) = integrate::refuse_trunk_state(state) {
455 return Err(refuse(Reason::RemoteConflict, reason));
456 }
457 match state {
458 integrate::TrunkState::Behind => {
459 fast_forward_trunk(run, &remote, &local)?;
460 steps.push(format!(
461 "fast-forwarded {} to origin/{}",
462 run.trunk, run.trunk
463 ));
464 }
465 integrate::TrunkState::Ahead => {
466 steps.push(format!(
467 "{} carries integrations nobody pushed yet",
468 run.trunk
469 ));
470 }
471 integrate::TrunkState::Level | integrate::TrunkState::Diverged => {}
472 }
473 Ok(())
474}
475
476fn fast_forward_trunk(run: &LocalRun<'_>, to: &str, from: &str) -> Result<(), RkError> {
479 trunk_move(run, to, from, "fast-forward")
480}
481
482fn publish(run: &LocalRun<'_>, commit: &str, expected: &str) -> Result<(), RkError> {
484 trunk_move(run, commit, expected, "integration")
485}
486
487fn trunk_move(run: &LocalRun<'_>, to: &str, expected: &str, what: &str) -> Result<(), RkError> {
495 let holder = run
496 .seats
497 .iter()
498 .find(|seat| seat.branch.as_deref() == Some(run.trunk));
499 if let Some(holder) = holder {
500 if holder.head != expected {
501 return Err(moved(run.trunk, expected, &holder.head));
502 }
503 if is_dirty(&holder.path)? {
504 return Err(refuse(
505 Reason::StateDrift,
506 format!(
507 "{} has the trunk checked out and carries uncommitted changes, so the {what} would leave it behind its own HEAD",
508 holder.path
509 ),
510 ));
511 }
512 let merged = git(&holder.path, &["merge", "--ff-only", to])?;
513 if !merged.status.success() {
514 return Err(refuse(
515 Reason::StateDrift,
516 format!(
517 "the {what} is not a fast-forward of {}: {}",
518 holder.path,
519 last_line(&merged.stderr)
520 ),
521 ));
522 }
523 return Ok(());
524 }
525 let reference = format!("refs/heads/{}", run.trunk);
526 let swapped = git(run.seat, &["update-ref", &reference, to, expected])?;
527 if !swapped.status.success() {
528 let now = rev_parse(run.seat, run.trunk).unwrap_or_else(|_| "an unreadable tip".to_owned());
529 return Err(moved(run.trunk, expected, &now));
530 }
531 Ok(())
532}
533
534fn moved(trunk: &str, expected: &str, now: &str) -> RkError {
541 RkError::refusal(
542 Diagnostic::new(
543 Reason::StateDrift,
544 integrate::refuse_moved_trunk(expected, now).unwrap_or_else(|| {
545 format!(
546 "{trunk} could not be moved and stands at {}",
547 integrate::short(now)
548 )
549 }),
550 )
551 .target_state(format!(
552 "{trunk} stands where it stood; the staged evidence names a commit it does not reach, which every prune ignores"
553 )),
554 )
555}
556
557fn gate(seat: &Utf8Path) -> Result<std::process::Output, RkError> {
559 let mut command = std::process::Command::new("pre-commit");
560 for var in GIT_HOOK_VARS {
561 command.env_remove(var);
562 }
563 command
564 .current_dir(seat.as_std_path())
565 .args(["run", "--hook-stage", "manual", "--all-files"])
566 .output()
567 .map_err(|source| {
568 RkError::subprocess(
569 Diagnostic::new(
570 Reason::SubprocessSpawn,
571 format!("pre-commit did not run in {seat}: {source}"),
572 )
573 .expected(
574 "pre-commit on PATH, which is what installs and runs this project's hooks",
575 )
576 .action("install pre-commit, or enter the project's devshell, and run it again")
577 .target_state("unchanged"),
578 )
579 })
580}
581
582fn report(
584 args: &IntegrateArgs,
585 out: Output,
586 run: &LocalRun<'_>,
587 trunk_before: &str,
588 trunk_commit: Option<String>,
589 steps: Vec<String>,
590 next: &[String],
591) -> Result<(), RkError> {
592 for step in &steps {
593 out.result_line(step);
594 }
595 out.emit(&Report {
596 schema: "rk.integrate/1",
597 mode: if args.apply { "apply" } else { "preview" },
598 target: run.target.to_string(),
599 integration: Integration::Local.as_str(),
600 authority_source: run.authority_source,
601 branch: args.branch.clone(),
602 trunk: run.trunk.to_owned(),
603 seat: run.seat.to_string(),
604 trunk_before: trunk_before.to_owned(),
605 trunk_commit,
606 steps,
607 next: next.to_vec(),
608 })?;
609 out.next(next);
610 Ok(())
611}
612
613const SHAPE: &str = "a scoped Conventional Commit: <type>(<scope>): <description>";
615
616const TYPES: [&str; 11] = [
619 "build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test",
620];
621
622fn refuse_message(target: &Utf8Path, text: &str) -> Result<(), RkError> {
643 let subject = text.lines().next().unwrap_or("").trim();
644 if subject.is_empty() {
645 return Err(refuse(
646 Reason::Usage,
647 "a local integration writes the trunk's commit message, so --message is required",
648 ));
649 }
650 if let Some(reason) = misshapen_subject(subject) {
651 return Err(refuse(Reason::Usage, reason));
652 }
653 let (findings, _) = crate::commands::message::judge(
654 text,
655 crate::cli::message::MessageKind::Commit,
656 target,
657 subject,
658 crate::commands::message::exempt_title(subject),
659 );
660 if findings.is_empty() {
661 return Ok(());
662 }
663 let named: Vec<String> = findings
664 .iter()
665 .map(|finding| format!("{}:{} {}", finding.class, finding.line, finding.detail))
666 .collect();
667 Err(refuse(
668 Reason::Usage,
669 format!(
670 "the trunk message carries {} finding{} the landed commit-msg stage would refuse, and git commit-tree fires no hook: {}",
671 findings.len(),
672 if findings.len() == 1 { "" } else { "s" },
673 named.join("; ")
674 ),
675 ))
676}
677
678#[must_use]
680fn misshapen_subject(subject: &str) -> Option<String> {
681 let Some((head, description)) = subject.split_once(": ") else {
682 return Some(format!("'{subject}' is not {SHAPE}"));
683 };
684 if description.trim().is_empty() {
685 return Some(format!(
686 "'{subject}' is not {SHAPE}: it states no description"
687 ));
688 }
689 let head = head.strip_suffix('!').unwrap_or(head);
692 let Some((kind, scope)) = head.split_once('(') else {
693 return Some(format!("'{subject}' is not {SHAPE}: it names no scope"));
694 };
695 let Some(scope) = scope.strip_suffix(')') else {
696 return Some(format!("'{subject}' is not {SHAPE}: its scope is unclosed"));
697 };
698 if !TYPES.contains(&kind) {
699 return Some(format!(
700 "'{kind}' is not a Conventional Commit type; the types are: {}",
701 TYPES.join(", ")
702 ));
703 }
704 if !crate::projection::scope_is_shaped(scope) {
705 return Some(format!(
706 "the scope '{scope}' is outside {}: lowercase letters, digits, and _ . / -",
707 crate::projection::SCOPE_SHAPE
708 ));
709 }
710 None
711}
712
713fn seats(target: &Utf8Path) -> Result<Vec<crate::worktree::Worktree>, RkError> {
715 let listed = git(target, &["worktree", "list", "--porcelain", "-z"])?;
716 if !listed.status.success() {
717 return Err(RkError::missing(
718 Diagnostic::new(
719 Reason::TargetNotFound,
720 format!("target {target} is not a git repository"),
721 )
722 .expected("a repository whose worktrees git can list"),
723 ));
724 }
725 crate::worktree::parse_worktrees(&listed.stdout)
726 .map_err(|detail| refuse(Reason::PrerequisiteUnmet, detail))
727}
728
729fn seat_for(
731 seats: &[crate::worktree::Worktree],
732 branch: &str,
733 target: &Utf8Path,
734) -> Result<Utf8PathBuf, RkError> {
735 seats
736 .iter()
737 .find(|seat| seat.branch.as_deref() == Some(branch))
738 .map(|seat| seat.path.clone())
739 .ok_or_else(|| {
740 refuse(
741 Reason::StateDrift,
742 format!(
743 "no worktree of {target} has {branch} checked out; rk worktree add {branch} --apply seats it"
744 ),
745 )
746 })
747}
748
749fn is_dirty(seat: &Utf8Path) -> Result<bool, RkError> {
751 let status = git(seat, &["status", "--porcelain"])?;
752 Ok(!status.stdout.is_empty())
753}
754
755fn rev_parse(seat: &Utf8Path, revision: &str) -> Result<String, RkError> {
757 let answer = git(seat, &["rev-parse", "--verify", "--quiet", revision])?;
758 if !answer.status.success() {
759 return Err(refuse(
760 Reason::StateDrift,
761 format!("{revision} does not resolve in {seat}"),
762 ));
763 }
764 Ok(String::from_utf8_lossy(&answer.stdout).trim().to_owned())
765}
766
767fn is_ancestor(seat: &Utf8Path, ancestor: &str, descendant: &str) -> bool {
769 git(seat, &["merge-base", "--is-ancestor", ancestor, descendant])
770 .is_ok_and(|answer| answer.status.success())
771}
772
773fn common_git_dir(target: &Utf8Path) -> Result<Utf8PathBuf, RkError> {
775 let answer = git(
776 target,
777 &["rev-parse", "--path-format=absolute", "--git-common-dir"],
778 )?;
779 if !answer.status.success() {
780 return Err(RkError::missing(
781 Diagnostic::new(
782 Reason::TargetNotFound,
783 format!("target {target} is not a git repository"),
784 )
785 .expected("a repository whose common git directory git can name"),
786 ));
787 }
788 let path = String::from_utf8_lossy(&answer.stdout).trim().to_owned();
789 Utf8PathBuf::from_path_buf(std::path::PathBuf::from(path)).map_err(|path| {
790 refuse(
791 Reason::PrerequisiteUnmet,
792 format!("the common git directory {} is not UTF-8", path.display()),
793 )
794 })
795}
796
797fn read_ledger(path: &Utf8Path) -> Result<Ledger, RkError> {
799 match std::fs::read_to_string(path) {
800 Ok(text) => {
801 Ledger::parse(&text).map_err(|detail| refuse(Reason::UnsupportedSchema, detail))
802 }
803 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Ledger::default()),
804 Err(error) => Err(RkError::Io(error)),
805 }
806}
807
808fn write_ledger(path: &Utf8Path, ledger: &Ledger) -> Result<(), RkError> {
810 let text = ledger
811 .render()
812 .map_err(|detail| refuse(Reason::Internal, detail))?;
813 if let Some(parent) = path.parent() {
814 std::fs::create_dir_all(parent)?;
815 }
816 crate::atomic::write(path.as_std_path(), text.as_bytes())?;
817 Ok(())
818}
819
820fn refuse(reason: Reason, message: impl Into<String>) -> RkError {
822 RkError::refusal(
823 Diagnostic::new(reason, message).target_state("unchanged; the trunk stands where it stood"),
824 )
825}
826
827fn git(at: &Utf8Path, args: &[&str]) -> Result<std::process::Output, RkError> {
829 let mut command = std::process::Command::new(crate::probes::git_bin());
830 for var in GIT_HOOK_VARS {
831 command.env_remove(var);
832 }
833 command
834 .arg("-C")
835 .arg(at.as_std_path())
836 .args(args)
837 .output()
838 .map_err(|source| {
839 RkError::subprocess(
840 Diagnostic::new(
841 Reason::SubprocessSpawn,
842 format!("git did not run in {at}: {source}"),
843 )
844 .target_state("unchanged"),
845 )
846 })
847}
848
849#[cfg(test)]
850mod tests {
851 use camino::Utf8Path;
852
853 use super::{misshapen_subject, refuse_message};
854
855 #[test]
856 fn a_trunk_subject_is_held_to_the_landed_convention() {
857 assert_eq!(misshapen_subject("feat(integrate): land the verb"), None);
858 assert_eq!(misshapen_subject("feat(a/b)!: break it"), None);
859 assert!(
860 misshapen_subject("land the verb")
861 .expect("an unscoped subject refuses")
862 .contains("Conventional Commit")
863 );
864 assert!(
865 misshapen_subject("feat: land the verb")
866 .expect("a missing scope refuses")
867 .contains("names no scope")
868 );
869 assert!(
870 misshapen_subject("feat(integrate): ")
871 .expect("an empty description refuses")
872 .contains("no description")
873 );
874 assert!(
875 misshapen_subject("wat(integrate): land it")
876 .expect("an unknown type refuses")
877 .contains("not a Conventional Commit type")
878 );
879 assert!(
880 misshapen_subject("feat(Integrate): land it")
881 .expect("a misshapen scope refuses")
882 .contains("outside")
883 );
884 }
885
886 #[test]
890 fn a_trunk_body_is_judged_by_the_one_message_owner() {
891 let dir = tempfile::tempdir().expect("a scratch dir exists");
892 let target = Utf8Path::from_path(dir.path()).expect("utf-8");
893 refuse_message(target, "feat(integrate): land the verb\n\nThe context.\n")
894 .expect("a clean message passes");
895 let error = refuse_message(
896 target,
897 "feat(integrate): land the verb\n\nCo-Authored-By: Claude <noreply@anthropic.com>\n",
898 )
899 .expect_err("agent attribution refuses")
900 .to_string();
901 assert!(error.contains("attribution"), "{error}");
902 assert!(error.contains("commit-tree fires no hook"), "{error}");
903 let error = refuse_message(
904 target,
905 "feat(integrate): land the verb\n\nSee .draft/plan.md for the rest.\n",
906 )
907 .expect_err("an internal path refuses")
908 .to_string();
909 assert!(error.contains("internal-path"), "{error}");
910 let error = refuse_message(target, "")
911 .expect_err("an empty message refuses")
912 .to_string();
913 assert!(error.contains("--message is required"), "{error}");
914 }
915}