Skip to main content

release_kit/commands/
integrate.rs

1//! `rk integrate`: move one implementation onto the trunk through the
2//! authority the target recorded.
3//!
4//! The local path is a transaction. It creates the squash commit as an
5//! unreferenced object, so nothing is published until one compare-and-swap
6//! carries the trunk tip observed before the gate ran. Every refusal
7//! therefore leaves the trunk at the tip it started from, and there is no
8//! half-integrated trunk to undo.
9//!
10//! The forge path stops where this repository's boundary already sits.
11//! It runs the project's own pre-push stage by pushing the branch, and it
12//! names the request command for the detected forge. Opening a request is
13//! an operator-named action in the landed routing block, and no verb here
14//! authors a request body: `rk message --check --kind body` is what
15//! judges one.
16//!
17//! SATISFIES git:a-local-integration-is-a-transaction
18//! SATISFIES git:the-manual-stage-is-the-pre-integrate-contract
19//! SATISFIES git:a-local-integration-warns-of-an-uncounted-release
20
21use 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/// The machine form of a report.
33#[derive(Debug, Serialize)]
34struct Report {
35    /// The shape version of this document.
36    schema: &'static str,
37    /// `preview` or `apply`.
38    mode: &'static str,
39    /// The target this ran against.
40    target: String,
41    /// The authority this execution used.
42    integration: &'static str,
43    /// Where the authority came from: `record` or `flag`.
44    authority_source: &'static str,
45    /// The branch integrated.
46    branch: String,
47    /// The trunk it integrated onto.
48    trunk: String,
49    /// The seat the gate ran in.
50    seat: String,
51    /// The trunk tip before this integration.
52    trunk_before: String,
53    /// The squash commit, on an applied local integration alone.
54    #[serde(skip_serializing_if = "Option::is_none")]
55    trunk_commit: Option<String>,
56    /// The steps that ran, in order.
57    steps: Vec<String>,
58    /// What the operator does next.
59    next: Vec<String>,
60}
61
62/// Integrate one branch; preview unless `--apply`.
63///
64/// # Errors
65///
66/// Returns [`RkError::Refusal`] for every judgment that stops the
67/// transaction, [`RkError::Missing`] for a target that is not a
68/// repository, and [`RkError::Io`] where the ledger cannot be written.
69pub 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
109/// Everything the local transaction resolved before it acts.
110struct 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
119/// The authority this execution uses, and where it came from.
120///
121/// The record answers, because the record is what landed: the hook block
122/// that admits or refuses these writes, the routing block an agent reads,
123/// and the forge protections the setup installed all render from it. A
124/// configuration edit is pending input to the next landing, never a
125/// runtime override — taking it here would run a local integration in a
126/// target whose installed controls still say forge, which is the split
127/// authority `target-config:the-config-is-input-and-the-record-is-the-record`
128/// exists to prevent. `--local` and `--forge` override one execution and
129/// write nothing.
130fn 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
147/// The forge path: gate through the push, then name the request command.
148fn 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        // The push is the boundary under forge integration, and the
158        // project's own pre-push stage is what git fires on it. Nothing
159        // here passes --no-verify.
160        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
197/// The request command for the forge the remote names, and the rule that
198/// the body is the operator's.
199fn 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
219/// The dry run, which refreshes nothing.
220///
221/// No lock and no fetch: a preview that moved remote-tracking refs and
222/// wrote `FETCH_HEAD` would make its own promise false, and it would
223/// also report a plan built on state it changed while reporting it.
224fn 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
256/// The local transaction.
257///
258/// It reads its evidence ledger before it builds anything, so a ledger
259/// this binary cannot parse refuses while the trunk still stands where it
260/// stood, and it stages the evidence before it publishes, so every
261/// fallible part of writing it happens with the trunk unmoved. The one
262/// ref this moves is the trunk, through one compare-and-swap.
263fn 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    // The lock is the common git directory, so two seats of one clone
273    // collide on it: they write one trunk ref.
274    let _held = crate::landing::lock::acquire(run.git_dir)?;
275
276    // The evidence ledger is read and judged before anything is built, so
277    // an unreadable one refuses with the trunk untouched rather than
278    // after the squash is published.
279    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    // Bring the branch onto the trunk. A conflict refuses and leaves both
287    // refs where it found them.
288    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    // The authoritative observation of the branch, taken before the gate
308    // so the tree the gate judges is the tree that reaches the trunk. It
309    // is re-observed below: a commit landing in the seat under the gate
310    // would otherwise be squashed without having passed it.
311    let branch_tip = rev_parse(run.seat, &args.branch)?;
312
313    // Before the gate, so the operator reads it while the trunk still
314    // stands where it stood and can stop to retype the message.
315    warn_if_uncounted(out, run, message, &[&trunk_before, &branch_tip], false);
316
317    // The gate. One line, one stage, no hook identifier read.
318    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    // The re-observation. One object feeds the tree that is integrated
333    // and the tip the evidence certifies, and that object is the one the
334    // gate judged. The lock bounds this binary's own runs and bounds no
335    // hand at the desk, so both directions are checked rather than
336    // assumed.
337    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    // Publish it with one compare-and-swap carrying the tip observed
351    // before the gate ran. A trunk that moved under the gate refuses here
352    // and nothing was written.
353    // The evidence is rendered and staged before the publication, so
354    // every fallible part of writing it happens while the trunk still
355    // stands where it stood. Evidence naming a commit no ref carries
356    // proves nothing: both prune verbs require the trunk to reach the
357    // recorded commit, so a staged entry whose publication then fails is
358    // inert rather than dangerous.
359    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
395/// The squash commit, as an object nothing references yet.
396///
397/// `git commit-tree` takes the rebased branch's tree and the trunk tip as
398/// its one parent, so the object is a squash by construction and no ref
399/// moves. Publishing it is a separate compare-and-swap, which is what
400/// makes every refusal before that point leave nothing behind.
401fn 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
424/// Bring the local trunk level with its remote, where a remote answers.
425///
426/// Only a divergence refuses. A trunk behind its remote fast-forwards,
427/// and a trunk ahead of it is the ordinary state under local
428/// integration: integrations accumulate and the operator pushes when
429/// they decide to.
430fn refresh_trunk(run: &LocalRun<'_>, steps: &mut Vec<String>) -> Result<(), RkError> {
431    // An absent origin and an origin that will not answer are different
432    // facts. A project with no remote integrates against its local trunk
433    // alone; a project whose remote exists and cannot be reached may have
434    // a newer or divergent trunk behind that failure, so proceeding from
435    // stale local state could publish an integration nobody can push.
436    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
488/// Move the trunk ref forward, through the seat that holds it where one
489/// does, so no working tree is left behind its own HEAD.
490fn fast_forward_trunk(run: &LocalRun<'_>, to: &str, from: &str) -> Result<(), RkError> {
491    trunk_move(run, to, from, "fast-forward")
492}
493
494/// Publish the squash commit.
495fn publish(run: &LocalRun<'_>, commit: &str, expected: &str) -> Result<(), RkError> {
496    trunk_move(run, commit, expected, "integration")
497}
498
499/// One trunk move, compare-and-swap against `expected`.
500///
501/// Where a worktree has the trunk checked out, the move goes through that
502/// worktree's own fast-forward merge, so its index and working tree move
503/// with HEAD. Where none does, the ref moves directly. Neither form
504/// discards anything: a merge that is not a fast-forward refuses, and the
505/// direct move refuses on a tip that is not `expected`.
506fn 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
546/// The refusal for a trunk that moved between the observation and the write.
547///
548/// This one refusal comes after the evidence was staged, so it says what
549/// is actually on disk rather than repeating the general claim: the trunk
550/// stands where it stood, and the staged entry names a commit the trunk
551/// does not reach, which both prune verbs ignore.
552fn 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
569/// Warn where the message states release intent and the squash changes no
570/// file the crate ships, under a record whose release bot is release-plz.
571///
572/// release-plz attributes a commit to a package only where the commit
573/// changes a file `cargo package --list` prints, so such a commit reaches
574/// neither the changelog nor a release. The checks run cheapest first, so a
575/// message stating no intent or a target another driver releases spawns no
576/// cargo. Anything that cannot answer leaves the integration silent: a
577/// warning built on an unproved listing teaches the operator to ignore it.
578/// The listing runs `--locked --offline`, so it writes nothing into the
579/// tree the gate judges.
580///
581/// SATISFIES git:a-local-integration-warns-of-an-uncounted-release
582fn 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
628/// One cargo call in the seat, for the listing the warning reads.
629fn 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
647/// Run the pre-integrate gate: one stage, one command.
648fn 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
672/// Emit one report.
673fn 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
703/// The shape a trunk commit message states, named once.
704const SHAPE: &str = "a scoped Conventional Commit: <type>(<scope>): <description>";
705
706/// The Conventional Commit types the branch grammar admits, which is the
707/// same set the landed `rk-branch-name` hook tests.
708const TYPES: [&str; 11] = [
709    "build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test",
710];
711
712/// Refuse a trunk commit message the landed guards would refuse.
713///
714/// `at` is the seat, never the trunk checkout: whether a path is ignored
715/// is answered by the ignore rules standing where the implementation was
716/// written, and a branch that adds one is exactly the case the landed
717/// `commit-msg` stage would have judged there.
718///
719/// `git commit-tree` fires no hook, so the whole `commit-msg` stage is
720/// applied here instead, and it is applied through the one owner rather
721/// than a second, weaker copy: the Conventional Commit shape the
722/// `conventional-pre-commit` hook holds, and then every finding
723/// `rk message --check` reports — agent attribution, a reference to a
724/// path the target ignores, and a scope outside the title check's shape.
725/// A message that reaches the trunk here reaches a permanent history and
726/// a forge-facing changelog, so the two paths judge one set.
727///
728/// # Errors
729///
730/// Returns [`RkError::Refusal`] naming what a landed guard would have
731/// refused, with the target untouched.
732fn 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/// Why a subject is not a scoped Conventional Commit, or `None`.
769#[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    // A breaking `!` sits after the scope, so it comes off the head
780    // before the scope is read out of it.
781    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
803/// Every worktree this clone registers.
804fn 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
819/// The worktree seating one branch.
820fn 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
839/// Whether a working tree carries uncommitted changes, untracked included.
840fn is_dirty(seat: &Utf8Path) -> Result<bool, RkError> {
841    let status = git(seat, &["status", "--porcelain"])?;
842    Ok(!status.stdout.is_empty())
843}
844
845/// One revision's full object name.
846fn 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
857/// Whether `ancestor` is reachable from `descendant`.
858fn 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
863/// The clone's common git directory, which every linked seat shares.
864fn 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
887/// Read the ledger, or an empty one where none exists.
888fn 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
898/// Write the ledger, atomically.
899fn 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
910/// One refusal that states the target was left alone.
911fn 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
917/// Run one git command against a directory; a spawn failure refuses.
918fn 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    /// The whole landed commit-msg stage runs here, not the subject
977    /// alone: `git commit-tree` fires no hook, so a body the landed
978    /// guard refuses must refuse here or it reaches a permanent history.
979    #[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}