Skip to main content

storage/worktree/
landing.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! A merge that has been decided, and what it writes down when it is
7//! made.
8//!
9//! Specified by `crates/storage/spec/Worktree.lean` ยง8-9.
10
11use std::path::Path;
12
13use kernel::TimeMs;
14
15use crate::checkpoint::Provenance;
16use crate::error::StorageError;
17
18use super::trees::Worktrees;
19
20/// What one merge writes down: four values that arrive together and are
21/// meaningless apart - a subject with nobody's provenance under it says
22/// who merged nothing. `reviewed_by_person` adds one `Reviewed-by: Name
23/// <email>` trailer, and only when this repository's git config also
24/// carries `user.name` and `user.email`: the city does not invent a
25/// person's name.
26pub struct Landing<'a> {
27    /// Determinism rule 2: the signature carries the injected instant.
28    pub t: TimeMs,
29    pub of: &'a Provenance,
30    pub subject: &'a str,
31    pub reviewed_by_person: bool,
32}
33
34impl Landing<'_> {
35    /// The `Reviewed-by:` line, or nothing at all. Both halves have to
36    /// hold: the caller says a person looked, and this machine's git
37    /// config says who that person is. A name the city made up would be
38    /// a false attribution in somebody's own repository.
39    pub(super) fn reviewer(&self, city_root: &Path) -> String {
40        if !self.reviewed_by_person {
41            return String::new();
42        }
43        // The city's own handles read no User file; who looked is the
44        // machine's answer, so this one read takes the whole chain.
45        let Ok(config) = git2::Repository::open(city_root).and_then(|repo| repo.config()) else {
46            return String::new();
47        };
48        match (
49            config.get_string("user.name"),
50            config.get_string("user.email"),
51        ) {
52            (Ok(name), Ok(email)) => format!(
53                "Reviewed-by: {name} <{email}>
54"
55            ),
56            _ => String::new(),
57        }
58    }
59}
60
61/// A merge that has been decided and not yet made.
62///
63/// The commit the trunk will land on is settled at construction and
64/// every refusal has already happened, so the line announcing this merge
65/// can be written before the trunk moves. [`PlannedMerge::apply`] is the
66/// only way to move it, and [`Worktrees::plan_merge`] is this value's
67/// only source.
68pub struct PlannedMerge<'a> {
69    /// Reachable from `trees` alone: [`Worktrees::plan_merge`] is this
70    /// value's only source, and the refusals it made are why holding
71    /// one means the merge was allowed.
72    pub(super) trees: &'a Worktrees,
73    pub(super) target: git2::Oid,
74}
75
76/// Names the decision, not the repository holding it: a `Worktrees` has
77/// no useful `Debug` and printing one would say nothing about which
78/// merge this is.
79impl std::fmt::Debug for PlannedMerge<'_> {
80    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81        f.debug_struct("PlannedMerge")
82            .field("commit", &self.target)
83            .finish()
84    }
85}
86
87impl PlannedMerge<'_> {
88    /// The commit the city trunk will point at, for the line that says so.
89    pub fn commit(&self) -> String {
90        self.target.to_string()
91    }
92
93    /// Brings a node's committed work into the city's own trunk, as a
94    /// merge commit carrying the merging run's trailers. The judgement
95    /// stays fast-forward only; what the history keeps is the merge
96    /// commit, because a pointer move leaves nothing to read and
97    /// no place to say who verified it.
98    ///
99    /// # Errors
100    /// Propagates a trunk that cannot be read, committed onto or checked
101    /// out. The fast-forward judgement is not repeated: it was made, and
102    /// refused if it had to be, before this value existed.
103    pub fn apply(self, landing: &Landing<'_>) -> Result<(), StorageError> {
104        self.trees.land_merge(self.target, landing)
105    }
106}
107
108/// Whether a checkout only judges the city folder or also writes it.
109#[derive(Clone, Copy)]
110pub(super) enum CheckoutRun {
111    DryRun,
112    Write,
113}
114
115/// Makes the city folder match `tree` without discarding anything:
116/// libgit2's safe strategy judges every path against the trunk as it
117/// stands, and one the person changed and did not commit is a
118/// conflict. Every conflict is named in the refusal, and a refused
119/// checkout has written nothing, because libgit2 finds all conflicts
120/// before it writes a file.
121pub(super) fn check_out(
122    repo: &git2::Repository,
123    tree: &git2::Tree<'_>,
124    run: CheckoutRun,
125) -> Result<(), StorageError> {
126    let mut paths = Vec::new();
127    let mut checkout = git2::build::CheckoutBuilder::new();
128    checkout.safe();
129    match run {
130        CheckoutRun::DryRun => checkout.dry_run(),
131        CheckoutRun::Write => &mut checkout,
132    };
133    checkout.notify_on(git2::CheckoutNotificationType::CONFLICT);
134    checkout.notify(|_, path, _, _, _| {
135        paths.extend(path.map(|p| p.to_string_lossy().into_owned()));
136        true
137    });
138    let outcome = repo.checkout_tree(tree.as_object(), Some(&mut checkout));
139    drop(checkout);
140    if !paths.is_empty() {
141        return Err(StorageError::MergeWouldDiscard { paths });
142    }
143    outcome.map_err(|err| StorageError::Worktree {
144        op: "check out the merged trunk",
145        detail: err.to_string(),
146    })
147}