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}