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#[cfg(test)]
230mod tests {
231    use super::{Entry, Ledger, refuse_branch_name, refuse_moved_trunk, refuse_trunk_state};
232
233    fn entry(branch: &str, tip: &str) -> Entry {
234        Entry {
235            branch: branch.to_owned(),
236            branch_tip: tip.to_owned(),
237            trunk_commit: "c".repeat(40),
238            at: "2026-09-15T00:00:00Z".to_owned(),
239        }
240    }
241
242    #[test]
243    fn an_absent_ledger_reads_as_empty_and_proves_nothing() {
244        let ledger = Ledger::parse("").expect("absence is empty");
245        assert!(ledger.entries.is_empty());
246        assert_eq!(ledger.proof("feat/x", &"a".repeat(40)), None);
247        assert!(!ledger.names("feat/x"));
248    }
249
250    #[test]
251    fn a_ledger_round_trips_and_refuses_an_unknown_schema() {
252        let mut ledger = Ledger::default();
253        ledger.record(entry("feat/x", &"a".repeat(40)));
254        let text = ledger.render().expect("it serializes");
255        assert_eq!(Ledger::parse(&text).expect("it reads back"), ledger);
256        let error = Ledger::parse(r#"{"schema":"rk.integrations/99","entries":[]}"#)
257            .expect_err("a newer schema refuses");
258        assert!(error.contains("rk.integrations/1"), "{error}");
259        let error = Ledger::parse("{").expect_err("malformed content refuses");
260        assert!(error.contains("not readable"), "{error}");
261    }
262
263    #[test]
264    fn a_proof_needs_the_tip_the_integration_recorded() {
265        let mut ledger = Ledger::default();
266        let tip = "a".repeat(40);
267        ledger.record(entry("feat/x", &tip));
268        assert!(ledger.proof("feat/x", &tip).is_some());
269        // One more commit after the integration: the work is not on the
270        // trunk, so the branch is not a candidate at all.
271        assert_eq!(ledger.proof("feat/x", &"b".repeat(40)), None);
272        assert!(ledger.names("feat/x"));
273        // Evidence for one branch never confirms another.
274        assert_eq!(ledger.proof("feat/y", &tip), None);
275    }
276
277    #[test]
278    fn a_re_integration_replaces_its_predecessor() {
279        let mut ledger = Ledger::default();
280        ledger.record(entry("feat/x", &"a".repeat(40)));
281        ledger.record(entry("feat/x", &"b".repeat(40)));
282        assert_eq!(ledger.entries.len(), 1);
283        assert!(ledger.proof("feat/x", &"b".repeat(40)).is_some());
284        ledger.forget("feat/x");
285        assert!(ledger.entries.is_empty());
286    }
287
288    #[test]
289    fn the_trunk_and_a_misshapen_branch_each_refuse_by_name() {
290        assert!(
291            refuse_branch_name("master", "master")
292                .expect("the trunk refuses")
293                .contains("trunk")
294        );
295        let error = refuse_branch_name("wip", "master").expect("the grammar refuses");
296        assert!(error.contains("<type>/<slug>"), "{error}");
297        assert_eq!(refuse_branch_name("feat/x", "master"), None);
298        assert_eq!(refuse_branch_name("123-slug", "master"), None);
299    }
300
301    #[test]
302    fn only_a_diverged_trunk_refuses() {
303        use super::{TrunkState, trunk_state};
304        // level, behind, and ahead all proceed; a trunk ahead of its
305        // remote is the ordinary state under local integration.
306        assert_eq!(trunk_state(true, false, false), TrunkState::Level);
307        assert_eq!(trunk_state(false, true, false), TrunkState::Behind);
308        assert_eq!(trunk_state(false, false, true), TrunkState::Ahead);
309        assert_eq!(trunk_state(false, false, false), TrunkState::Diverged);
310        for state in [TrunkState::Level, TrunkState::Behind, TrunkState::Ahead] {
311            assert_eq!(refuse_trunk_state(state), None, "{state:?}");
312        }
313        let error = refuse_trunk_state(TrunkState::Diverged).expect("divergence refuses");
314        assert!(error.contains("refuses rather than merging"), "{error}");
315    }
316
317    #[test]
318    fn a_trunk_that_moved_under_the_gate_refuses() {
319        assert_eq!(refuse_moved_trunk("a", "a"), None);
320        let error =
321            refuse_moved_trunk(&"a".repeat(40), &"b".repeat(40)).expect("a moved trunk refuses");
322        assert!(error.contains("aaaaaaa"), "{error}");
323        assert!(error.contains("bbbbbbb"), "{error}");
324    }
325}