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
20use 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/// The machine form of a report.
32#[derive(Debug, Serialize)]
33struct Report {
34    /// The shape version of this document.
35    schema: &'static str,
36    /// `preview` or `apply`.
37    mode: &'static str,
38    /// The target this ran against.
39    target: String,
40    /// The authority this execution used.
41    integration: &'static str,
42    /// Where the authority came from: `record` or `flag`.
43    authority_source: &'static str,
44    /// The branch integrated.
45    branch: String,
46    /// The trunk it integrated onto.
47    trunk: String,
48    /// The seat the gate ran in.
49    seat: String,
50    /// The trunk tip before this integration.
51    trunk_before: String,
52    /// The squash commit, on an applied local integration alone.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    trunk_commit: Option<String>,
55    /// The steps that ran, in order.
56    steps: Vec<String>,
57    /// What the operator does next.
58    next: Vec<String>,
59}
60
61/// Integrate one branch; preview unless `--apply`.
62///
63/// # Errors
64///
65/// Returns [`RkError::Refusal`] for every judgment that stops the
66/// transaction, [`RkError::Missing`] for a target that is not a
67/// repository, and [`RkError::Io`] where the ledger cannot be written.
68pub 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
108/// Everything the local transaction resolved before it acts.
109struct 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
118/// The authority this execution uses, and where it came from.
119///
120/// The record answers, because the record is what landed: the hook block
121/// that admits or refuses these writes, the routing block an agent reads,
122/// and the forge protections the setup installed all render from it. A
123/// configuration edit is pending input to the next landing, never a
124/// runtime override — taking it here would run a local integration in a
125/// target whose installed controls still say forge, which is the split
126/// authority `target-config:the-config-is-input-and-the-record-is-the-record`
127/// exists to prevent. `--local` and `--forge` override one execution and
128/// write nothing.
129fn 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
146/// The forge path: gate through the push, then name the request command.
147fn 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        // The push is the boundary under forge integration, and the
157        // project's own pre-push stage is what git fires on it. Nothing
158        // here passes --no-verify.
159        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
196/// The request command for the forge the remote names, and the rule that
197/// the body is the operator's.
198fn 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
218/// The dry run, which refreshes nothing.
219///
220/// No lock and no fetch: a preview that moved remote-tracking refs and
221/// wrote `FETCH_HEAD` would make its own promise false, and it would
222/// also report a plan built on state it changed while reporting it.
223fn 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
248/// The local transaction.
249///
250/// It reads its evidence ledger before it builds anything, so a ledger
251/// this binary cannot parse refuses while the trunk still stands where it
252/// stood, and it stages the evidence before it publishes, so every
253/// fallible part of writing it happens with the trunk unmoved. The one
254/// ref this moves is the trunk, through one compare-and-swap.
255fn 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    // The lock is the common git directory, so two seats of one clone
265    // collide on it: they write one trunk ref.
266    let _held = crate::landing::lock::acquire(run.git_dir)?;
267
268    // The evidence ledger is read and judged before anything is built, so
269    // an unreadable one refuses with the trunk untouched rather than
270    // after the squash is published.
271    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    // Bring the branch onto the trunk. A conflict refuses and leaves both
279    // refs where it found them.
280    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    // The authoritative observation of the branch, taken before the gate
300    // so the tree the gate judges is the tree that reaches the trunk. It
301    // is re-observed below: a commit landing in the seat under the gate
302    // would otherwise be squashed without having passed it.
303    let branch_tip = rev_parse(run.seat, &args.branch)?;
304
305    // The gate. One line, one stage, no hook identifier read.
306    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    // The re-observation. One object feeds the tree that is integrated
321    // and the tip the evidence certifies, and that object is the one the
322    // gate judged. The lock bounds this binary's own runs and bounds no
323    // hand at the desk, so both directions are checked rather than
324    // assumed.
325    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    // Publish it with one compare-and-swap carrying the tip observed
339    // before the gate ran. A trunk that moved under the gate refuses here
340    // and nothing was written.
341    // The evidence is rendered and staged before the publication, so
342    // every fallible part of writing it happens while the trunk still
343    // stands where it stood. Evidence naming a commit no ref carries
344    // proves nothing: both prune verbs require the trunk to reach the
345    // recorded commit, so a staged entry whose publication then fails is
346    // inert rather than dangerous.
347    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
383/// The squash commit, as an object nothing references yet.
384///
385/// `git commit-tree` takes the rebased branch's tree and the trunk tip as
386/// its one parent, so the object is a squash by construction and no ref
387/// moves. Publishing it is a separate compare-and-swap, which is what
388/// makes every refusal before that point leave nothing behind.
389fn 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
412/// Bring the local trunk level with its remote, where a remote answers.
413///
414/// Only a divergence refuses. A trunk behind its remote fast-forwards,
415/// and a trunk ahead of it is the ordinary state under local
416/// integration: integrations accumulate and the operator pushes when
417/// they decide to.
418fn refresh_trunk(run: &LocalRun<'_>, steps: &mut Vec<String>) -> Result<(), RkError> {
419    // An absent origin and an origin that will not answer are different
420    // facts. A project with no remote integrates against its local trunk
421    // alone; a project whose remote exists and cannot be reached may have
422    // a newer or divergent trunk behind that failure, so proceeding from
423    // stale local state could publish an integration nobody can push.
424    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
476/// Move the trunk ref forward, through the seat that holds it where one
477/// does, so no working tree is left behind its own HEAD.
478fn fast_forward_trunk(run: &LocalRun<'_>, to: &str, from: &str) -> Result<(), RkError> {
479    trunk_move(run, to, from, "fast-forward")
480}
481
482/// Publish the squash commit.
483fn publish(run: &LocalRun<'_>, commit: &str, expected: &str) -> Result<(), RkError> {
484    trunk_move(run, commit, expected, "integration")
485}
486
487/// One trunk move, compare-and-swap against `expected`.
488///
489/// Where a worktree has the trunk checked out, the move goes through that
490/// worktree's own fast-forward merge, so its index and working tree move
491/// with HEAD. Where none does, the ref moves directly. Neither form
492/// discards anything: a merge that is not a fast-forward refuses, and the
493/// direct move refuses on a tip that is not `expected`.
494fn 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
534/// The refusal for a trunk that moved between the observation and the write.
535///
536/// This one refusal comes after the evidence was staged, so it says what
537/// is actually on disk rather than repeating the general claim: the trunk
538/// stands where it stood, and the staged entry names a commit the trunk
539/// does not reach, which both prune verbs ignore.
540fn 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
557/// Run the pre-integrate gate: one stage, one command.
558fn 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
582/// Emit one report.
583fn 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
613/// The shape a trunk commit message states, named once.
614const SHAPE: &str = "a scoped Conventional Commit: <type>(<scope>): <description>";
615
616/// The Conventional Commit types the branch grammar admits, which is the
617/// same set the landed `rk-branch-name` hook tests.
618const TYPES: [&str; 11] = [
619    "build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test",
620];
621
622/// Refuse a trunk commit message the landed guards would refuse.
623///
624/// `at` is the seat, never the trunk checkout: whether a path is ignored
625/// is answered by the ignore rules standing where the implementation was
626/// written, and a branch that adds one is exactly the case the landed
627/// `commit-msg` stage would have judged there.
628///
629/// `git commit-tree` fires no hook, so the whole `commit-msg` stage is
630/// applied here instead, and it is applied through the one owner rather
631/// than a second, weaker copy: the Conventional Commit shape the
632/// `conventional-pre-commit` hook holds, and then every finding
633/// `rk message --check` reports — agent attribution, a reference to a
634/// path the target ignores, and a scope outside the title check's shape.
635/// A message that reaches the trunk here reaches a permanent history and
636/// a forge-facing changelog, so the two paths judge one set.
637///
638/// # Errors
639///
640/// Returns [`RkError::Refusal`] naming what a landed guard would have
641/// refused, with the target untouched.
642fn 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/// Why a subject is not a scoped Conventional Commit, or `None`.
679#[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    // A breaking `!` sits after the scope, so it comes off the head
690    // before the scope is read out of it.
691    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
713/// Every worktree this clone registers.
714fn 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
729/// The worktree seating one branch.
730fn 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
749/// Whether a working tree carries uncommitted changes, untracked included.
750fn is_dirty(seat: &Utf8Path) -> Result<bool, RkError> {
751    let status = git(seat, &["status", "--porcelain"])?;
752    Ok(!status.stdout.is_empty())
753}
754
755/// One revision's full object name.
756fn 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
767/// Whether `ancestor` is reachable from `descendant`.
768fn 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
773/// The clone's common git directory, which every linked seat shares.
774fn 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
797/// Read the ledger, or an empty one where none exists.
798fn 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
808/// Write the ledger, atomically.
809fn 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
820/// One refusal that states the target was left alone.
821fn 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
827/// Run one git command against a directory; a spawn failure refuses.
828fn 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    /// The whole landed commit-msg stage runs here, not the subject
887    /// alone: `git commit-tree` fires no hook, so a body the landed
888    /// guard refuses must refuse here or it reaches a permanent history.
889    #[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}