Skip to main content

loftspec_store/repository/
base.rs

1//! The tree at a base commit, for `loft status --base <revision>`: the commit where HEAD left
2//! the revision, as `git merge-base` gives it, and the files under `spec/` that the commit
3//! holds, listed by `git ls-tree` and read in one `git cat-file --batch`.
4
5use std::io::Write;
6use std::path::Path;
7use std::process::{Output, Stdio};
8use std::{io, thread};
9
10use loftspec::codec::{Files, SpecPath};
11
12use super::{GitCommand, succeeded, text};
13use crate::StoreError;
14use crate::fs::{SPEC, hidden};
15
16/// The commit where HEAD left a revision, completed by the revision.
17pub const MERGE_BASE: [&str; 2] = ["merge-base", "HEAD"];
18/// The `-z` records of a commit's files, completed by the commit, `--` and `spec`.
19pub const LIST_TREE: [&str; 3] = ["ls-tree", "-r", "-z"];
20/// Each object whose id is a line of standard input, after a header of its id, type and size.
21pub const BATCH: [&str; 2] = ["cat-file", "--batch"];
22
23/// What `git merge-base HEAD <revision>` says of a revision that names a commit.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum MergeBase {
26    /// The commit where HEAD left the revision.
27    Commit(String),
28    /// HEAD and the revision share no history.
29    Unrelated,
30}
31
32impl GitCommand {
33    /// The commit where HEAD left `revision`.
34    ///
35    /// # Errors
36    ///
37    /// `revision` names no commit, or git fails or cannot be run.
38    pub fn merge_base(&self, root: &Path, revision: &str) -> Result<MergeBase, StoreError> {
39        if revision.is_empty() || revision.starts_with('-') {
40            return Err(StoreError::UnknownRevision(revision.to_owned()));
41        }
42        let call: Vec<&str> = MERGE_BASE.into_iter().chain([revision]).collect();
43        let output = self.run(root, &call)?;
44        match output.status.code() {
45            Some(1) => return Ok(MergeBase::Unrelated),
46            Some(128)
47                if String::from_utf8_lossy(&output.stderr).contains("Not a valid object name") =>
48            {
49                return Err(StoreError::UnknownRevision(revision.to_owned()));
50            }
51            _ => {}
52        }
53        Ok(MergeBase::Commit(text(&succeeded(&call, output)?)))
54    }
55
56    /// The files under `spec/` that `commit` holds, by their paths under `spec/`, leaving out a
57    /// path with a component that begins with `.`, as the reader does.
58    ///
59    /// # Errors
60    ///
61    /// git fails or cannot be run, or answers in a shape the port does not read.
62    pub fn spec_files_at(&self, root: &Path, commit: &str) -> Result<Files, StoreError> {
63        let call: Vec<&str> = LIST_TREE.into_iter().chain([commit, "--", SPEC]).collect();
64        let listed = succeeded(&call, self.run(root, &call)?)?;
65        let blobs = blobs(&listed, &call)?;
66        let mut input = String::new();
67        for (object, _) in &blobs {
68            input.push_str(object);
69            input.push('\n');
70        }
71        let call = BATCH;
72        let output = self.run_with_input(root, &call, input.as_bytes())?;
73        let answer = succeeded(&call, output)?;
74        let mut files = Files::new();
75        let mut at = 0;
76        for (object, path) in blobs {
77            let (bytes, next) = object_at(&answer, at, &object).ok_or_else(|| StoreError::Git {
78                call: call.join(" "),
79                message: format!("no object {object} for {path}"),
80            })?;
81            files.insert(SpecPath::new(&path), bytes.to_vec());
82            at = next;
83        }
84        Ok(files)
85    }
86
87    /// Runs one call with `input` on its standard input, written from another thread so that a
88    /// call that answers while it reads cannot stall on a full pipe.
89    fn run_with_input(
90        &self,
91        root: &Path,
92        call: &[&str],
93        input: &[u8],
94    ) -> Result<Output, StoreError> {
95        let failed = |error: io::Error| match error.kind() {
96            io::ErrorKind::NotFound => StoreError::GitUnavailable(error.to_string()),
97            _ => StoreError::Git {
98                call: call.join(" "),
99                message: error.to_string(),
100            },
101        };
102        let mut child = (self.command)()
103            .arg("-C")
104            .arg(root)
105            .args(call)
106            .env("GIT_OPTIONAL_LOCKS", "0")
107            .stdin(Stdio::piped())
108            .stdout(Stdio::piped())
109            .stderr(Stdio::piped())
110            .spawn()
111            .map_err(failed)?;
112        let mut stdin = child.stdin.take().expect("the input is piped");
113        thread::scope(|scope| {
114            scope.spawn(move || stdin.write_all(input));
115            child.wait_with_output()
116        })
117        .map_err(failed)
118    }
119}
120
121/// The object id and the path under `spec/` of each blob of `ls-tree -z` records, whose paths
122/// run from the working directory.
123fn blobs(listed: &[u8], call: &[&str]) -> Result<Vec<(String, String)>, StoreError> {
124    let unread = |record: &str| StoreError::Git {
125        call: call.join(" "),
126        message: format!("a record the port does not read: {record:?}"),
127    };
128    let mut blobs = Vec::new();
129    for record in listed
130        .split(|byte| *byte == 0)
131        .filter(|record| !record.is_empty())
132    {
133        let record = String::from_utf8_lossy(record);
134        let (header, path) = record.split_once('\t').ok_or_else(|| unread(&record))?;
135        let mut fields = header.split(' ');
136        let (Some(_mode), Some(kind), Some(object)) = (fields.next(), fields.next(), fields.next())
137        else {
138            return Err(unread(&record));
139        };
140        let Some(path) = path
141            .strip_prefix(SPEC)
142            .and_then(|rest| rest.strip_prefix('/'))
143        else {
144            return Err(unread(&record));
145        };
146        if kind != "blob" || path.split('/').any(hidden) {
147            continue;
148        }
149        blobs.push((object.to_owned(), path.to_owned()));
150    }
151    Ok(blobs)
152}
153
154/// The bytes of the object of a `cat-file --batch` answer that starts at `at`, when it is
155/// `object`, and where the next one starts.
156fn object_at<'a>(answer: &'a [u8], at: usize, object: &str) -> Option<(&'a [u8], usize)> {
157    let rest = answer.get(at..)?;
158    let end = rest.iter().position(|byte| *byte == b'\n')?;
159    let header = String::from_utf8_lossy(&rest[..end]);
160    let mut fields = header.split(' ');
161    let (Some(id), Some(_kind), Some(size)) = (fields.next(), fields.next(), fields.next()) else {
162        return None;
163    };
164    let size: usize = size.parse().ok()?;
165    let start = end + 1;
166    if id != object || rest.get(start + size) != Some(&b'\n') {
167        return None;
168    }
169    Some((&rest[start..start + size], at + start + size + 1))
170}