Skip to main content

release_kit/
integrate.rs

1//! The pure half of `rk integrate`: the transaction's judgments and the
2//! evidence a local integration leaves behind.
3//!
4//! This module spawns nothing. It decides what a branch name, a seat, a
5//! pair of observed tips, and a stored ledger permit, and it renders and
6//! parses the ledger. `crate::commands::integrate` is the half that runs
7//! git and `pre-commit`.
8//!
9//! The evidence is clone-local, under the common git directory, because a
10//! local integration is a fact about one checkout: the branch it names
11//! exists in that clone alone, the prune verbs that read it act in that
12//! clone alone, and a committed file would carry a per-checkout fact into
13//! every other clone. The common git directory rather than a worktree's
14//! own is what makes one ledger serve every linked seat.
15//!
16//! SATISFIES git:a-local-integration-is-a-transaction
17
18use serde::{Deserialize, Serialize};
19
20/// The ledger's path below the common git directory.
21pub const LEDGER_PATH: &str = "rk/integrations.json";
22
23/// The ledger's shape version.
24const LEDGER_SCHEMA: &str = "rk.integrations/1";
25
26/// One local integration, as the prune verbs read it.
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct Entry {
29    /// The branch that was integrated.
30    pub branch: String,
31    /// The branch's tip at the moment it was integrated. A prune reads
32    /// this: a branch that took another commit afterwards no longer
33    /// matches, and its work is not on the trunk.
34    pub branch_tip: String,
35    /// The squash commit this integration wrote onto the trunk.
36    pub trunk_commit: String,
37    /// When the integration completed, UTC.
38    pub at: String,
39}
40
41/// Every local integration this clone recorded, newest last.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct Ledger {
44    /// The shape version this document declares.
45    #[serde(default = "ledger_schema")]
46    pub schema: String,
47    /// One entry per branch; a re-integration replaces its predecessor.
48    #[serde(default)]
49    pub entries: Vec<Entry>,
50}
51
52fn ledger_schema() -> String {
53    LEDGER_SCHEMA.to_owned()
54}
55
56impl Default for Ledger {
57    fn default() -> Self {
58        Self {
59            schema: ledger_schema(),
60            entries: Vec::new(),
61        }
62    }
63}
64
65impl Ledger {
66    /// Parse a stored ledger.
67    ///
68    /// An absent file is an empty ledger, because a clone that never
69    /// integrated locally has recorded nothing. Malformed content refuses
70    /// rather than reading as empty: an empty ledger authorizes no
71    /// deletion, so a silent downgrade would be safe, but it would also
72    /// hide a defect that loses real evidence.
73    ///
74    /// # Errors
75    ///
76    /// The detail of what could not be read: invalid JSON, or a schema
77    /// this binary does not know.
78    pub fn parse(text: &str) -> Result<Self, String> {
79        if text.trim().is_empty() {
80            return Ok(Self::default());
81        }
82        let ledger: Self = serde_json::from_str(text)
83            .map_err(|source| format!("the integration ledger is not readable: {source}"))?;
84        if ledger.schema != LEDGER_SCHEMA {
85            return Err(format!(
86                "the integration ledger declares schema {}, and this binary knows {LEDGER_SCHEMA}",
87                ledger.schema
88            ));
89        }
90        Ok(ledger)
91    }
92
93    /// The stored form, one trailing newline.
94    ///
95    /// # Errors
96    ///
97    /// A serialization defect in this binary.
98    pub fn render(&self) -> Result<String, String> {
99        let mut text = serde_json::to_string_pretty(self)
100            .map_err(|source| format!("the integration ledger does not serialize: {source}"))?;
101        text.push('\n');
102        Ok(text)
103    }
104
105    /// Record one integration, replacing any earlier entry for the same
106    /// branch: a branch name is reused, and the ledger answers for the
107    /// branch standing now.
108    pub fn record(&mut self, entry: Entry) {
109        self.entries.retain(|held| held.branch != entry.branch);
110        self.entries.push(entry);
111    }
112
113    /// The proof this ledger offers for one branch at one observed tip.
114    ///
115    /// The tip must match exactly, re-observed by the caller at the
116    /// moment of action. A branch that advanced after its integration has
117    /// work the trunk does not carry, so it is not a candidate at all.
118    #[must_use]
119    pub fn proof(&self, branch: &str, tip: &str) -> Option<&Entry> {
120        self.entries
121            .iter()
122            .find(|entry| entry.branch == branch && entry.branch_tip == tip)
123    }
124
125    /// Whether this ledger holds any entry for one branch, at whatever
126    /// tip. The prune reports use it to tell a branch that was never
127    /// integrated from one that advanced after it was.
128    #[must_use]
129    pub fn names(&self, branch: &str) -> bool {
130        self.entries.iter().any(|entry| entry.branch == branch)
131    }
132
133    /// Forget one branch's entry, after the branch itself is gone.
134    pub fn forget(&mut self, branch: &str) {
135        self.entries.retain(|entry| entry.branch != branch);
136    }
137}
138
139/// Why a branch name cannot be integrated, or `None` where it can.
140///
141/// The trunk is refused before the grammar, because the trunk is the
142/// destination and naming it is a different mistake from naming something
143/// off the grammar. The grammar is the one owner in
144/// [`crate::projection::BRANCH_GRAMMAR`], through
145/// [`crate::worktree::matches_grammar`], so the desk, the landed hook,
146/// and this verb judge one language.
147#[must_use]
148pub fn refuse_branch_name(branch: &str, trunk: &str) -> Option<String> {
149    if branch == trunk {
150        return Some(format!(
151            "{branch} is the trunk; integration moves a short-lived branch onto it"
152        ));
153    }
154    if !crate::worktree::matches_grammar(branch) {
155        return Some(format!(
156            "{branch} is neither <type>/<slug> nor <issue-id>-<slug>, so no landed hook would admit its commits"
157        ));
158    }
159    None
160}
161
162/// How the local trunk stands against its remote.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum TrunkState {
165    /// No remote answered, or the two name one commit.
166    Level,
167    /// The remote is ahead: the local trunk fast-forwards onto it.
168    Behind,
169    /// The local trunk carries integrations nobody pushed yet, which is
170    /// the ordinary state under local integration.
171    Ahead,
172    /// Neither reaches the other.
173    Diverged,
174}
175
176/// Where the local trunk stands against the remote tip, if any.
177#[must_use]
178pub const fn trunk_state(
179    level: bool,
180    local_reaches_remote: bool,
181    remote_reaches_local: bool,
182) -> TrunkState {
183    if level {
184        TrunkState::Level
185    } else if local_reaches_remote {
186        TrunkState::Behind
187    } else if remote_reaches_local {
188        TrunkState::Ahead
189    } else {
190        TrunkState::Diverged
191    }
192}
193
194/// Why a diverged trunk cannot be integrated onto.
195///
196/// Only divergence refuses. A trunk behind its remote fast-forwards, and
197/// a trunk ahead of it is the ordinary state under local integration:
198/// integrations accumulate and the operator pushes when they decide to.
199/// A divergence is never merged here, because choosing between the fetch
200/// and the push is the operator's and a merge would put a second parent
201/// on a trunk this convention keeps linear.
202#[must_use]
203pub fn refuse_trunk_state(state: TrunkState) -> Option<String> {
204    matches!(state, TrunkState::Diverged).then(|| {
205        "the local trunk and its remote diverged; neither reaches the other, so this command \
206         refuses rather than merging them"
207            .to_owned()
208    })
209}
210
211/// Why the gate's verdict blocks the integration, or `None`.
212#[must_use]
213pub fn refuse_moved_trunk(before: &str, now: &str) -> Option<String> {
214    (before != now).then(|| {
215        format!(
216            "the trunk moved from {} to {} while the gate ran, so the gate judged a trunk that is gone",
217            short(before),
218            short(now)
219        )
220    })
221}
222
223/// The seven-character form a report shows.
224#[must_use]
225pub fn short(oid: &str) -> String {
226    oid.chars().take(7).collect()
227}
228
229/// The release intent a Conventional Commit message states, as the phrase
230/// a warning names it by, or `None` for a type that states none.
231///
232/// A breaking `!` on the subject's type or a `BREAKING CHANGE:` footer
233/// states a breaking change whatever the type; otherwise `feat` and `fix`
234/// are the two types a release bot bumps for.
235#[must_use]
236pub fn release_intent(message: &str) -> Option<&'static str> {
237    let mut lines = message.lines();
238    let subject = lines.next()?.trim();
239    let (head, _) = subject.split_once(':')?;
240    let footer = lines
241        .any(|line| line.starts_with("BREAKING CHANGE:") || line.starts_with("BREAKING-CHANGE:"));
242    if head.ends_with('!') || footer {
243        return Some("a breaking change");
244    }
245    let kind = head.split_once('(').map_or(head, |(kind, _)| kind);
246    match kind {
247        "feat" => Some("a feat type"),
248        "fix" => Some("a fix type"),
249        _ => None,
250    }
251}
252
253/// The warning for a release-intent message over a change the package's
254/// listing does not reach: release-plz will neither list the commit nor
255/// count it toward a release.
256///
257/// SATISFIES git:a-local-integration-warns-of-an-uncounted-release
258#[must_use]
259pub fn uncounted_release(intent: &str, previewed: bool) -> String {
260    let changes = if previewed {
261        "the squash would change"
262    } else {
263        "the squash changes"
264    };
265    format!(
266        "the message states {intent} and release-plz drives this target's release, but {changes} no file cargo package --list prints for the crate, so release-plz will neither list the commit in the changelog nor count it toward a release: change a file the crate ships, or retype the message with a type that states no release intent, such as docs, chore, or ci"
267    )
268}
269
270#[cfg(test)]
271mod tests {
272    use super::{
273        Entry, Ledger, refuse_branch_name, refuse_moved_trunk, refuse_trunk_state, release_intent,
274        uncounted_release,
275    };
276
277    #[test]
278    fn release_intent_reads_the_type_the_bang_and_the_footer() {
279        assert_eq!(release_intent("feat(cli): add it"), Some("a feat type"));
280        assert_eq!(release_intent("fix(cli): mend it"), Some("a fix type"));
281        assert_eq!(
282            release_intent("docs(cli)!: rename it"),
283            Some("a breaking change")
284        );
285        assert_eq!(
286            release_intent("chore(cli): move it\n\nBREAKING CHANGE: the flag is gone"),
287            Some("a breaking change")
288        );
289        assert_eq!(
290            release_intent("chore(cli): move it\n\nBREAKING-CHANGE: the flag is gone"),
291            Some("a breaking change")
292        );
293        assert_eq!(release_intent("docs(cli): explain it"), None);
294        assert_eq!(release_intent("refactor(cli): tidy it"), None);
295        assert_eq!(release_intent("no shape at all"), None);
296    }
297
298    #[test]
299    fn the_uncounted_release_warning_names_both_fixes() {
300        let preview = uncounted_release("a feat type", true);
301        assert!(preview.contains("the squash would change"), "{preview}");
302        let applied = uncounted_release("a fix type", false);
303        assert!(applied.contains("the squash changes no file"), "{applied}");
304        for needle in [
305            "neither list the commit in the changelog nor count it toward a release",
306            "change a file the crate ships",
307            "retype the message",
308        ] {
309            assert!(applied.contains(needle), "{applied}");
310        }
311    }
312
313    fn entry(branch: &str, tip: &str) -> Entry {
314        Entry {
315            branch: branch.to_owned(),
316            branch_tip: tip.to_owned(),
317            trunk_commit: "c".repeat(40),
318            at: "2026-09-15T00:00:00Z".to_owned(),
319        }
320    }
321
322    #[test]
323    fn an_absent_ledger_reads_as_empty_and_proves_nothing() {
324        let ledger = Ledger::parse("").expect("absence is empty");
325        assert!(ledger.entries.is_empty());
326        assert_eq!(ledger.proof("feat/x", &"a".repeat(40)), None);
327        assert!(!ledger.names("feat/x"));
328    }
329
330    #[test]
331    fn a_ledger_round_trips_and_refuses_an_unknown_schema() {
332        let mut ledger = Ledger::default();
333        ledger.record(entry("feat/x", &"a".repeat(40)));
334        let text = ledger.render().expect("it serializes");
335        assert_eq!(Ledger::parse(&text).expect("it reads back"), ledger);
336        let error = Ledger::parse(r#"{"schema":"rk.integrations/99","entries":[]}"#)
337            .expect_err("a newer schema refuses");
338        assert!(error.contains("rk.integrations/1"), "{error}");
339        let error = Ledger::parse("{").expect_err("malformed content refuses");
340        assert!(error.contains("not readable"), "{error}");
341    }
342
343    #[test]
344    fn a_proof_needs_the_tip_the_integration_recorded() {
345        let mut ledger = Ledger::default();
346        let tip = "a".repeat(40);
347        ledger.record(entry("feat/x", &tip));
348        assert!(ledger.proof("feat/x", &tip).is_some());
349        // One more commit after the integration: the work is not on the
350        // trunk, so the branch is not a candidate at all.
351        assert_eq!(ledger.proof("feat/x", &"b".repeat(40)), None);
352        assert!(ledger.names("feat/x"));
353        // Evidence for one branch never confirms another.
354        assert_eq!(ledger.proof("feat/y", &tip), None);
355    }
356
357    #[test]
358    fn a_re_integration_replaces_its_predecessor() {
359        let mut ledger = Ledger::default();
360        ledger.record(entry("feat/x", &"a".repeat(40)));
361        ledger.record(entry("feat/x", &"b".repeat(40)));
362        assert_eq!(ledger.entries.len(), 1);
363        assert!(ledger.proof("feat/x", &"b".repeat(40)).is_some());
364        ledger.forget("feat/x");
365        assert!(ledger.entries.is_empty());
366    }
367
368    #[test]
369    fn the_trunk_and_a_misshapen_branch_each_refuse_by_name() {
370        assert!(
371            refuse_branch_name("master", "master")
372                .expect("the trunk refuses")
373                .contains("trunk")
374        );
375        let error = refuse_branch_name("wip", "master").expect("the grammar refuses");
376        assert!(error.contains("<type>/<slug>"), "{error}");
377        assert_eq!(refuse_branch_name("feat/x", "master"), None);
378        assert_eq!(refuse_branch_name("123-slug", "master"), None);
379    }
380
381    #[test]
382    fn only_a_diverged_trunk_refuses() {
383        use super::{TrunkState, trunk_state};
384        // level, behind, and ahead all proceed; a trunk ahead of its
385        // remote is the ordinary state under local integration.
386        assert_eq!(trunk_state(true, false, false), TrunkState::Level);
387        assert_eq!(trunk_state(false, true, false), TrunkState::Behind);
388        assert_eq!(trunk_state(false, false, true), TrunkState::Ahead);
389        assert_eq!(trunk_state(false, false, false), TrunkState::Diverged);
390        for state in [TrunkState::Level, TrunkState::Behind, TrunkState::Ahead] {
391            assert_eq!(refuse_trunk_state(state), None, "{state:?}");
392        }
393        let error = refuse_trunk_state(TrunkState::Diverged).expect("divergence refuses");
394        assert!(error.contains("refuses rather than merging"), "{error}");
395    }
396
397    #[test]
398    fn a_trunk_that_moved_under_the_gate_refuses() {
399        assert_eq!(refuse_moved_trunk("a", "a"), None);
400        let error =
401            refuse_moved_trunk(&"a".repeat(40), &"b".repeat(40)).expect("a moved trunk refuses");
402        assert!(error.contains("aaaaaaa"), "{error}");
403        assert!(error.contains("bbbbbbb"), "{error}");
404    }
405}