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//! Under the `rebase` merge form the local path builds no commit at all:
18//! it publishes the rebased branch tip with the same compare-and-swap, so
19//! every commit of the branch reaches the trunk with its own message, and
20//! each of those messages is judged first.
21//!
22//! SATISFIES git:a-local-integration-is-a-transaction
23//! SATISFIES git:a-rebase-integration-publishes-the-branch-tip
24//! SATISFIES git:the-manual-stage-is-the-pre-integrate-contract
25//! SATISFIES git:a-local-integration-warns-of-an-uncounted-release
26
27use camino::{Utf8Path, Utf8PathBuf};
28use serde::Serialize;
29
30use crate::cli::integrate::IntegrateArgs;
31use crate::diagnostic::{Diagnostic, Reason};
32use crate::error::RkError;
33use crate::integrate::{self, Entry, Ledger};
34use crate::landing::{Integration, MergeForm};
35use crate::maintenance::{GIT_HOOK_VARS, last_line};
36use crate::output::Output;
37
38/// The machine form of a report.
39#[derive(Debug, Serialize)]
40struct Report {
41    /// The shape version of this document.
42    schema: &'static str,
43    /// `preview` or `apply`.
44    mode: &'static str,
45    /// The target this ran against.
46    target: String,
47    /// The authority this execution used.
48    integration: &'static str,
49    /// Where the authority came from: `record` or `flag`.
50    authority_source: &'static str,
51    /// The recorded merge form: `squash` or `rebase`.
52    merge_form: &'static str,
53    /// The branch integrated.
54    branch: String,
55    /// The trunk it integrated onto.
56    trunk: String,
57    /// The seat the gate ran in.
58    seat: String,
59    /// The trunk tip before this integration.
60    trunk_before: String,
61    /// The commit the trunk now carries, on an applied local integration
62    /// alone: the squash commit, or the branch tip under `rebase`.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    trunk_commit: Option<String>,
65    /// The steps that ran, in order.
66    steps: Vec<String>,
67    /// What the operator does next.
68    next: Vec<String>,
69}
70
71/// Integrate one branch; preview unless `--apply`.
72///
73/// # Errors
74///
75/// Returns [`RkError::Refusal`] for every judgment that stops the
76/// transaction, [`RkError::Missing`] for a target that is not a
77/// repository, and [`RkError::Io`] where the ledger cannot be written.
78pub fn run(args: &IntegrateArgs) -> Result<(), RkError> {
79    let out = Output::new(args.json);
80    let target = &args.target;
81    let trunk = crate::config::trunk_of(target.as_std_path())?;
82
83    let (integration, authority_source) = authority(args, target)?;
84    let merge_form = recorded_merge_form(target)?;
85    let scope_required = recorded_scope_rule(target)?;
86    if let Some(reason) = integrate::refuse_branch_name(&args.branch, &trunk) {
87        return Err(refuse(Reason::Usage, reason));
88    }
89
90    let git_dir = common_git_dir(target)?;
91    let seats = seats(target)?;
92    let seat = seat_for(&seats, &args.branch, target)?;
93    let dirty = is_dirty(&seat)?;
94    if dirty {
95        return Err(refuse(
96            Reason::StateDrift,
97            format!(
98                "{seat} has uncommitted changes, so the gate would judge a tree nobody reviewed"
99            ),
100        ));
101    }
102
103    let run = LocalRun {
104        target,
105        git_dir: &git_dir,
106        seat: &seat,
107        seats: &seats,
108        trunk: &trunk,
109        authority_source,
110        merge_form,
111        scope_required,
112    };
113    match (integration, merge_form) {
114        (Integration::Forge, _) => forge_path(
115            args,
116            out,
117            &seat,
118            &trunk,
119            authority_source,
120            merge_form,
121            scope_required,
122        ),
123        (Integration::Local, MergeForm::Squash) => local_path(args, out, &run),
124        (Integration::Local, MergeForm::Rebase) => rebase_path(args, out, &run),
125    }
126}
127
128/// Everything the local transaction resolved before it acts.
129struct LocalRun<'a> {
130    target: &'a Utf8Path,
131    git_dir: &'a Utf8Path,
132    seat: &'a Utf8Path,
133    seats: &'a [crate::worktree::Worktree],
134    trunk: &'a str,
135    authority_source: &'static str,
136    merge_form: MergeForm,
137    /// Whether a trunk message must name a scope: wherever the record
138    /// names a release, because the changelog reads it.
139    scope_required: bool,
140}
141
142/// The authority this execution uses, and where it came from.
143///
144/// The record answers, because the record is what landed: the hook block
145/// that admits or refuses these writes, the routing block an agent reads,
146/// and the forge protections the setup installed all render from it. A
147/// configuration edit is pending input to the next landing, never a
148/// runtime override — taking it here would run a local integration in a
149/// target whose installed controls still say forge, which is the split
150/// authority `target-config:the-config-is-input-and-the-record-is-the-record`
151/// exists to prevent. `--local` and `--forge` override one execution and
152/// write nothing.
153fn authority(
154    args: &IntegrateArgs,
155    target: &Utf8Path,
156) -> Result<(Integration, &'static str), RkError> {
157    if args.local {
158        return Ok((Integration::Local, "flag"));
159    }
160    if args.forge {
161        return Ok((Integration::Forge, "flag"));
162    }
163    let recorded = crate::landing::manifest::load(target)?.map(|record| record.git.integration);
164    Ok(recorded.map_or(
165        (crate::landing::manifest::integration_forge(), "default"),
166        |mode| (mode, "record"),
167    ))
168}
169
170/// The merge form this execution uses: the record's, as for the authority,
171/// and `squash` where no record exists or the record predates the form.
172///
173/// No flag overrides it. The authority flags exist because the same
174/// branch can reach the trunk either way, and the form decides which
175/// messages the trunk keeps, which the landed forge settings and gates
176/// were rendered for.
177fn recorded_merge_form(target: &Utf8Path) -> Result<MergeForm, RkError> {
178    Ok(crate::landing::manifest::load(target)?
179        .map_or_else(crate::landing::manifest::merge_form_squash, |record| {
180            record.git.merge_form
181        }))
182}
183
184/// Whether a trunk message must name a scope, as the record says: a
185/// release reads the scope, so every mode but `none` requires one, and a
186/// target with no record keeps the requirement it always had.
187///
188/// SATISFIES project-profile:a-release-less-landing-carries-no-release-text
189fn recorded_scope_rule(target: &Utf8Path) -> Result<bool, RkError> {
190    Ok(crate::landing::manifest::load(target)?
191        .is_none_or(|record| record.profile.release.mode != crate::profile::ReleaseMode::None))
192}
193
194/// The forge path: gate through the push, then name the request command.
195fn forge_path(
196    args: &IntegrateArgs,
197    out: Output,
198    seat: &Utf8Path,
199    trunk: &str,
200    authority_source: &'static str,
201    merge_form: MergeForm,
202    scope_required: bool,
203) -> Result<(), RkError> {
204    let mut steps = Vec::new();
205    if args.apply {
206        // The push is the boundary under forge integration, and the
207        // project's own pre-push stage is what git fires on it. Nothing
208        // here passes --no-verify.
209        let pushed = git(seat, &["push", "--set-upstream", "origin", &args.branch])?;
210        if !pushed.status.success() {
211            return Err(refuse(
212                Reason::RemoteConflict,
213                format!(
214                    "pushing {} refused: {}",
215                    args.branch,
216                    last_line(&pushed.stderr)
217                ),
218            ));
219        }
220        steps.push(format!("pushed {} to origin", args.branch));
221    } else {
222        steps.push(format!("would push {} to origin", args.branch));
223    }
224    let next = request_commands(
225        &origin_url(seat),
226        trunk,
227        &args.branch,
228        merge_form,
229        scope_required,
230    );
231    for line in &steps {
232        out.result_line(line);
233    }
234    out.emit(&Report {
235        schema: "rk.integrate/1",
236        mode: if args.apply { "apply" } else { "preview" },
237        target: args.target.to_string(),
238        integration: Integration::Forge.as_str(),
239        authority_source,
240        merge_form: merge_form.as_str(),
241        branch: args.branch.clone(),
242        trunk: trunk.to_owned(),
243        seat: seat.to_string(),
244        trunk_before: String::new(),
245        trunk_commit: None,
246        steps,
247        next: next.clone(),
248    })?;
249    out.next(&next);
250    Ok(())
251}
252
253/// The URL of the seat's `origin`, or empty where none answers.
254fn origin_url(seat: &Utf8Path) -> String {
255    git(seat, &["remote", "get-url", "origin"])
256        .ok()
257        .filter(|answer| answer.status.success())
258        .map(|answer| String::from_utf8_lossy(&answer.stdout).trim().to_owned())
259        .unwrap_or_default()
260}
261
262/// The request command for the forge the remote names, and what reaches
263/// the trunk from the request under the recorded merge form.
264///
265/// Under `squash` the request's title becomes the trunk's one commit, and
266/// GitLab is asked to squash. Under `rebase` every commit of the branch
267/// reaches the trunk with its own message, so no squash is asked for and
268/// each message is the one the commit-msg judgment holds. Where the record
269/// names no release, the guidance asks for a Conventional Commit whose
270/// scope is preferred and not required, because no changelog reads it.
271///
272/// SATISFIES git:the-merge-form-is-a-landing-parameter
273/// SATISFIES project-profile:a-release-less-landing-carries-no-release-text
274fn request_commands(
275    remote: &str,
276    trunk: &str,
277    branch: &str,
278    form: MergeForm,
279    scope_required: bool,
280) -> Vec<String> {
281    let squash = form == MergeForm::Squash;
282    let create = if remote.contains("gitlab") {
283        format!(
284            "glab mr create --source-branch {branch} --target-branch {trunk}{}",
285            if squash { " --squash-before-merge" } else { "" }
286        )
287    } else {
288        format!("gh pr create --base {trunk} --head {branch}")
289    };
290    let reaches = match (squash, scope_required) {
291        (true, true) => {
292            "the title is the trunk's commit message, so it is a scoped Conventional Commit"
293        }
294        (true, false) => {
295            "the title is the trunk's commit message, so it is a Conventional Commit, whose scope is preferred and not required"
296        }
297        (false, true) => {
298            "every commit of the branch reaches the trunk with its own message, so each one passes the commit-msg judgment: a scoped Conventional Commit with no guarded content"
299        }
300        (false, false) => {
301            "every commit of the branch reaches the trunk with its own message, so each one passes the commit-msg judgment: a Conventional Commit, whose scope is preferred and not required, with no guarded content"
302        }
303    };
304    vec![
305        create,
306        reaches.to_owned(),
307        "the body is yours; rk message --check --kind body judges it before you post it".to_owned(),
308    ]
309}
310
311/// Rebase the branch in its seat onto `onto`, with the integration's own
312/// semantics rather than the clone's preferences.
313///
314/// Both local merge forms run this one rebase. A merge in the branch is
315/// flattened, never recreated, whatever `rebase.rebaseMerges` says. No
316/// other branch moves, whatever `rebase.updateRefs` says, because the
317/// trunk is the one ref an integration publishes. No `fixup!` commit is
318/// folded and nothing is stashed, so the commits the gate judges are the
319/// commits the branch carries. A conflict refuses and leaves every ref
320/// where it found it.
321///
322/// SATISFIES git:a-rebase-integration-publishes-the-branch-tip
323fn rebase_onto(run: &LocalRun<'_>, branch: &str, onto: &str) -> Result<(), RkError> {
324    let rebased = git(
325        run.seat,
326        &REBASE_ARGS
327            .iter()
328            .copied()
329            .chain([onto])
330            .collect::<Vec<_>>(),
331    )?;
332    if !rebased.status.success() {
333        let _ = git(run.seat, &["rebase", "--abort"]);
334        return Err(refuse(
335            Reason::StateDrift,
336            format!(
337                "{branch} does not rebase onto {} cleanly: {}",
338                integrate::short(onto),
339                last_line(&rebased.stderr)
340            ),
341        ));
342    }
343    Ok(())
344}
345
346/// The rebase every local integration runs, before the commit it rebases
347/// onto: each option overrides the clone's configured preference of the
348/// same name.
349const REBASE_ARGS: [&str; 5] = [
350    "rebase",
351    "--no-rebase-merges",
352    "--no-update-refs",
353    "--no-autosquash",
354    "--no-autostash",
355];
356
357/// Refuse a range to publish that holds a merge commit.
358///
359/// The rebase flattens merges, and this does not trust it to: the trunk
360/// takes no merge commit from any actor, so the range is read back before
361/// the publication, and a merge in it refuses with the trunk unmoved.
362///
363/// SATISFIES git:a-rebase-integration-publishes-the-branch-tip
364fn refuse_merges_in_range(run: &LocalRun<'_>, from: &str, tip: &str) -> Result<(), RkError> {
365    let listed = git(
366        run.seat,
367        &["rev-list", "--merges", &format!("{from}..{tip}")],
368    )?;
369    if !listed.status.success() {
370        return Err(refuse(
371            Reason::StateDrift,
372            format!(
373                "the range {}..{} could not be read for merge commits: {}",
374                integrate::short(from),
375                integrate::short(tip),
376                last_line(&listed.stderr)
377            ),
378        ));
379    }
380    let merges: Vec<String> = String::from_utf8_lossy(&listed.stdout)
381        .lines()
382        .filter(|line| !line.is_empty())
383        .map(integrate::short)
384        .collect();
385    if merges.is_empty() {
386        return Ok(());
387    }
388    Err(refuse(
389        Reason::StateDrift,
390        format!(
391            "the rebased range {}..{} still holds the merge commit {}, and the trunk takes no merge commit, so nothing was published",
392            integrate::short(from),
393            integrate::short(tip),
394            merges.join(", ")
395        ),
396    ))
397}
398
399/// The dry run, which refreshes nothing.
400///
401/// No lock and no fetch: a preview that moved remote-tracking refs and
402/// wrote `FETCH_HEAD` would make its own promise false, and it would
403/// also report a plan built on state it changed while reporting it.
404fn preview(args: &IntegrateArgs, out: Output, run: &LocalRun<'_>) -> Result<(), RkError> {
405    let mut steps = Vec::new();
406    let trunk_before = rev_parse(run.seat, run.trunk)?;
407    warn_if_uncounted(
408        out,
409        run,
410        args.message.as_deref().unwrap_or_default(),
411        &[&format!("{trunk_before}...{}", args.branch)],
412        true,
413    );
414    steps.push("no fetch and no lock: a preview refreshes nothing".to_owned());
415    steps.push(format!(
416        "would rebase {} onto {}, or onto whatever the fetch brings",
417        args.branch,
418        integrate::short(&trunk_before)
419    ));
420    steps.push("would run the manual stage in the seat".to_owned());
421    steps.push(format!("would write one squash commit onto {}", run.trunk));
422    report(
423        args,
424        out,
425        run,
426        &trunk_before,
427        None,
428        steps,
429        &[format!(
430            "rk integrate {} --target {} --apply performs it",
431            args.branch, run.target
432        )],
433    )
434}
435
436/// The local transaction.
437///
438/// It reads its evidence ledger before it builds anything, so a ledger
439/// this binary cannot parse refuses while the trunk still stands where it
440/// stood, and it stages the evidence before it publishes, so every
441/// fallible part of writing it happens with the trunk unmoved. The one
442/// ref this moves is the trunk, through [`trunk_move`]'s publication.
443fn local_path(args: &IntegrateArgs, out: Output, run: &LocalRun<'_>) -> Result<(), RkError> {
444    let message = args.message.as_deref().unwrap_or_default();
445    refuse_message(run.seat, message, run.scope_required)?;
446    let mut steps = Vec::new();
447
448    if !args.apply {
449        return preview(args, out, run);
450    }
451
452    // The lock is the common git directory, so two seats of one clone
453    // collide on it: they write one trunk ref.
454    let _held = crate::landing::lock::acquire(run.git_dir)?;
455
456    // The evidence ledger is read and judged before anything is built, so
457    // an unreadable one refuses with the trunk untouched rather than
458    // after the squash is published.
459    let ledger_path = run.git_dir.join(integrate::LEDGER_PATH);
460    let mut ledger = read_ledger(&ledger_path)?;
461
462    refresh_trunk(run, &mut steps)?;
463
464    let trunk_before = rev_parse(run.seat, run.trunk)?;
465
466    // Bring the branch onto the trunk. A conflict refuses and leaves both
467    // refs where it found them.
468    rebase_onto(run, &args.branch, &trunk_before)?;
469    steps.push(format!(
470        "rebased {} onto {}",
471        args.branch,
472        integrate::short(&trunk_before)
473    ));
474
475    // The authoritative observation of the branch, taken before the gate
476    // so the tree the gate judges is the tree that reaches the trunk. It
477    // is re-observed below: a commit landing in the seat under the gate
478    // would otherwise be squashed without having passed it.
479    let branch_tip = rev_parse(run.seat, &args.branch)?;
480
481    // Before the gate, so the operator reads it while the trunk still
482    // stands where it stood and can stop to retype the message.
483    warn_if_uncounted(out, run, message, &[&trunk_before, &branch_tip], false);
484
485    // The gate. One line, one stage, no hook identifier read.
486    let (runner, gate) = gate(run.seat)?;
487    if !gate.status.success() {
488        out.child_passthrough(crate::events::ChildStream::Stderr, &gate.stderr);
489        out.child_passthrough(crate::events::ChildStream::Stdout, &gate.stdout);
490        return Err(refuse(
491            Reason::StateDrift,
492            format!(
493                "the manual stage failed in {} under {runner}, so nothing reached {}",
494                run.seat, run.trunk
495            ),
496        ));
497    }
498    steps.push(format!("the manual stage passed under {runner}"));
499
500    // The re-observation. One object feeds the tree that is integrated
501    // and the tip the evidence certifies, and that object is the one the
502    // gate judged. The lock bounds this binary's own runs and bounds no
503    // hand at the desk, so both directions are checked rather than
504    // assumed.
505    let now = rev_parse(run.seat, &args.branch)?;
506    if now != branch_tip {
507        return Err(refuse(
508            Reason::StateDrift,
509            format!(
510                "{} moved from {} to {} while the gate ran, so the gate judged a tree that is no longer the branch's",
511                args.branch,
512                integrate::short(&branch_tip),
513                integrate::short(&now)
514            ),
515        ));
516    }
517    let commit = build_squash(run, &branch_tip, &trunk_before, message)?;
518    // Publish it against the tip observed before the gate ran. A trunk
519    // that moved under the gate refuses here and nothing was written.
520    // The evidence is rendered and staged before the publication, so
521    // every fallible part of writing it happens while the trunk still
522    // stands where it stood. Evidence naming a commit no ref carries
523    // proves nothing: both prune verbs require the trunk to reach the
524    // recorded commit, so a staged entry whose publication then fails is
525    // inert rather than dangerous.
526    ledger.record(Entry {
527        branch: args.branch.clone(),
528        branch_tip,
529        trunk_commit: commit.clone(),
530        at: crate::landing::manifest::now(),
531    });
532    write_ledger(&ledger_path, &ledger)?;
533
534    let drift = publish(run, &commit, &trunk_before)?;
535    steps.push(format!(
536        "{} now carries {}",
537        run.trunk,
538        integrate::short(&commit)
539    ));
540    steps.push("recorded the integration".to_owned());
541    if let Some(drift) = &drift {
542        steps.push(drift.step(run.trunk));
543    }
544
545    report(
546        args,
547        out,
548        run,
549        &trunk_before,
550        Some(commit.clone()),
551        steps,
552        &[
553            format!(
554                "git -C {} push origin {} pushes the trunk; it is a fast-forward and never a force",
555                run.target, run.trunk
556            ),
557            format!(
558                "rk worktree prune --target {} retires the seat, then its branch",
559                run.target
560            ),
561        ],
562    )?;
563    drift.map_or(Ok(()), |drift| Err(drift.into_error(run.trunk, &commit)))
564}
565
566/// The local transaction under the `rebase` merge form.
567///
568/// It is the squash transaction with the build step removed: the same
569/// lock, ledger read, refresh, rebase, gate, and re-observation, and the
570/// same publication, which moves the trunk to the rebased branch tip
571/// itself once the range is read back with no merge commit in it.
572/// Every commit between the trunk and that tip becomes a trunk commit, so
573/// each message is judged before the gate, and none is written here.
574///
575/// SATISFIES git:a-rebase-integration-publishes-the-branch-tip
576fn rebase_path(args: &IntegrateArgs, out: Output, run: &LocalRun<'_>) -> Result<(), RkError> {
577    refuse_message_under_rebase(args)?;
578    if !args.apply {
579        return rebase_preview(args, out, run);
580    }
581    let mut steps = Vec::new();
582
583    let _held = crate::landing::lock::acquire(run.git_dir)?;
584    let ledger_path = run.git_dir.join(integrate::LEDGER_PATH);
585    let mut ledger = read_ledger(&ledger_path)?;
586
587    refresh_trunk(run, &mut steps)?;
588
589    let trunk_before = rev_parse(run.seat, run.trunk)?;
590
591    rebase_onto(run, &args.branch, &trunk_before)?;
592    steps.push(format!(
593        "rebased {} onto {}",
594        args.branch,
595        integrate::short(&trunk_before)
596    ));
597
598    // The one observation the judgment, the gate, the evidence, and the
599    // publication all rest on; it is re-observed after the gate.
600    let branch_tip = rev_parse(run.seat, &args.branch)?;
601    let commits = branch_commits(run.seat, &trunk_before, &branch_tip, &args.branch)?;
602    refuse_commit_messages(run.seat, &commits, run.scope_required)?;
603    steps.push(format!(
604        "judged the message of each of {} commit{}",
605        commits.len(),
606        if commits.len() == 1 { "" } else { "s" }
607    ));
608
609    let (runner, gate) = gate(run.seat)?;
610    if !gate.status.success() {
611        out.child_passthrough(crate::events::ChildStream::Stderr, &gate.stderr);
612        out.child_passthrough(crate::events::ChildStream::Stdout, &gate.stdout);
613        return Err(refuse(
614            Reason::StateDrift,
615            format!(
616                "the manual stage failed in {} under {runner}, so nothing reached {}",
617                run.seat, run.trunk
618            ),
619        ));
620    }
621    steps.push(format!("the manual stage passed under {runner}"));
622
623    let now = rev_parse(run.seat, &args.branch)?;
624    if now != branch_tip {
625        return Err(refuse(
626            Reason::StateDrift,
627            format!(
628                "{} moved from {} to {} while the gate ran, so the gate judged a tree that is no longer the branch's",
629                args.branch,
630                integrate::short(&branch_tip),
631                integrate::short(&now)
632            ),
633        ));
634    }
635
636    // The range to publish holds no merge commit, whatever the rebase
637    // above did with the clone's preferences.
638    refuse_merges_in_range(run, &trunk_before, &branch_tip)?;
639
640    // The evidence names the branch tip as the integrated commit, staged
641    // before the publication like the squash form's, and inert in the
642    // same way where the publication then refuses: no prune reads an
643    // entry whose commit the trunk does not reach.
644    ledger.record(Entry {
645        branch: args.branch.clone(),
646        branch_tip: branch_tip.clone(),
647        trunk_commit: branch_tip.clone(),
648        at: crate::landing::manifest::now(),
649    });
650    write_ledger(&ledger_path, &ledger)?;
651
652    let drift = publish(run, &branch_tip, &trunk_before)?;
653    steps.push(format!(
654        "{} now carries {} at {}, with no new commit",
655        run.trunk,
656        args.branch,
657        integrate::short(&branch_tip)
658    ));
659    steps.push("recorded the integration".to_owned());
660    if let Some(drift) = &drift {
661        steps.push(drift.step(run.trunk));
662    }
663
664    report(
665        args,
666        out,
667        run,
668        &trunk_before,
669        Some(branch_tip.clone()),
670        steps,
671        &[
672            format!(
673                "git -C {} push origin {} pushes the trunk; it is a fast-forward and never a force",
674                run.target, run.trunk
675            ),
676            format!(
677                "rk worktree prune --target {} retires the seat, then its branch",
678                run.target
679            ),
680        ],
681    )?;
682    drift.map_or(
683        Ok(()),
684        |drift| Err(drift.into_error(run.trunk, &branch_tip)),
685    )
686}
687
688/// The dry run under `rebase`, which refreshes nothing and writes nothing.
689///
690/// It judges the messages the branch carries now: a rebase keeps every
691/// message, so a refusal the apply would reach is named here first.
692fn rebase_preview(args: &IntegrateArgs, out: Output, run: &LocalRun<'_>) -> Result<(), RkError> {
693    let trunk_before = rev_parse(run.seat, run.trunk)?;
694    let branch_tip = rev_parse(run.seat, &args.branch)?;
695    let commits = branch_commits(run.seat, &trunk_before, &branch_tip, &args.branch)?;
696    refuse_commit_messages(run.seat, &commits, run.scope_required)?;
697    let steps = vec![
698        "no fetch and no lock: a preview refreshes nothing".to_owned(),
699        format!(
700            "would rebase {} onto {}, or onto whatever the fetch brings",
701            args.branch,
702            integrate::short(&trunk_before)
703        ),
704        "would run the manual stage in the seat".to_owned(),
705        format!(
706            "would move {} to the tip of {}, carrying its {} commit{} with their own messages and writing no new commit",
707            run.trunk,
708            args.branch,
709            commits.len(),
710            if commits.len() == 1 { "" } else { "s" }
711        ),
712    ];
713    report(
714        args,
715        out,
716        run,
717        &trunk_before,
718        None,
719        steps,
720        &[format!(
721            "rk integrate {} --target {} --apply performs it",
722            args.branch, run.target
723        )],
724    )
725}
726
727/// Refuse a trunk message under `rebase`, where no commit takes one.
728///
729/// A message given here would reach nothing: every commit of the branch
730/// keeps its own, and the integration writes no commit to carry it. A
731/// silent drop would let the operator believe it landed.
732fn refuse_message_under_rebase(args: &IntegrateArgs) -> Result<(), RkError> {
733    if args.message.is_none() {
734        return Ok(());
735    }
736    Err(RkError::refusal(
737        Diagnostic::new(
738            Reason::MergeFormMismatch,
739            "-m names a trunk message, and the recorded merge form is rebase: every commit of the branch reaches the trunk with its own message, and no commit is written to carry this one",
740        )
741        .expected("no -m under git.merge_form = \"rebase\"")
742        .action(format!(
743            "rk integrate {} --apply integrates without it; git rebase -i on the branch rewords a commit",
744            args.branch
745        ))
746        .target_state("unchanged; the trunk stands where it stood"),
747    ))
748}
749
750/// One commit the rebase form publishes, with its message.
751struct BranchCommit {
752    id: String,
753    message: String,
754}
755
756/// Every commit between the trunk and the branch tip, oldest first.
757///
758/// A branch with no commit of its own has nothing to integrate, and a
759/// publication that moved nothing would still write evidence naming it.
760fn branch_commits(
761    seat: &Utf8Path,
762    trunk: &str,
763    tip: &str,
764    branch: &str,
765) -> Result<Vec<BranchCommit>, RkError> {
766    let listed = git(seat, &["rev-list", "--reverse", &format!("{trunk}..{tip}")])?;
767    if !listed.status.success() {
768        return Err(refuse(
769            Reason::StateDrift,
770            format!(
771                "the commits of {branch} could not be listed: {}",
772                last_line(&listed.stderr)
773            ),
774        ));
775    }
776    let ids: Vec<String> = String::from_utf8_lossy(&listed.stdout)
777        .lines()
778        .filter(|line| !line.is_empty())
779        .map(str::to_owned)
780        .collect();
781    if ids.is_empty() {
782        return Err(refuse(
783            Reason::StateDrift,
784            format!(
785                "{branch} carries no commit {} does not already carry, so there is nothing to integrate",
786                integrate::short(trunk)
787            ),
788        ));
789    }
790    ids.into_iter()
791        .map(|id| {
792            let shown = git(seat, &["log", "-1", "--format=%B", &id])?;
793            if !shown.status.success() {
794                return Err(refuse(
795                    Reason::StateDrift,
796                    format!(
797                        "the message of {} could not be read: {}",
798                        integrate::short(&id),
799                        last_line(&shown.stderr)
800                    ),
801                ));
802            }
803            Ok(BranchCommit {
804                message: String::from_utf8_lossy(&shown.stdout).into_owned(),
805                id,
806            })
807        })
808        .collect()
809}
810
811/// Refuse the first branch commit whose message the landed `commit-msg`
812/// stage would refuse.
813///
814/// The move that publishes these commits fires no hook, so the whole
815/// stage is applied here through the one owner the squash form uses: the
816/// scoped Conventional Commit shape, then every finding
817/// `rk message --check` reports. `at` is the seat, for the same reason as
818/// the squash form's: the ignore rules a message is judged against are
819/// the branch's.
820fn refuse_commit_messages(
821    at: &Utf8Path,
822    commits: &[BranchCommit],
823    scope_required: bool,
824) -> Result<(), RkError> {
825    for commit in commits {
826        let subject = commit.message.lines().next().unwrap_or("").trim();
827        let named = format!("commit {}", integrate::short(&commit.id));
828        if subject.is_empty() {
829            return Err(refuse(
830                Reason::Usage,
831                format!(
832                    "{named} carries no message, and every commit reaches the trunk with its own"
833                ),
834            ));
835        }
836        if let Some(reason) = misshapen_subject(subject, scope_required) {
837            return Err(refuse(Reason::Usage, format!("{named}: {reason}")));
838        }
839        let (findings, _) = crate::commands::message::judge(
840            &commit.message,
841            crate::cli::message::MessageKind::Commit,
842            at,
843            subject,
844            crate::commands::message::Judgment {
845                exempt: crate::commands::message::exempt_title(subject),
846                scope_required,
847            },
848        );
849        if findings.is_empty() {
850            continue;
851        }
852        let listed: Vec<String> = findings
853            .iter()
854            .map(|finding| format!("{}:{} {}", finding.class, finding.line, finding.detail))
855            .collect();
856        return Err(refuse(
857            Reason::Usage,
858            format!(
859                "{named} '{subject}' carries {} finding{} the landed commit-msg stage would refuse, and the trunk move fires no hook: {}",
860                findings.len(),
861                if findings.len() == 1 { "" } else { "s" },
862                listed.join("; ")
863            ),
864        ));
865    }
866    Ok(())
867}
868
869/// The squash commit, as an object nothing references yet.
870///
871/// `git commit-tree` takes the rebased branch's tree and the trunk tip as
872/// its one parent, so the object is a squash by construction and no ref
873/// moves. Publishing it is a separate compare-and-swap, which is what
874/// makes every refusal before that point leave nothing behind.
875fn build_squash(
876    run: &LocalRun<'_>,
877    branch_tip: &str,
878    parent: &str,
879    message: &str,
880) -> Result<String, RkError> {
881    let tree = rev_parse(run.seat, &format!("{branch_tip}^{{tree}}"))?;
882    let built = git(
883        run.seat,
884        &["commit-tree", &tree, "-p", parent, "-m", message],
885    )?;
886    if !built.status.success() {
887        return Err(refuse(
888            Reason::Internal,
889            format!(
890                "the squash commit could not be built: {}",
891                last_line(&built.stderr)
892            ),
893        ));
894    }
895    Ok(String::from_utf8_lossy(&built.stdout).trim().to_owned())
896}
897
898/// Bring the local trunk level with its remote, where a remote answers.
899///
900/// Only a divergence refuses. A trunk behind its remote fast-forwards,
901/// and a trunk ahead of it is the ordinary state under local
902/// integration: integrations accumulate and the operator pushes when
903/// they decide to.
904fn refresh_trunk(run: &LocalRun<'_>, steps: &mut Vec<String>) -> Result<(), RkError> {
905    // An absent origin and an origin that will not answer are different
906    // facts. A project with no remote integrates against its local trunk
907    // alone; a project whose remote exists and cannot be reached may have
908    // a newer or divergent trunk behind that failure, so proceeding from
909    // stale local state could publish an integration nobody can push.
910    let named = git(run.seat, &["remote", "get-url", "origin"])?;
911    if !named.status.success() {
912        steps.push("no origin remote; the local trunk stands alone".to_owned());
913        return Ok(());
914    }
915    let fetched = git(run.seat, &["fetch", "--quiet", "origin"])?;
916    if !fetched.status.success() {
917        return Err(refuse(
918            Reason::ForgeTemporary,
919            format!(
920                "origin is configured and did not answer, so the trunk could not be refreshed: {}",
921                last_line(&fetched.stderr)
922            ),
923        ));
924    }
925    steps.push("fetched origin".to_owned());
926    let Some(remote) = rev_parse(run.seat, &format!("refs/remotes/origin/{}", run.trunk)).ok()
927    else {
928        steps.push(format!(
929            "origin carries no {} yet; the local trunk stands alone",
930            run.trunk
931        ));
932        return Ok(());
933    };
934    let local = rev_parse(run.seat, run.trunk)?;
935    let state = integrate::trunk_state(
936        local == remote,
937        is_ancestor(run.seat, &local, &remote),
938        is_ancestor(run.seat, &remote, &local),
939    );
940    if let Some(reason) = integrate::refuse_trunk_state(state) {
941        return Err(refuse(Reason::RemoteConflict, reason));
942    }
943    match state {
944        integrate::TrunkState::Behind => {
945            fast_forward_trunk(run, &remote, &local)?;
946            steps.push(format!(
947                "fast-forwarded {} to origin/{}",
948                run.trunk, run.trunk
949            ));
950        }
951        integrate::TrunkState::Ahead => {
952            steps.push(format!(
953                "{} carries integrations nobody pushed yet",
954                run.trunk
955            ));
956        }
957        integrate::TrunkState::Level | integrate::TrunkState::Diverged => {}
958    }
959    Ok(())
960}
961
962/// Move the trunk ref forward to its remote tip, through the seat that
963/// holds it where one does, so no working tree is left behind its own HEAD.
964///
965/// This runs before the gate, and nothing is integrated yet, so a refresh
966/// that completed and then could not be confirmed, or was raced, refuses
967/// the integration. Its diagnostic describes the completed refresh: the
968/// trunk moved to its remote tip, and no integration was published.
969fn fast_forward_trunk(run: &LocalRun<'_>, to: &str, from: &str) -> Result<(), RkError> {
970    let PostPublication::Unsettled(unsettled) = trunk_move(run, to, from, "fast-forward")? else {
971        return Ok(());
972    };
973    Err(unsettled.into_refresh_error(run.trunk, from, to))
974}
975
976/// Publish the integrated commit: the squash commit, or the branch tip
977/// under `rebase`.
978///
979/// `Some` is a publication the trunk's read-back did not confirm as the
980/// planned move; the caller reports the publication and then fails with
981/// [`Unsettled::into_error`].
982fn publish(run: &LocalRun<'_>, commit: &str, expected: &str) -> Result<Option<Unsettled>, RkError> {
983    Ok(match trunk_move(run, commit, expected, "integration")? {
984        PostPublication::Confirmed => None,
985        PostPublication::Unsettled(unsettled) => Some(unsettled),
986    })
987}
988
989/// What a trunk move came to once it was made.
990///
991/// Every refusal of a move is an `Err` of [`trunk_move`] and happens before
992/// anything is written. Once the held trunk's fast-forward succeeded, the
993/// trunk is published, and what follows is this type and never an error:
994/// [`confirm_move`] returns it and cannot fail, so no read-back problem can
995/// travel the refusal path and claim the trunk stands where it stood.
996#[derive(Debug, Clone, PartialEq, Eq)]
997enum PostPublication {
998    /// The move was the planned one: a compare-and-swap of an unheld
999    /// trunk, or a held fast-forward whose start and end were confirmed.
1000    Confirmed,
1001    /// The trunk moved, and the read-back did not confirm the plan.
1002    Unsettled(Unsettled),
1003}
1004
1005/// A completed move of the held trunk that is not confirmed as planned.
1006#[derive(Debug, Clone, PartialEq, Eq)]
1007enum Unsettled {
1008    /// A confirmation source shows the move started elsewhere: a writer
1009    /// outside rk moved the trunk during the publication.
1010    Raced(Drift),
1011    /// No source confirms where the move started, for the reason given.
1012    Unconfirmed {
1013        /// The commit the observation found before the gate.
1014        expected: String,
1015        /// Why no source confirmed the move.
1016        reason: String,
1017    },
1018}
1019
1020impl Unsettled {
1021    /// The report line naming the uncertainty beside the publication.
1022    fn step(&self, trunk: &str) -> String {
1023        match self {
1024            Self::Raced(drift) => drift.step(trunk),
1025            Self::Unconfirmed { expected, reason } => format!(
1026                "{trunk}'s move from the observed {} is not confirmed: {reason}",
1027                integrate::short(expected)
1028            ),
1029        }
1030    }
1031
1032    /// The diagnostic of a publication that is not confirmed as planned:
1033    /// the trunk is published, nothing is rolled back, and the operator
1034    /// reads what moved it.
1035    fn into_error(self, trunk: &str, published: &str) -> RkError {
1036        match self {
1037            Self::Raced(drift) => drift.into_error(trunk, published),
1038            Self::Unconfirmed { expected, reason } => {
1039                unconfirmed_error(trunk, &expected, published, &reason)
1040            }
1041        }
1042    }
1043
1044    /// The diagnostic of a pre-gate refresh that completed and is not
1045    /// confirmed as planned: the trunk moved to its remote tip, and no
1046    /// integration was published, so the integration refuses.
1047    fn into_refresh_error(self, trunk: &str, from: &str, to: &str) -> RkError {
1048        let message = match &self {
1049            Self::Raced(drift) => format!(
1050                "{trunk} was fast-forwarded to its remote tip {}, and its last move went from {} to {} where this run observed {}: a writer outside rk moved it at the same time, so no integration was published",
1051                integrate::short(to),
1052                integrate::short(&drift.found_old),
1053                integrate::short(&drift.found_new),
1054                integrate::short(from)
1055            ),
1056            Self::Unconfirmed { reason, .. } => format!(
1057                "{trunk} was fast-forwarded to its remote tip {}, but rk could not confirm that the move started at the observed {}: {reason}; no integration was published",
1058                integrate::short(to),
1059                integrate::short(from)
1060            ),
1061        };
1062        RkError::refusal(
1063            Diagnostic::new(Reason::StateDrift, message)
1064                .action(format!(
1065                    "read git reflog {trunk} and git log {}..{}, then run the integration again",
1066                    integrate::short(from),
1067                    integrate::short(to)
1068                ))
1069                .target_state(format!(
1070                    "{trunk} was refreshed to its remote tip {}, with no rollback; no integration was published",
1071                    integrate::short(to)
1072                )),
1073        )
1074    }
1075}
1076
1077/// The diagnostic of a publication no source confirmed.
1078fn unconfirmed_error(trunk: &str, expected: &str, published: &str, reason: &str) -> RkError {
1079    let expected = integrate::short(expected);
1080    let published = integrate::short(published);
1081    RkError::check_failed(
1082        Diagnostic::new(
1083            Reason::StateDrift,
1084            format!(
1085                "{trunk} now stands at the published {published}, but rk could not confirm that the move started at the observed {expected}: {reason}"
1086            ),
1087        )
1088        .expected(format!("a confirmed move of {trunk} from {expected} to {published}"))
1089        .action(format!(
1090            "read git reflog {trunk} and git log {expected}..{published}, and decide whether anything else moved {trunk}; rk moves nothing back"
1091        ))
1092        .target_state(format!(
1093            "{trunk} is published at {published}, with no rollback and no reset; the staged evidence names {published} and the trunk reaches it"
1094        )),
1095    )
1096}
1097
1098/// A held trunk's move, read back, that did not start at the observed tip.
1099#[derive(Debug, Clone, PartialEq, Eq)]
1100struct Drift {
1101    /// The commit the observation found before the gate.
1102    expected: String,
1103    /// The commit the move started at.
1104    found_old: String,
1105    /// The commit the move ended at.
1106    found_new: String,
1107}
1108
1109impl Drift {
1110    /// The report line naming the drift beside the publication.
1111    fn step(&self, trunk: &str) -> String {
1112        format!(
1113            "{trunk}'s last move went from {} to {}, where this run observed {}",
1114            integrate::short(&self.found_old),
1115            integrate::short(&self.found_new),
1116            integrate::short(&self.expected)
1117        )
1118    }
1119
1120    /// The diagnostic of a publication a writer outside rk raced: the
1121    /// trunk is published, nothing is rolled back, and the operator reads
1122    /// the commits that writer added.
1123    fn into_error(self, trunk: &str, published: &str) -> RkError {
1124        RkError::check_failed(
1125            Diagnostic::new(
1126                Reason::StateDrift,
1127                format!(
1128                    "{trunk} now stands at the published {}, and its last move went from {} to {}, not from the observed {}: a writer outside rk moved it during the publication",
1129                    integrate::short(published),
1130                    integrate::short(&self.found_old),
1131                    integrate::short(&self.found_new),
1132                    integrate::short(&self.expected)
1133                ),
1134            )
1135            .expected(format!(
1136                "a move of {trunk} from the observed {} to the published {}",
1137                integrate::short(&self.expected),
1138                integrate::short(published)
1139            ))
1140            .action(format!(
1141                "read the commits between the observed and the found starting commit, git log {}..{}, and decide whether they belong on {trunk}; rk moves nothing back",
1142                integrate::short(&self.expected),
1143                integrate::short(&self.found_old)
1144            ))
1145            .target_state(format!(
1146                "{trunk} is published at {}, with no rollback and no reset; the staged evidence names {} and the trunk reaches it",
1147                integrate::short(published),
1148                integrate::short(published)
1149            )),
1150        )
1151    }
1152}
1153
1154/// One trunk move from `expected` to `to`.
1155///
1156/// The worktrees and the trunk are read again immediately before the
1157/// move: a trunk that moved since the observation refuses with the
1158/// `moved` refusal, and a trunk that became checked out, or stopped being
1159/// checked out, since then refuses too, because the form of the move
1160/// depends on who holds it. Nothing was written in either case.
1161///
1162/// Where no worktree holds the trunk, the ref moves by `git update-ref`
1163/// carrying `expected` as its old value, a compare-and-swap. Where a
1164/// worktree holds it, Git offers no such swap that also moves the
1165/// worktree, so the move is that worktree's own fast-forward merge, which
1166/// keeps its index and working tree with HEAD. A writer outside rk can
1167/// still move the trunk between the re-observation and the merge. A merge
1168/// that refuses writes nothing and is an `Err`. A merge that succeeds has
1169/// published, and [`confirm_move`] then reads where the move started.
1170fn trunk_move(
1171    run: &LocalRun<'_>,
1172    to: &str,
1173    expected: &str,
1174    what: &str,
1175) -> Result<PostPublication, RkError> {
1176    let observed = run
1177        .seats
1178        .iter()
1179        .find(|seat| seat.branch.as_deref() == Some(run.trunk))
1180        .map(|seat| seat.path.clone());
1181    let live_seats = seats(run.target)?;
1182    let holder = live_seats
1183        .iter()
1184        .find(|seat| seat.branch.as_deref() == Some(run.trunk));
1185    if let Some(changed) = holder_change(
1186        run.trunk,
1187        observed.as_deref(),
1188        holder.map(|seat| seat.path.as_path()),
1189        what,
1190    ) {
1191        return Err(refuse(Reason::StateDrift, changed));
1192    }
1193    let reference = format!("refs/heads/{}", run.trunk);
1194    let live = rev_parse(run.seat, &reference)?;
1195    if live != expected {
1196        return Err(moved(run.trunk, expected, &live));
1197    }
1198    let Some(holder) = holder else {
1199        let swapped = git(run.seat, &["update-ref", &reference, to, expected])?;
1200        if !swapped.status.success() {
1201            let now =
1202                rev_parse(run.seat, &reference).unwrap_or_else(|_| "an unreadable tip".to_owned());
1203            return Err(moved(run.trunk, expected, &now));
1204        }
1205        return Ok(PostPublication::Confirmed);
1206    };
1207    if is_dirty(&holder.path)? {
1208        return Err(refuse(
1209            Reason::StateDrift,
1210            format!(
1211                "{} has the trunk checked out and carries uncommitted changes, so the {what} would leave it behind its own HEAD",
1212                holder.path
1213            ),
1214        ));
1215    }
1216    // The reflog is written whatever the clone says, and the merge reports
1217    // its own move in English with full object names, so the move can be
1218    // read back from either.
1219    let merged = git_in_c_locale(
1220        &holder.path,
1221        &[
1222            "-c",
1223            "core.logAllRefUpdates=always",
1224            "-c",
1225            "core.abbrev=no",
1226            "merge",
1227            "--ff-only",
1228            to,
1229        ],
1230    )?;
1231    if !merged.status.success() {
1232        return Err(refuse(
1233            Reason::StateDrift,
1234            format!(
1235                "the {what} is not a fast-forward of {}, so nothing was published: {}",
1236                holder.path,
1237                last_line(&merged.stderr)
1238            ),
1239        ));
1240    }
1241    let report = String::from_utf8_lossy(&merged.stdout).into_owned();
1242    Ok(confirm_move(run, &reference, expected, to, &report))
1243}
1244
1245/// Where the held trunk's completed fast-forward started and ended, read
1246/// back, and what that makes of it. It cannot fail: every problem is an
1247/// unconfirmed publication.
1248///
1249/// The sources, in order: the files backend's raw reflog entry, which
1250/// records the transition as written; else the newest two entries of
1251/// `git reflog show`, where two exist; and the merge's own `Updating
1252/// <old>..<new>` line, with full object names. A reflog source and the
1253/// merge's line that disagree confirm nothing. Only exact full-name
1254/// equality confirms: the start equals the observed tip and the end the
1255/// published object. A source that shows another start, with the planned
1256/// end, is the race; anything else is unconfirmed.
1257fn confirm_move(
1258    run: &LocalRun<'_>,
1259    reference: &str,
1260    expected: &str,
1261    to: &str,
1262    merge_report: &str,
1263) -> PostPublication {
1264    let reflog = reflog_transition(run, reference);
1265    let reported = parse_updating(merge_report, expected.len());
1266    judge_move(expected, to, reflog, reported)
1267}
1268
1269/// The judgment over the two read-backs; see [`confirm_move`].
1270fn judge_move(
1271    expected: &str,
1272    to: &str,
1273    reflog: Result<(String, String), String>,
1274    reported: Result<(String, String), String>,
1275) -> PostPublication {
1276    let unconfirmed = |reason: String| {
1277        PostPublication::Unsettled(Unsettled::Unconfirmed {
1278            expected: expected.to_owned(),
1279            reason,
1280        })
1281    };
1282    let transition = match (reflog, reported) {
1283        (Ok(logged), Ok(said)) if logged != said => {
1284            return unconfirmed(format!(
1285                "the reflog shows {}..{} and the fast-forward reported {}..{}",
1286                integrate::short(&logged.0),
1287                integrate::short(&logged.1),
1288                integrate::short(&said.0),
1289                integrate::short(&said.1)
1290            ));
1291        }
1292        (Ok(logged), _) => logged,
1293        (Err(_), Ok(said)) => said,
1294        (Err(logged), Err(said)) => {
1295            return unconfirmed(format!("{logged}, and {said}"));
1296        }
1297    };
1298    let (old, new) = transition;
1299    if new != to {
1300        return unconfirmed(format!(
1301            "the move read back ends at {}, not at the published {}",
1302            integrate::short(&new),
1303            integrate::short(to)
1304        ));
1305    }
1306    if old == expected {
1307        return PostPublication::Confirmed;
1308    }
1309    PostPublication::Unsettled(Unsettled::Raced(Drift {
1310        expected: expected.to_owned(),
1311        found_old: old,
1312        found_new: new,
1313    }))
1314}
1315
1316/// Whether `name` is a full object name of `len` hexadecimal characters.
1317fn full_name(name: &str, len: usize) -> bool {
1318    name.len() == len && name.bytes().all(|byte| byte.is_ascii_hexdigit())
1319}
1320
1321/// The old and new value of the trunk's newest reflog entry, or why they
1322/// cannot be read.
1323///
1324/// The files backend keeps the raw transition in the ref's log, read as
1325/// written. A backend with no such file answers through the reflog
1326/// command, whose second entry carries the value the newest one started
1327/// from; with one entry, as after an expiry, it answers nothing.
1328fn reflog_transition(run: &LocalRun<'_>, reference: &str) -> Result<(String, String), String> {
1329    let path = run.git_dir.join("logs").join(reference);
1330    match std::fs::read_to_string(path.as_std_path()) {
1331        Ok(text) => {
1332            let line = text.lines().last().unwrap_or_default();
1333            let mut fields = line.split_whitespace();
1334            match (fields.next(), fields.next()) {
1335                (Some(old), Some(new))
1336                    if full_name(old, new.len()) && full_name(new, old.len()) =>
1337                {
1338                    Ok((old.to_owned(), new.to_owned()))
1339                }
1340                _ => Err(format!("the reflog file {path} holds no readable entry")),
1341            }
1342        }
1343        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1344            let listed = git(
1345                run.seat,
1346                &["reflog", "show", "--format=%H", "-n", "2", reference],
1347            )
1348            .map_err(|error| format!("git reflog did not run: {error}"))?;
1349            if !listed.status.success() {
1350                return Err(format!(
1351                    "git reflog show {reference} failed: {}",
1352                    last_line(&listed.stderr)
1353                ));
1354            }
1355            let text = String::from_utf8_lossy(&listed.stdout).into_owned();
1356            let mut lines = text.lines();
1357            match (lines.next(), lines.next()) {
1358                (Some(new), Some(old))
1359                    if full_name(new, old.len()) && full_name(old, new.len()) =>
1360                {
1361                    Ok((old.to_owned(), new.to_owned()))
1362                }
1363                _ => Err(format!(
1364                    "the reflog of {reference} holds fewer than two readable entries"
1365                )),
1366            }
1367        }
1368        Err(error) => Err(format!("the reflog file {path} could not be read: {error}")),
1369    }
1370}
1371
1372/// The `<old>` and `<new>` of the fast-forward's own `Updating
1373/// <old>..<new>` line, each a full object name of `len` characters, or why
1374/// the report confirms nothing: no such line, more than one, a malformed
1375/// one, or an abbreviated name.
1376fn parse_updating(report: &str, len: usize) -> Result<(String, String), String> {
1377    let lines: Vec<&str> = report
1378        .lines()
1379        .filter_map(|line| line.strip_prefix("Updating "))
1380        .collect();
1381    let [line] = lines.as_slice() else {
1382        return Err(if lines.is_empty() {
1383            "the fast-forward reported no Updating line".to_owned()
1384        } else {
1385            "the fast-forward reported more than one Updating line".to_owned()
1386        });
1387    };
1388    let Some((old, new)) = line.trim().split_once("..") else {
1389        return Err(format!(
1390            "the fast-forward's Updating line is malformed: {line}"
1391        ));
1392    };
1393    if !full_name(old, len) || !full_name(new, len) {
1394        return Err(format!(
1395            "the fast-forward's Updating line names no full object names: {line}"
1396        ));
1397    }
1398    Ok((old.to_owned(), new.to_owned()))
1399}
1400
1401/// Why the trunk's holder differs from the one observed, or `None`.
1402fn holder_change(
1403    trunk: &str,
1404    observed: Option<&Utf8Path>,
1405    live: Option<&Utf8Path>,
1406    what: &str,
1407) -> Option<String> {
1408    match (observed, live) {
1409        (None, None) => None,
1410        (Some(before), Some(now)) if before == now => None,
1411        (None, Some(now)) => Some(format!(
1412            "{trunk} was checked out in {now} after this run observed it, so the {what} would leave that worktree behind its own HEAD; nothing was published"
1413        )),
1414        (Some(before), None) => Some(format!(
1415            "{trunk} stopped being checked out in {before} after this run observed it, so the {what} no longer has the worktree it was planned for; nothing was published"
1416        )),
1417        (Some(before), Some(now)) => Some(format!(
1418            "{trunk} moved from being checked out in {before} to {now} after this run observed it; nothing was published"
1419        )),
1420    }
1421}
1422
1423/// The refusal for a trunk that moved between the observation and the write.
1424///
1425/// This one refusal comes after the evidence was staged, so it says what
1426/// is actually on disk rather than repeating the general claim: the trunk
1427/// stands where it stood, and the staged entry names a commit the trunk
1428/// does not reach, which both prune verbs ignore.
1429fn moved(trunk: &str, expected: &str, now: &str) -> RkError {
1430    RkError::refusal(
1431        Diagnostic::new(
1432            Reason::StateDrift,
1433            integrate::refuse_moved_trunk(expected, now).unwrap_or_else(|| {
1434                format!(
1435                    "{trunk} could not be moved and stands at {}",
1436                    integrate::short(now)
1437                )
1438            }),
1439        )
1440        .target_state(format!(
1441            "{trunk} stands where it stood; the staged evidence names a commit it does not reach, which every prune ignores"
1442        )),
1443    )
1444}
1445
1446/// Warn where the message states release intent and the squash changes no
1447/// file the crate ships, under a record whose release bot is release-plz.
1448///
1449/// release-plz attributes a commit to a package only where the commit
1450/// changes a file `cargo package --list` prints, so such a commit reaches
1451/// neither the changelog nor a release. The checks run cheapest first, so a
1452/// message stating no intent or a target another driver releases spawns no
1453/// cargo. Anything that cannot answer leaves the integration silent: a
1454/// warning built on an unproved listing teaches the operator to ignore it.
1455/// The listing runs `--locked --offline`, so it writes nothing into the
1456/// tree the gate judges.
1457///
1458/// SATISFIES git:a-local-integration-warns-of-an-uncounted-release
1459fn warn_if_uncounted(
1460    out: Output,
1461    run: &LocalRun<'_>,
1462    message: &str,
1463    range: &[&str],
1464    previewed: bool,
1465) {
1466    let Some(intent) = integrate::release_intent(message) else {
1467        return;
1468    };
1469    let rust_release = crate::landing::manifest::load(run.target)
1470        .ok()
1471        .flatten()
1472        .is_some_and(|record| {
1473            let release = &record.profile.release;
1474            release.mode == crate::profile::ReleaseMode::Automatic
1475                && release.driver.as_deref() == Some("rust")
1476        });
1477    if !rust_release {
1478        return;
1479    }
1480    let mut diff = vec!["diff", "--name-only", "--no-renames", "-z"];
1481    diff.extend_from_slice(range);
1482    let Ok(changed) = git(run.seat, &diff) else {
1483        return;
1484    };
1485    if !changed.status.success() {
1486        return;
1487    }
1488    let changed = String::from_utf8_lossy(&changed.stdout).into_owned();
1489    let changed: Vec<&str> = changed
1490        .split('\0')
1491        .filter(|path| !path.is_empty())
1492        .collect();
1493    let root_manifest = run.seat.as_std_path().join("Cargo.toml");
1494    let probe = crate::cargo_package::probe(&root_manifest, &["--locked", "--offline"], |args| {
1495        cargo_in(run.seat, args)
1496    });
1497    let Ok(crate::cargo_package::Probe::Listed(listing)) = probe else {
1498        return;
1499    };
1500    if !listing.touched_by(changed.iter().copied()) {
1501        out.warn(integrate::uncounted_release(intent, previewed));
1502    }
1503}
1504
1505/// One cargo call in the seat, for the listing the warning reads.
1506fn cargo_in(seat: &Utf8Path, args: &[&str]) -> Result<crate::cargo_package::Answer, ()> {
1507    let mut command = std::process::Command::new("cargo");
1508    for var in GIT_HOOK_VARS {
1509        command.env_remove(var);
1510    }
1511    command
1512        .current_dir(seat.as_std_path())
1513        .args(args)
1514        .stdin(std::process::Stdio::null())
1515        .output()
1516        .map(|output| crate::cargo_package::Answer {
1517            success: output.status.success(),
1518            stdout: output.stdout,
1519            stderr: output.stderr,
1520        })
1521        .map_err(|_| ())
1522}
1523
1524/// The hook runner the gate calls, with the executable it resolved to:
1525/// prek where it resolves on `PATH`, because the host supplies it, else
1526/// pre-commit, which a project that has not moved still carries in its own
1527/// environment. Both read the same configuration and take the same
1528/// arguments. None when neither resolves.
1529fn hook_runner() -> Option<(&'static str, std::path::PathBuf)> {
1530    ["prek", "pre-commit"]
1531        .into_iter()
1532        .find_map(|name| program_on_path(name).map(|path| (name, path)))
1533}
1534
1535/// The executable file of this name in the first `PATH` directory that
1536/// holds one. A relative entry resolves from this process's current
1537/// directory, the way a shell resolves it, so the path stays right when the
1538/// caller runs it from another directory.
1539fn program_on_path(name: &str) -> Option<std::path::PathBuf> {
1540    let path = std::env::var_os("PATH")?;
1541    let cwd = std::env::current_dir().ok();
1542    std::env::split_paths(&path).find_map(|dir| {
1543        let dir = if dir.is_relative() {
1544            cwd.as_ref()?.join(dir)
1545        } else {
1546            dir
1547        };
1548        let candidate = dir.join(name);
1549        let executable = std::fs::metadata(&candidate).is_ok_and(|meta| {
1550            #[cfg(unix)]
1551            {
1552                use std::os::unix::fs::PermissionsExt as _;
1553                meta.is_file() && meta.permissions().mode() & 0o111 != 0
1554            }
1555            #[cfg(not(unix))]
1556            {
1557                meta.is_file()
1558            }
1559        });
1560        executable.then_some(candidate)
1561    })
1562}
1563
1564/// Run the pre-integrate gate: one stage, one command, under the runner
1565/// that `hook_runner` chooses, which it returns beside the output.
1566fn gate(seat: &Utf8Path) -> Result<(&'static str, std::process::Output), RkError> {
1567    let unrunnable = |message: String| {
1568        RkError::subprocess(
1569            Diagnostic::new(Reason::SubprocessSpawn, message)
1570                .expected(
1571                    "prek or pre-commit on PATH, the hook runner that installs and runs this project's hooks",
1572                )
1573                .action("install prek on the host, or enter the project's environment that carries pre-commit, and run it again")
1574                .target_state("unchanged"),
1575        )
1576    };
1577    let (runner, executable) = hook_runner()
1578        .ok_or_else(|| unrunnable(format!("no hook runner is on PATH to run in {seat}")))?;
1579    let mut command = std::process::Command::new(&executable);
1580    for var in GIT_HOOK_VARS {
1581        command.env_remove(var);
1582    }
1583    command
1584        .current_dir(seat.as_std_path())
1585        .args(["run", "--hook-stage", "manual", "--all-files"])
1586        .output()
1587        .map(|output| (runner, output))
1588        .map_err(|source| unrunnable(format!("{runner} did not run in {seat}: {source}")))
1589}
1590
1591/// Emit one report.
1592fn report(
1593    args: &IntegrateArgs,
1594    out: Output,
1595    run: &LocalRun<'_>,
1596    trunk_before: &str,
1597    trunk_commit: Option<String>,
1598    steps: Vec<String>,
1599    next: &[String],
1600) -> Result<(), RkError> {
1601    for step in &steps {
1602        out.result_line(step);
1603    }
1604    out.emit(&Report {
1605        schema: "rk.integrate/1",
1606        mode: if args.apply { "apply" } else { "preview" },
1607        target: run.target.to_string(),
1608        integration: Integration::Local.as_str(),
1609        authority_source: run.authority_source,
1610        merge_form: run.merge_form.as_str(),
1611        branch: args.branch.clone(),
1612        trunk: run.trunk.to_owned(),
1613        seat: run.seat.to_string(),
1614        trunk_before: trunk_before.to_owned(),
1615        trunk_commit,
1616        steps,
1617        next: next.to_vec(),
1618    })?;
1619    out.next(next);
1620    Ok(())
1621}
1622
1623/// The shape a trunk commit message states, named once.
1624const SHAPE: &str = "a scoped Conventional Commit: <type>(<scope>): <description>";
1625
1626/// The shape a trunk commit message states where the record names no
1627/// release, and a scope is preferred rather than required.
1628const SHAPE_SCOPE_PREFERRED: &str =
1629    "a Conventional Commit: <type>(<scope>): <description>, or <type>: <description>";
1630
1631/// The Conventional Commit types the branch grammar admits, which is the
1632/// same set the landed `rk-branch-name` hook tests.
1633const TYPES: [&str; 11] = [
1634    "build", "chore", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test",
1635];
1636
1637/// Refuse a trunk commit message the landed guards would refuse.
1638///
1639/// `at` is the seat, never the trunk checkout: whether a path is ignored
1640/// is answered by the ignore rules standing where the implementation was
1641/// written, and a branch that adds one is exactly the case the landed
1642/// `commit-msg` stage would have judged there.
1643///
1644/// `git commit-tree` fires no hook, so the whole `commit-msg` stage is
1645/// applied here instead, and it is applied through the one owner rather
1646/// than a second, weaker copy: the Conventional Commit shape the
1647/// `conventional-pre-commit` hook holds, and then every finding
1648/// `rk message --check` reports — agent attribution, a reference to a
1649/// path the target ignores, and a scope outside the title check's shape.
1650/// A message that reaches the trunk here reaches a permanent history and
1651/// a forge-facing changelog, so the two paths judge one set.
1652///
1653/// # Errors
1654///
1655/// Returns [`RkError::Refusal`] naming what a landed guard would have
1656/// refused, with the target untouched.
1657fn refuse_message(target: &Utf8Path, text: &str, scope_required: bool) -> Result<(), RkError> {
1658    let subject = text.lines().next().unwrap_or("").trim();
1659    if subject.is_empty() {
1660        return Err(refuse(
1661            Reason::Usage,
1662            "a local integration writes the trunk's commit message, so --message is required",
1663        ));
1664    }
1665    if let Some(reason) = misshapen_subject(subject, scope_required) {
1666        return Err(refuse(Reason::Usage, reason));
1667    }
1668    let (findings, _) = crate::commands::message::judge(
1669        text,
1670        crate::cli::message::MessageKind::Commit,
1671        target,
1672        subject,
1673        crate::commands::message::Judgment {
1674            exempt: crate::commands::message::exempt_title(subject),
1675            scope_required,
1676        },
1677    );
1678    if findings.is_empty() {
1679        return Ok(());
1680    }
1681    let named: Vec<String> = findings
1682        .iter()
1683        .map(|finding| format!("{}:{} {}", finding.class, finding.line, finding.detail))
1684        .collect();
1685    Err(refuse(
1686        Reason::Usage,
1687        format!(
1688            "the trunk message carries {} finding{} the landed commit-msg stage would refuse, and git commit-tree fires no hook: {}",
1689            findings.len(),
1690            if findings.len() == 1 { "" } else { "s" },
1691            named.join("; ")
1692        ),
1693    ))
1694}
1695
1696/// Why a subject is not the Conventional Commit the record asks for, or
1697/// `None`: a scoped one wherever `scope_required`, and one whose scope may
1698/// be absent where the record names no release.
1699#[must_use]
1700fn misshapen_subject(subject: &str, scope_required: bool) -> Option<String> {
1701    let shape = if scope_required {
1702        SHAPE
1703    } else {
1704        SHAPE_SCOPE_PREFERRED
1705    };
1706    let Some((head, description)) = subject.split_once(": ") else {
1707        return Some(format!("'{subject}' is not {shape}"));
1708    };
1709    if description.trim().is_empty() {
1710        return Some(format!(
1711            "'{subject}' is not {shape}: it states no description"
1712        ));
1713    }
1714    // A breaking `!` sits after the scope, so it comes off the head
1715    // before the scope is read out of it.
1716    let head = head.strip_suffix('!').unwrap_or(head);
1717    let (kind, scope) = match head.split_once('(') {
1718        None if scope_required => {
1719            return Some(format!("'{subject}' is not {shape}: it names no scope"));
1720        }
1721        None => (head, None),
1722        Some((kind, scope)) => {
1723            let Some(scope) = scope.strip_suffix(')') else {
1724                return Some(format!("'{subject}' is not {shape}: its scope is unclosed"));
1725            };
1726            (kind, Some(scope))
1727        }
1728    };
1729    if !TYPES.contains(&kind) {
1730        return Some(format!(
1731            "'{kind}' is not a Conventional Commit type; the types are: {}",
1732            TYPES.join(", ")
1733        ));
1734    }
1735    if let Some(scope) = scope
1736        && !crate::projection::scope_is_shaped(scope)
1737    {
1738        return Some(format!(
1739            "the scope '{scope}' is outside {}: lowercase letters, digits, and _ . / -",
1740            crate::projection::SCOPE_SHAPE
1741        ));
1742    }
1743    None
1744}
1745
1746/// Every worktree this clone registers.
1747fn seats(target: &Utf8Path) -> Result<Vec<crate::worktree::Worktree>, RkError> {
1748    let listed = git(target, &["worktree", "list", "--porcelain", "-z"])?;
1749    if !listed.status.success() {
1750        return Err(RkError::missing(
1751            Diagnostic::new(
1752                Reason::TargetNotFound,
1753                format!("target {target} is not a git repository"),
1754            )
1755            .expected("a repository whose worktrees git can list"),
1756        ));
1757    }
1758    crate::worktree::parse_worktrees(&listed.stdout)
1759        .map_err(|detail| refuse(Reason::PrerequisiteUnmet, detail))
1760}
1761
1762/// The worktree seating one branch.
1763fn seat_for(
1764    seats: &[crate::worktree::Worktree],
1765    branch: &str,
1766    target: &Utf8Path,
1767) -> Result<Utf8PathBuf, RkError> {
1768    seats
1769        .iter()
1770        .find(|seat| seat.branch.as_deref() == Some(branch))
1771        .map(|seat| seat.path.clone())
1772        .ok_or_else(|| {
1773            refuse(
1774                Reason::StateDrift,
1775                format!(
1776                    "no worktree of {target} has {branch} checked out; rk worktree add {branch} --apply seats it"
1777                ),
1778            )
1779        })
1780}
1781
1782/// Whether a working tree carries uncommitted changes, untracked included.
1783fn is_dirty(seat: &Utf8Path) -> Result<bool, RkError> {
1784    let status = git(seat, &["status", "--porcelain"])?;
1785    Ok(!status.stdout.is_empty())
1786}
1787
1788/// One revision's full object name.
1789fn rev_parse(seat: &Utf8Path, revision: &str) -> Result<String, RkError> {
1790    let answer = git(seat, &["rev-parse", "--verify", "--quiet", revision])?;
1791    if !answer.status.success() {
1792        return Err(refuse(
1793            Reason::StateDrift,
1794            format!("{revision} does not resolve in {seat}"),
1795        ));
1796    }
1797    Ok(String::from_utf8_lossy(&answer.stdout).trim().to_owned())
1798}
1799
1800/// Whether `ancestor` is reachable from `descendant`.
1801fn is_ancestor(seat: &Utf8Path, ancestor: &str, descendant: &str) -> bool {
1802    git(seat, &["merge-base", "--is-ancestor", ancestor, descendant])
1803        .is_ok_and(|answer| answer.status.success())
1804}
1805
1806/// The clone's common git directory, which every linked seat shares.
1807fn common_git_dir(target: &Utf8Path) -> Result<Utf8PathBuf, RkError> {
1808    let answer = git(
1809        target,
1810        &["rev-parse", "--path-format=absolute", "--git-common-dir"],
1811    )?;
1812    if !answer.status.success() {
1813        return Err(RkError::missing(
1814            Diagnostic::new(
1815                Reason::TargetNotFound,
1816                format!("target {target} is not a git repository"),
1817            )
1818            .expected("a repository whose common git directory git can name"),
1819        ));
1820    }
1821    let path = String::from_utf8_lossy(&answer.stdout).trim().to_owned();
1822    Utf8PathBuf::from_path_buf(std::path::PathBuf::from(path)).map_err(|path| {
1823        refuse(
1824            Reason::PrerequisiteUnmet,
1825            format!("the common git directory {} is not UTF-8", path.display()),
1826        )
1827    })
1828}
1829
1830/// Read the ledger, or an empty one where none exists.
1831fn read_ledger(path: &Utf8Path) -> Result<Ledger, RkError> {
1832    match std::fs::read_to_string(path) {
1833        Ok(text) => {
1834            Ledger::parse(&text).map_err(|detail| refuse(Reason::UnsupportedSchema, detail))
1835        }
1836        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Ledger::default()),
1837        Err(error) => Err(RkError::Io(error)),
1838    }
1839}
1840
1841/// Write the ledger, atomically.
1842fn write_ledger(path: &Utf8Path, ledger: &Ledger) -> Result<(), RkError> {
1843    let text = ledger
1844        .render()
1845        .map_err(|detail| refuse(Reason::Internal, detail))?;
1846    if let Some(parent) = path.parent() {
1847        std::fs::create_dir_all(parent)?;
1848    }
1849    crate::atomic::write(path.as_std_path(), text.as_bytes())?;
1850    Ok(())
1851}
1852
1853/// One refusal that states the target was left alone.
1854fn refuse(reason: Reason, message: impl Into<String>) -> RkError {
1855    RkError::refusal(
1856        Diagnostic::new(reason, message).target_state("unchanged; the trunk stands where it stood"),
1857    )
1858}
1859
1860/// Run one git command against a directory under the C locale, so its
1861/// human report reads in English; a spawn failure refuses.
1862fn git_in_c_locale(at: &Utf8Path, args: &[&str]) -> Result<std::process::Output, RkError> {
1863    let mut command = std::process::Command::new(crate::probes::git_bin());
1864    for var in GIT_HOOK_VARS {
1865        command.env_remove(var);
1866    }
1867    command
1868        .env("LC_ALL", "C")
1869        .arg("-C")
1870        .arg(at.as_std_path())
1871        .args(args)
1872        .output()
1873        .map_err(|source| {
1874            RkError::subprocess(
1875                Diagnostic::new(
1876                    Reason::SubprocessSpawn,
1877                    format!("git did not run in {at}: {source}"),
1878                )
1879                .target_state("unchanged"),
1880            )
1881        })
1882}
1883
1884/// Run one git command against a directory; a spawn failure refuses.
1885fn git(at: &Utf8Path, args: &[&str]) -> Result<std::process::Output, RkError> {
1886    let mut command = std::process::Command::new(crate::probes::git_bin());
1887    for var in GIT_HOOK_VARS {
1888        command.env_remove(var);
1889    }
1890    command
1891        .arg("-C")
1892        .arg(at.as_std_path())
1893        .args(args)
1894        .output()
1895        .map_err(|source| {
1896            RkError::subprocess(
1897                Diagnostic::new(
1898                    Reason::SubprocessSpawn,
1899                    format!("git did not run in {at}: {source}"),
1900                )
1901                .target_state("unchanged"),
1902            )
1903        })
1904}
1905
1906#[cfg(test)]
1907mod tests {
1908    use camino::Utf8Path;
1909
1910    use super::{
1911        BranchCommit, misshapen_subject, refuse_commit_messages, refuse_message, request_commands,
1912    };
1913    use crate::landing::MergeForm;
1914
1915    /// SATISFIES git:the-merge-form-is-a-landing-parameter
1916    #[test]
1917    fn merge_form_rebase_asks_gitlab_for_no_squash() {
1918        let gitlab = "git@gitlab.com:acme/widget.git";
1919        let squash = request_commands(gitlab, "master", "feat/x", MergeForm::Squash, true);
1920        assert_eq!(
1921            squash[0],
1922            "glab mr create --source-branch feat/x --target-branch master --squash-before-merge"
1923        );
1924        assert!(squash[1].contains("the title is the trunk's commit message"));
1925        let rebase = request_commands(gitlab, "master", "feat/x", MergeForm::Rebase, true);
1926        assert_eq!(
1927            rebase[0],
1928            "glab mr create --source-branch feat/x --target-branch master"
1929        );
1930        assert!(
1931            !rebase.iter().any(|line| line.contains("squash")),
1932            "{rebase:?}"
1933        );
1934        assert!(
1935            rebase[1].contains("every commit of the branch reaches the trunk with its own message"),
1936            "{rebase:?}"
1937        );
1938        assert!(!rebase[1].contains("title"), "{rebase:?}");
1939    }
1940
1941    #[test]
1942    fn merge_form_keeps_the_github_request_command() {
1943        let github = "git@github.com:acme/widget.git";
1944        for form in [MergeForm::Squash, MergeForm::Rebase] {
1945            assert_eq!(
1946                request_commands(github, "master", "feat/x", form, true)[0],
1947                "gh pr create --base master --head feat/x"
1948            );
1949        }
1950    }
1951
1952    #[test]
1953    fn a_trunk_subject_is_held_to_the_landed_convention() {
1954        assert_eq!(
1955            misshapen_subject("feat(integrate): land the verb", true),
1956            None
1957        );
1958        assert_eq!(misshapen_subject("feat(a/b)!: break it", true), None);
1959        assert!(
1960            misshapen_subject("land the verb", true)
1961                .expect("an unscoped subject refuses")
1962                .contains("Conventional Commit")
1963        );
1964        assert!(
1965            misshapen_subject("feat: land the verb", true)
1966                .expect("a missing scope refuses")
1967                .contains("names no scope")
1968        );
1969        assert!(
1970            misshapen_subject("feat(integrate):   ", true)
1971                .expect("an empty description refuses")
1972                .contains("no description")
1973        );
1974        assert!(
1975            misshapen_subject("wat(integrate): land it", true)
1976                .expect("an unknown type refuses")
1977                .contains("not a Conventional Commit type")
1978        );
1979        assert!(
1980            misshapen_subject("feat(Integrate): land it", true)
1981                .expect("a misshapen scope refuses")
1982                .contains("outside")
1983        );
1984    }
1985
1986    /// The whole landed commit-msg stage runs here, not the subject
1987    /// alone: `git commit-tree` fires no hook, so a body the landed
1988    /// guard refuses must refuse here or it reaches a permanent history.
1989    #[test]
1990    fn a_trunk_body_is_judged_by_the_one_message_owner() {
1991        let dir = tempfile::tempdir().expect("a scratch dir exists");
1992        let target = Utf8Path::from_path(dir.path()).expect("utf-8");
1993        refuse_message(
1994            target,
1995            "feat(integrate): land the verb\n\nThe context.\n",
1996            true,
1997        )
1998        .expect("a clean message passes");
1999        let error = refuse_message(
2000            target,
2001            "feat(integrate): land the verb\n\nCo-Authored-By: Claude <noreply@anthropic.com>\n",
2002            true,
2003        )
2004        .expect_err("agent attribution refuses")
2005        .to_string();
2006        assert!(error.contains("attribution"), "{error}");
2007        assert!(error.contains("commit-tree fires no hook"), "{error}");
2008        let error = refuse_message(
2009            target,
2010            "feat(integrate): land the verb\n\nSee .draft/plan.md for the rest.\n",
2011            true,
2012        )
2013        .expect_err("an internal path refuses")
2014        .to_string();
2015        assert!(error.contains("internal-path"), "{error}");
2016        let error = refuse_message(target, "", true)
2017            .expect_err("an empty message refuses")
2018            .to_string();
2019        assert!(error.contains("--message is required"), "{error}");
2020    }
2021
2022    /// Under `rebase` every commit of the branch becomes a trunk commit,
2023    /// so each message takes the whole landed commit-msg judgment and the
2024    /// refusal names the commit that failed it.
2025    #[test]
2026    fn rebase_integrate_judges_every_commit_message() {
2027        let dir = tempfile::tempdir().expect("a scratch dir exists");
2028        let target = Utf8Path::from_path(dir.path()).expect("utf-8");
2029        let commit = |id: &str, message: &str| BranchCommit {
2030            id: id.to_owned(),
2031            message: message.to_owned(),
2032        };
2033        refuse_commit_messages(
2034            target,
2035            &[
2036                commit("1111111aaaa", "feat(greeting): add it\n"),
2037                commit("2222222bbbb", "fix(greeting): mend it\n\nThe context.\n"),
2038            ],
2039            true,
2040        )
2041        .expect("clean messages pass");
2042        let error = refuse_commit_messages(
2043            target,
2044            &[
2045                commit("1111111aaaa", "feat(greeting): add it\n"),
2046                commit(
2047                    "2222222bbbb",
2048                    "fix(greeting): mend it\n\nCo-Authored-By: Claude <noreply@anthropic.com>\n",
2049                ),
2050            ],
2051            true,
2052        )
2053        .expect_err("agent attribution refuses")
2054        .to_string();
2055        assert!(error.contains("commit 2222222"), "{error}");
2056        assert!(error.contains("attribution"), "{error}");
2057        let error = refuse_commit_messages(target, &[commit("3333333cccc", "mend it\n")], true)
2058            .expect_err("an unscoped subject refuses")
2059            .to_string();
2060        assert!(error.contains("commit 3333333"), "{error}");
2061        assert!(error.contains("Conventional Commit"), "{error}");
2062        let error = refuse_commit_messages(target, &[commit("4444444dddd", "\n")], true)
2063            .expect_err("an empty message refuses")
2064            .to_string();
2065        assert!(error.contains("carries no message"), "{error}");
2066    }
2067
2068    /// Where the record names no release, a scope is preferred and not
2069    /// required: a header with none passes, while every other part of the
2070    /// shape, the type and a present scope's shape included, still holds.
2071    ///
2072    /// SATISFIES project-profile:a-release-less-landing-carries-no-release-text
2073    #[test]
2074    fn workflow_only_a_trunk_subject_may_name_no_scope() {
2075        assert_eq!(misshapen_subject("fix: drop the stale path", false), None);
2076        assert_eq!(misshapen_subject("feat!: break it", false), None);
2077        assert_eq!(misshapen_subject("feat(a/b): add it", false), None);
2078        assert!(
2079            misshapen_subject("fix: drop the stale path", true)
2080                .expect("a release requires the scope")
2081                .contains("names no scope")
2082        );
2083        assert!(
2084            misshapen_subject("wat: drop it", false)
2085                .expect("an unknown type refuses")
2086                .contains("not a Conventional Commit type")
2087        );
2088        assert!(
2089            misshapen_subject("drop the stale path", false)
2090                .expect("a subject with no type refuses")
2091                .contains("<type>: <description>")
2092        );
2093        assert!(
2094            misshapen_subject("fix(Bad): mend it", false)
2095                .expect("a misshapen scope refuses")
2096                .contains("outside")
2097        );
2098        let dir = tempfile::tempdir().expect("a scratch dir exists");
2099        let target = Utf8Path::from_path(dir.path()).expect("utf-8");
2100        refuse_message(target, "fix: drop the stale path\n", false)
2101            .expect("a release-less trunk message names no scope");
2102        refuse_commit_messages(
2103            target,
2104            &[BranchCommit {
2105                id: "5555555eeee".to_owned(),
2106                message: "fix: drop the stale path\n".to_owned(),
2107            }],
2108            false,
2109        )
2110        .expect("a release-less branch commit names no scope");
2111    }
2112
2113    /// Under `release.mode = "none"` the forge request guidance asks for a
2114    /// Conventional Commit whose scope is preferred, never a scoped one, in
2115    /// either merge form.
2116    ///
2117    /// SATISFIES project-profile:a-release-less-landing-carries-no-release-text
2118    #[test]
2119    fn workflow_only_request_guidance_prefers_the_scope() {
2120        for remote in [
2121            "git@gitlab.com:acme/widget.git",
2122            "git@github.com:acme/widget.git",
2123        ] {
2124            for form in [MergeForm::Squash, MergeForm::Rebase] {
2125                let lines = request_commands(remote, "master", "feat/x", form, false);
2126                assert!(!lines[1].contains("scoped"), "{lines:?}");
2127                assert!(
2128                    lines[1].contains("scope is preferred and not required"),
2129                    "{lines:?}"
2130                );
2131                let releasing = request_commands(remote, "master", "feat/x", form, true);
2132                assert!(
2133                    releasing[1].contains("scoped Conventional Commit"),
2134                    "{releasing:?}"
2135                );
2136                assert_eq!(lines[0], releasing[0], "the command itself does not change");
2137            }
2138        }
2139    }
2140
2141    /// The publication a writer outside rk raced reads as a publication:
2142    /// a check failure with the reason `state-drift`, naming the published
2143    /// object, the observed tip, and the move the reflog shows, telling the
2144    /// operator what to read, and never claiming the trunk stands where it
2145    /// stood.
2146    ///
2147    /// SATISFIES git:a-local-integration-is-a-transaction
2148    #[test]
2149    fn integrate_reports_a_raced_publication_as_published() {
2150        let drift = super::Drift {
2151            expected: "a".repeat(40),
2152            found_old: "b".repeat(40),
2153            found_new: "c".repeat(40),
2154        };
2155        assert!(drift.step("master").contains("bbbbbbb to ccccccc"));
2156        let error = drift.into_error("master", &"c".repeat(40));
2157        assert_eq!(error.exit_code(), 1);
2158        let diagnostic = error.diagnostic();
2159        assert_eq!(diagnostic.reason, crate::diagnostic::Reason::StateDrift);
2160        for named in [
2161            "published ccccccc",
2162            "bbbbbbb",
2163            "observed aaaaaaa",
2164            "outside rk",
2165        ] {
2166            assert!(
2167                diagnostic.message.contains(named),
2168                "{named}: {}",
2169                diagnostic.message
2170            );
2171        }
2172        assert!(
2173            !diagnostic.message.contains("nothing"),
2174            "{}",
2175            diagnostic.message
2176        );
2177        let state = diagnostic.target_state.unwrap_or_default();
2178        assert!(state.contains("published at ccccccc"), "{state}");
2179        assert!(state.contains("no rollback"), "{state}");
2180        assert!(!state.contains("where it stood"), "{state}");
2181        let action = diagnostic.action.unwrap_or_default();
2182        assert!(action.contains("git log aaaaaaa..bbbbbbb"), "{action}");
2183    }
2184
2185    /// The holder observed before the gate must be the holder at the
2186    /// publication: a trunk checked out since, or no longer, refuses.
2187    ///
2188    /// SATISFIES git:a-local-integration-is-a-transaction
2189    #[test]
2190    fn integrate_refuses_a_changed_trunk_holder() {
2191        let main = Utf8Path::new("/repo");
2192        let other = Utf8Path::new("/repo@trunk");
2193        assert_eq!(
2194            super::holder_change("master", None, None, "integration"),
2195            None
2196        );
2197        assert_eq!(
2198            super::holder_change("master", Some(main), Some(main), "integration"),
2199            None
2200        );
2201        for (observed, live) in [
2202            (None, Some(other)),
2203            (Some(main), None),
2204            (Some(main), Some(other)),
2205        ] {
2206            let reason = super::holder_change("master", observed, live, "integration")
2207                .expect("a changed holder refuses");
2208            assert!(reason.contains("checked out"), "{reason}");
2209            assert!(reason.contains("nothing was published"), "{reason}");
2210        }
2211    }
2212
2213    /// A publication no source confirmed reads as a publication: a check
2214    /// failure with the reason `state-drift`, naming the published object
2215    /// and the observed tip, telling the operator what to read, and never
2216    /// claiming the trunk stands where it stood. The pre-gate refresh of a
2217    /// held trunk, completed and unconfirmed or raced, says the trunk was
2218    /// refreshed and that no integration was published.
2219    ///
2220    /// SATISFIES git:a-local-integration-is-a-transaction
2221    #[test]
2222    fn integrate_reports_an_unconfirmed_move_as_published() {
2223        let expected = "a".repeat(40);
2224        let published = "c".repeat(40);
2225        let unsettled = super::Unsettled::Unconfirmed {
2226            expected: expected.clone(),
2227            reason: "the reflog holds one entry".to_owned(),
2228        };
2229        assert!(unsettled.step("master").contains("is not confirmed"));
2230        let error = unsettled.clone().into_error("master", &published);
2231        assert_eq!(error.exit_code(), 1);
2232        let diagnostic = error.diagnostic();
2233        assert_eq!(diagnostic.reason, crate::diagnostic::Reason::StateDrift);
2234        assert_eq!(
2235            diagnostic.message,
2236            "master now stands at the published ccccccc, but rk could not confirm that the move started at the observed aaaaaaa: the reflog holds one entry"
2237        );
2238        assert_eq!(
2239            diagnostic.expected.as_deref(),
2240            Some("a confirmed move of master from aaaaaaa to ccccccc")
2241        );
2242        assert_eq!(
2243            diagnostic.action.as_deref(),
2244            Some(
2245                "read git reflog master and git log aaaaaaa..ccccccc, and decide whether anything else moved master; rk moves nothing back"
2246            )
2247        );
2248        assert_eq!(
2249            diagnostic.target_state.as_deref(),
2250            Some(
2251                "master is published at ccccccc, with no rollback and no reset; the staged evidence names ccccccc and the trunk reaches it"
2252            )
2253        );
2254
2255        let raced = super::Unsettled::Raced(super::Drift {
2256            expected: expected.clone(),
2257            found_old: "b".repeat(40),
2258            found_new: published.clone(),
2259        });
2260        for refresh in [unsettled, raced] {
2261            let diagnostic = refresh
2262                .into_refresh_error("master", &expected, &published)
2263                .diagnostic();
2264            assert!(
2265                diagnostic
2266                    .message
2267                    .contains("was fast-forwarded to its remote tip ccccccc")
2268            );
2269            assert!(diagnostic.message.contains("no integration was published"));
2270            let state = diagnostic.target_state.unwrap_or_default();
2271            assert!(
2272                state.contains("refreshed to its remote tip ccccccc"),
2273                "{state}"
2274            );
2275            for unchanged in ["unchanged", "where it stood", "now stands at the published"] {
2276                assert!(!diagnostic.message.contains(unchanged), "{unchanged}");
2277                assert!(!state.contains(unchanged), "{unchanged}");
2278            }
2279        }
2280    }
2281
2282    /// The fast-forward's own report confirms only through one well-formed
2283    /// `Updating <old>..<new>` line of full object names.
2284    ///
2285    /// SATISFIES git:a-local-integration-is-a-transaction
2286    #[test]
2287    fn integrate_parses_only_a_full_updating_line() {
2288        let old = "a".repeat(40);
2289        let new = "b".repeat(40);
2290        assert_eq!(
2291            super::parse_updating(&format!("Updating {old}..{new}\nFast-forward\n"), 40),
2292            Ok((old.clone(), new.clone()))
2293        );
2294        for report in [
2295            "Fast-forward\n".to_owned(),
2296            format!("Updating {}..{}\n", &old[..7], &new[..7]),
2297            format!("Updating {old}..{}\n", &new[..12]),
2298            format!("Updating {old} {new}\n"),
2299            format!("Updating {old}..{new}\nUpdating {new}..{old}\n"),
2300            format!("Updating {}..{new}\n", "g".repeat(40)),
2301            format!("Updating {old}..{new}\n").replace("Updating", "Mise à jour"),
2302        ] {
2303            assert!(super::parse_updating(&report, 40).is_err(), "{report}");
2304        }
2305    }
2306
2307    /// The read-backs confirm only an exact full-name match; a start
2308    /// elsewhere is the race; an end elsewhere, two sources that disagree,
2309    /// and no source at all are unconfirmed.
2310    ///
2311    /// SATISFIES git:a-local-integration-is-a-transaction
2312    #[test]
2313    fn integrate_judges_the_read_backs_of_a_held_move() {
2314        use super::{PostPublication, Unsettled, judge_move};
2315        let expected = "a".repeat(40);
2316        let other = "b".repeat(40);
2317        let to = "c".repeat(40);
2318        let pair = |old: &str, new: &str| Ok((old.to_owned(), new.to_owned()));
2319        let none = || Err::<(String, String), String>("unavailable".to_owned());
2320        assert_eq!(
2321            judge_move(&expected, &to, pair(&expected, &to), none()),
2322            PostPublication::Confirmed
2323        );
2324        assert_eq!(
2325            judge_move(&expected, &to, none(), pair(&expected, &to)),
2326            PostPublication::Confirmed
2327        );
2328        assert_eq!(
2329            judge_move(&expected, &to, pair(&expected, &to), pair(&expected, &to)),
2330            PostPublication::Confirmed
2331        );
2332        assert!(matches!(
2333            judge_move(&expected, &to, pair(&other, &to), none()),
2334            PostPublication::Unsettled(Unsettled::Raced(_))
2335        ));
2336        for judged in [
2337            judge_move(&expected, &to, pair(&expected, &to), pair(&other, &to)),
2338            judge_move(&expected, &to, none(), pair(&expected, &other)),
2339            judge_move(&expected, &to, none(), none()),
2340        ] {
2341            assert!(
2342                matches!(
2343                    judged,
2344                    PostPublication::Unsettled(Unsettled::Unconfirmed { .. })
2345                ),
2346                "{judged:?}"
2347            );
2348        }
2349    }
2350}