Skip to main content

storage/checkpoint/
base.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 building's base checkpoint, written as one pack.
7
8use std::io::Write;
9
10use kernel::{Payload, TimeMs};
11
12use crate::error::StorageError;
13
14use super::commit::{Checkpoint, checkpoint_ref, committed, git_err, subject_of};
15use super::opening::{HeadMove, IndexOwner, commit_refused};
16use super::provenance::Provenance;
17use super::scan::CommitPlan;
18
19/// How far a base checkpoint has got, reported as each step lands.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum BaseProgress {
22    /// The building's files are in the index and their blobs in memory.
23    Staged { files: usize },
24    /// The one pack holding every object the checkpoint wrote is on disk.
25    Packed { bytes: usize },
26}
27
28/// What one base checkpoint stages and how it signs: three values a caller
29/// never passes apart.
30pub(super) struct BaseOf<'a> {
31    pub(super) scopes: &'a [String],
32    pub(super) t: TimeMs,
33    pub(super) of: &'a Provenance,
34}
35
36/// Where a base checkpoint's commit is filed, decided under `HEAD_MOVES`.
37pub(super) enum BaseTarget {
38    /// The city has no commit: the commit becomes the branch HEAD names,
39    /// created only if that branch is still absent.
40    Head,
41    /// The city has history: the commit is filed like a wave checkpoint.
42    Filed,
43}
44
45impl Checkpoint {
46    /// Stages `scopes`, scans them and commits, with every object held
47    /// in memory until the end and then written as one pack.
48    ///
49    /// Takes the handle by value: the in-memory store is installed on
50    /// this handle's object database and cannot be taken off again, so
51    /// the handle goes with it, and no later checkpoint can write an object
52    /// into a store nobody dumps (`crates/storage/spec/Checkpoint.lean` ยง8-8).
53    ///
54    /// The commit moves HEAD when the city has none, under `HEAD_MOVES` and
55    /// by compare-and-swap (storage D27), and is otherwise filed under
56    /// `refs/sprawling/runs/<run>/<oid>` like a wave checkpoint.
57    ///
58    /// # Errors
59    /// Propagates staging, the staged-secret scan, the commit, a pack the
60    /// object database refuses, a reference it will not write, and a HEAD
61    /// another writer created after this one read it as absent.
62    pub fn base_checkpoint(
63        self,
64        scopes: &[String],
65        t: TimeMs,
66        of: &Provenance,
67        progress: &mut dyn FnMut(BaseProgress),
68    ) -> Result<Payload, StorageError> {
69        if Self::head_commit(&self.repo)?.is_some() {
70            return self.write_held(&BaseOf { scopes, t, of }, BaseTarget::Filed, progress);
71        }
72        let _head_moves = super::opening::moving_head()?;
73        let target = match Self::head_commit(&self.repo)? {
74            None => BaseTarget::Head,
75            Some(_) => BaseTarget::Filed,
76        };
77        self.write_held(&BaseOf { scopes, t, of }, target, progress)
78    }
79
80    /// A second handle on the same repository and the same index file, for
81    /// a caller that keeps its own handle and still writes one base pack.
82    ///
83    /// # Errors
84    /// A repository or a writer's index that does not open.
85    pub(super) fn reopened(&self) -> Result<Checkpoint, StorageError> {
86        let repo = git2::Repository::open(self.repo.path())
87            .map_err(git_err("open the city repository for a base"))?;
88        let index = match &self.index {
89            IndexOwner::City => IndexOwner::City,
90            IndexOwner::Writer(own) => {
91                let mut index = git2::Index::open(own).map_err(git_err("open a writer's index"))?;
92                repo.set_index(&mut index)
93                    .map_err(git_err("give a writer its index"))?;
94                IndexOwner::Writer(own.clone())
95            }
96        };
97        Ok(Checkpoint {
98            repo,
99            last: None,
100            index,
101        })
102    }
103
104    /// The mempack write itself; the caller decided `target` under
105    /// `HEAD_MOVES` when it is [`BaseTarget::Head`].
106    pub(super) fn write_held(
107        mut self,
108        base: &BaseOf<'_>,
109        target: BaseTarget,
110        progress: &mut dyn FnMut(BaseProgress),
111    ) -> Result<Payload, StorageError> {
112        let objects = self.repo.path().join("objects");
113        let objects = objects.to_str().ok_or_else(|| StorageError::Checkpoint {
114            op: "open the object store",
115            detail: format!("{} is not valid UTF-8", objects.display()),
116        })?;
117        // The disk store is an alternate, and libgit2 writes to no
118        // alternate: every object this checkpoint makes lands in memory.
119        let odb = git2::Odb::new().map_err(git_err("open an in-memory object store"))?;
120        let held = odb
121            .add_new_mempack_backend(1)
122            .map_err(git_err("open an in-memory object store"))?;
123        odb.add_disk_alternate(objects)
124            .map_err(git_err("read the object store"))?;
125        self.repo
126            .set_odb(&odb)
127            .map_err(git_err("hold the base checkpoint in memory"))?;
128
129        let BaseOf { scopes, t, of } = *base;
130        let files = self.stage_scopes_held(scopes)?;
131        progress(BaseProgress::Staged { files: files.len() });
132        self.scan_staged()?;
133        let oid = self.commit(&CommitPlan {
134            t,
135            of,
136            subject: &subject_of(scopes),
137            head: HeadMove::Leave,
138        })?;
139
140        let mut pack = git2::Buf::new();
141        held.dump(&self.repo, &mut pack)
142            .map_err(git_err("dump the base checkpoint"))?;
143        let disk =
144            git2::Repository::open(self.repo.path()).map_err(git_err("open the object store"))?;
145        let disk_odb = disk.odb().map_err(git_err("open the object store"))?;
146        let mut writer = disk_odb
147            .packwriter()
148            .map_err(git_err("write the base checkpoint pack"))?;
149        writer
150            .write_all(&pack)
151            .map_err(|err| StorageError::Checkpoint {
152                op: "write the base checkpoint pack",
153                detail: err.to_string(),
154            })?;
155        writer
156            .commit()
157            .map_err(git_err("write the base checkpoint pack"))?;
158        progress(BaseProgress::Packed { bytes: pack.len() });
159
160        // Only now are the objects on disk, so only now may the index and
161        // a reference name them: a failure above leaves both as they were.
162        super::scan::write_index(&mut self.repo.index().map_err(git_err("read index"))?)?;
163        match target {
164            // Created only while absent: the swap from the "no HEAD" this
165            // writer read (`a_held_base_moves_head_only_from_what_it_read`).
166            BaseTarget::Head => {
167                let branch = self
168                    .repo
169                    .find_reference("HEAD")
170                    .map_err(git_err("read HEAD"))?
171                    .symbolic_target()
172                    .map_err(|err| StorageError::Checkpoint {
173                        op: "read HEAD",
174                        detail: err.to_string(),
175                    })?
176                    .map(str::to_owned)
177                    .ok_or_else(|| StorageError::Checkpoint {
178                        op: "move HEAD to the base checkpoint",
179                        detail: "HEAD names no branch".to_owned(),
180                    })?;
181                self.repo
182                    .reference(&branch, oid, false, "sprawling: a base checkpoint")
183                    .map_err(|err| commit_refused(Some("HEAD"), err))?;
184            }
185            BaseTarget::Filed => {
186                self.repo
187                    .reference(
188                        &checkpoint_ref(of, oid),
189                        oid,
190                        true,
191                        "sprawling: a base checkpoint",
192                    )
193                    .map_err(git_err("file a base checkpoint"))?;
194            }
195        }
196        held.reset()
197            .map_err(git_err("release the base checkpoint"))?;
198        committed(oid, of, scopes, files)
199    }
200}
201
202#[cfg(test)]
203#[allow(
204    clippy::unwrap_used,
205    clippy::expect_used,
206    clippy::panic,
207    clippy::indexing_slicing,
208    reason = "test code"
209)]
210mod tests;