Skip to main content

loftspec_store/
repository.rs

1//! The repository port: what the publisher needs to know of the git repository that holds the
2//! tree, a merge or rebase stopped in it included, and the local configuration that registers
3//! the merge driver, through the `git` command (the accepted decision "Git is an external
4//! component reached through the `git` command"). Nothing else in this crate runs git.
5
6use std::path::Path;
7use std::process::{Command, Output};
8use std::{fs, io};
9
10use loftspec::publish::{LOCK, Operation, RepoState, Stopped};
11
12use crate::StoreError;
13use crate::fs::{SPEC, hidden};
14
15/// The working directory's path from the repository's top level.
16pub const SHOW_PREFIX: [&str; 2] = ["rev-parse", "--show-prefix"];
17/// The checked-out branch; exit 1 on a detached HEAD.
18pub const CURRENT_BRANCH: [&str; 4] = ["symbolic-ref", "--quiet", "--short", "HEAD"];
19/// The porcelain v1 records of the uncommitted changes under `spec/`.
20pub const STATUS: [&str; 6] = [
21    "status",
22    "--porcelain=v1",
23    "-z",
24    "--untracked-files=all",
25    "--",
26    SPEC,
27];
28
29/// A name's value in the repository's local configuration; exit 1 when it is unset.
30pub const CONFIG_GET: [&str; 3] = ["config", "--local", "--get"];
31/// Sets a name in the repository's local configuration.
32pub const CONFIG_SET: [&str; 2] = ["config", "--local"];
33
34/// The commit a stopped merge merges; exit 1 when no merge stopped.
35pub const MERGE_HEAD: [&str; 4] = ["rev-parse", "-q", "--verify", "MERGE_HEAD"];
36/// The commit a stopped rebase replays; exit 1 when no rebase stopped.
37pub const REBASE_HEAD: [&str; 4] = ["rev-parse", "-q", "--verify", "REBASE_HEAD"];
38/// A path under the git directory, from the working directory, completed by the path.
39pub const GIT_PATH: [&str; 2] = ["rev-parse", "--git-path"];
40/// Where each backend of a stopped rebase keeps the branch it rebases.
41pub const REBASE_HEAD_NAMES: [&str; 2] = ["rebase-merge/head-name", "rebase-apply/head-name"];
42/// The index entries of every unmerged path in the repository, with their paths from the top
43/// level.
44pub const UNMERGED: [&str; 6] = ["ls-files", "-u", "-z", "--full-name", "--", ":/"];
45
46/// The shell alias with which [`GitCommand::launcher_version`] runs `<command> --version`.
47#[must_use]
48pub fn launcher_call(command: &str) -> [String; 3] {
49    [
50        "-c".to_owned(),
51        format!("alias.loft-launcher=!{command} --version"),
52        "loft-launcher".to_owned(),
53    ]
54}
55
56/// Where the publisher learns the repository's state.
57pub trait Repository {
58    /// The state of the repository that holds the directory `root`, which holds `spec/`.
59    ///
60    /// # Errors
61    ///
62    /// `root` is not inside a repository, the repository cannot be read, or git cannot be run.
63    fn state(&self, root: &Path) -> Result<RepoState, StoreError>;
64}
65
66/// The repository read by running `git`.
67pub struct GitCommand {
68    command: Box<dyn Fn() -> Command>,
69}
70
71impl Default for GitCommand {
72    /// Runs the `git` on the search path.
73    fn default() -> Self {
74        Self::from_command(|| Command::new("git"))
75    }
76}
77
78impl GitCommand {
79    /// Runs the git command that `command` starts, with the arguments of each call added.
80    pub fn from_command(command: impl Fn() -> Command + 'static) -> Self {
81        Self {
82            command: Box::new(command),
83        }
84    }
85
86    fn run(&self, root: &Path, call: &[&str]) -> Result<Output, StoreError> {
87        (self.command)()
88            .arg("-C")
89            .arg(root)
90            .args(call)
91            .env("GIT_OPTIONAL_LOCKS", "0")
92            .output()
93            .map_err(|error| match error.kind() {
94                io::ErrorKind::NotFound => StoreError::GitUnavailable(error.to_string()),
95                _ => StoreError::Git {
96                    call: call.join(" "),
97                    message: error.to_string(),
98                },
99            })
100    }
101
102    /// The working directory's path from the repository's top level.
103    ///
104    /// # Errors
105    ///
106    /// `root` is not inside a repository, or git fails or cannot be run.
107    pub fn prefix(&self, root: &Path) -> Result<String, StoreError> {
108        let call = SHOW_PREFIX;
109        let output = self.run(root, &call)?;
110        let stderr = String::from_utf8_lossy(&output.stderr);
111        if output.status.code() == Some(128) && stderr.contains("not a git repository") {
112            return Err(StoreError::NotARepository(root.to_owned()));
113        }
114        Ok(text(&succeeded(&call, output)?))
115    }
116
117    fn branch(&self, root: &Path) -> Result<Option<String>, StoreError> {
118        let call = CURRENT_BRANCH;
119        let output = self.run(root, &call)?;
120        if output.status.code() == Some(1) {
121            return Ok(None);
122        }
123        Ok(Some(text(&succeeded(&call, output)?)))
124    }
125
126    fn status(&self, root: &Path) -> Result<Vec<u8>, StoreError> {
127        let call = STATUS;
128        succeeded(&call, self.run(root, &call)?)
129    }
130
131    /// A name's value in the local configuration of the repository that holds `root`, or none.
132    ///
133    /// # Errors
134    ///
135    /// git fails or cannot be run.
136    pub fn config_get(&self, root: &Path, name: &str) -> Result<Option<String>, StoreError> {
137        let call: Vec<&str> = CONFIG_GET.into_iter().chain([name]).collect();
138        let output = self.run(root, &call)?;
139        if output.status.code() == Some(1) {
140            return Ok(None);
141        }
142        Ok(Some(text(&succeeded(&call, output)?)))
143    }
144
145    /// The first line `<command> --version` prints when git runs it as it runs a merge driver:
146    /// through the shell, from the top of the working tree; none when it fails.
147    ///
148    /// # Errors
149    ///
150    /// git cannot be run.
151    pub fn launcher_version(
152        &self,
153        root: &Path,
154        command: &str,
155    ) -> Result<Option<String>, StoreError> {
156        let call = launcher_call(command);
157        let call: Vec<&str> = call.iter().map(String::as_str).collect();
158        let output = self.run(root, &call)?;
159        Ok(output.status.success().then(|| text(&output.stdout)))
160    }
161
162    /// Sets a name in the local configuration of the repository that holds `root`.
163    ///
164    /// # Errors
165    ///
166    /// git fails or cannot be run.
167    pub fn config_set(&self, root: &Path, name: &str, value: &str) -> Result<(), StoreError> {
168        let call: Vec<&str> = CONFIG_SET.into_iter().chain([name, value]).collect();
169        succeeded(&call, self.run(root, &call)?).map(drop)
170    }
171}
172
173impl GitCommand {
174    /// The merge or rebase stopped in the repository, where one is: a rebase first, since a
175    /// rebase replays each commit by merging it.
176    fn stopped(
177        &self,
178        root: &Path,
179        prefix: &str,
180        branch: Option<&String>,
181    ) -> Result<Option<Stopped>, StoreError> {
182        let (operation, branch) = if self.verified(root, &REBASE_HEAD)? {
183            (Operation::Rebase, self.rebased(root)?)
184        } else if self.verified(root, &MERGE_HEAD)? {
185            (Operation::Merge, branch.cloned())
186        } else {
187            return Ok(None);
188        };
189        let call = UNMERGED;
190        let listed = succeeded(&call, self.run(root, &call)?)?;
191        Ok(Some(Stopped {
192            operation,
193            branch,
194            head_lock: self.lock_at(root, "HEAD")?,
195            other_lock: self.lock_at(root, operation.other_head())?,
196            unmerged: unmerged(&listed, prefix),
197        }))
198    }
199
200    /// Whether a `rev-parse -q --verify` call names a commit.
201    fn verified(&self, root: &Path, call: &[&str]) -> Result<bool, StoreError> {
202        let output = self.run(root, call)?;
203        if output.status.code() == Some(1) {
204            return Ok(false);
205        }
206        succeeded(call, output).map(|_| true)
207    }
208
209    /// The branch a stopped rebase rebases, from the file either backend keeps it in; none
210    /// for a rebase of a detached HEAD.
211    fn rebased(&self, root: &Path) -> Result<Option<String>, StoreError> {
212        for name in REBASE_HEAD_NAMES {
213            let call: Vec<&str> = GIT_PATH.into_iter().chain([name]).collect();
214            let path = text(&succeeded(&call, self.run(root, &call)?)?);
215            match fs::read_to_string(root.join(path)) {
216                Ok(held) => {
217                    let branch = held.trim_end().strip_prefix("refs/heads/");
218                    return Ok(branch.map(str::to_owned));
219                }
220                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
221                Err(error) => {
222                    return Err(StoreError::Git {
223                        call: call.join(" "),
224                        message: error.to_string(),
225                    });
226                }
227            }
228        }
229        Ok(None)
230    }
231
232    /// The bytes of the lock beside `spec/` at `revision`, or none where it holds no lock:
233    /// `rev-parse -q --verify` names the lock's object, or exits 1 without one, so no message of
234    /// git's is read.
235    fn lock_at(&self, root: &Path, revision: &str) -> Result<Option<Vec<u8>>, StoreError> {
236        let object = format!("{revision}:./{LOCK}");
237        let call = ["rev-parse", "-q", "--verify", object.as_str()];
238        let output = self.run(root, &call)?;
239        if output.status.code() == Some(1) {
240            return Ok(None);
241        }
242        let object = text(&succeeded(&call, output)?);
243        let call = ["cat-file", "blob", object.as_str()];
244        succeeded(&call, self.run(root, &call)?).map(Some)
245    }
246}
247
248impl Repository for GitCommand {
249    fn state(&self, root: &Path) -> Result<RepoState, StoreError> {
250        let prefix = self.prefix(root)?;
251        let branch = self.branch(root)?;
252        let status = self.status(root)?;
253        let stopped = self.stopped(root, &prefix, branch.as_ref())?;
254        Ok(RepoState {
255            branch,
256            modified: modified(&status, &prefix),
257            stopped,
258        })
259    }
260}
261
262/// The paths of `ls-files -u -z` records, sorted and without duplicates: from the directory
263/// whose path from the top level is `prefix` where they are under it, and otherwise from the
264/// top level after `:/`.
265fn unmerged(listed: &[u8], prefix: &str) -> Vec<String> {
266    let mut paths: Vec<String> = listed
267        .split(|byte| *byte == 0)
268        .filter_map(|record| {
269            let record = String::from_utf8_lossy(record);
270            let (_, path) = record.split_once('\t')?;
271            Some(
272                path.strip_prefix(prefix)
273                    .map_or_else(|| format!(":/{path}"), str::to_owned),
274            )
275        })
276        .collect();
277    paths.sort();
278    paths.dedup();
279    paths
280}
281
282fn succeeded(call: &[&str], output: Output) -> Result<Vec<u8>, StoreError> {
283    if output.status.success() {
284        Ok(output.stdout)
285    } else {
286        Err(StoreError::Git {
287            call: call.join(" "),
288            message: String::from_utf8_lossy(&output.stderr)
289                .trim_end()
290                .to_owned(),
291        })
292    }
293}
294
295/// The first line of a call's output.
296fn text(stdout: &[u8]) -> String {
297    String::from_utf8_lossy(stdout)
298        .lines()
299        .next()
300        .unwrap_or_default()
301        .to_owned()
302}
303
304/// The paths of porcelain v1 `-z` records, from the directory whose path from the top level is
305/// `prefix`, leaving out a path with a component that begins with `.`, as the reader does.
306fn modified(status: &[u8], prefix: &str) -> Vec<String> {
307    let mut fields = status
308        .split(|byte| *byte == 0)
309        .map(|field| String::from_utf8_lossy(field).into_owned());
310    let mut paths = Vec::new();
311    while let Some(record) = fields.next() {
312        let Some((code, path)) = record.split_at_checked(3) else {
313            continue;
314        };
315        paths.push(path.to_owned());
316        if code.contains(['R', 'C'])
317            && let Some(from) = fields.next()
318        {
319            paths.push(from);
320        }
321    }
322    let mut paths: Vec<String> = paths
323        .into_iter()
324        .map(|path| path.strip_prefix(prefix).map(str::to_owned).unwrap_or(path))
325        .filter(|path| !path.split('/').any(hidden))
326        .collect();
327    paths.sort();
328    paths.dedup();
329    paths
330}