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    /// The current branch's short name, or none on a detached HEAD.
118    ///
119    /// # Errors
120    ///
121    /// `root` is not inside a repository, or git fails or cannot be run.
122    pub fn branch(&self, root: &Path) -> Result<Option<String>, StoreError> {
123        let call = CURRENT_BRANCH;
124        let output = self.run(root, &call)?;
125        match output.status.code() {
126            Some(1) => return Ok(None),
127            Some(128)
128                if String::from_utf8_lossy(&output.stderr).contains("not a git repository") =>
129            {
130                return Err(StoreError::NotARepository(root.to_owned()));
131            }
132            _ => {}
133        }
134        Ok(Some(text(&succeeded(&call, output)?)))
135    }
136
137    fn status(&self, root: &Path) -> Result<Vec<u8>, StoreError> {
138        let call = STATUS;
139        succeeded(&call, self.run(root, &call)?)
140    }
141
142    /// A name's value in the local configuration of the repository that holds `root`, or none.
143    ///
144    /// # Errors
145    ///
146    /// git fails or cannot be run.
147    pub fn config_get(&self, root: &Path, name: &str) -> Result<Option<String>, StoreError> {
148        let call: Vec<&str> = CONFIG_GET.into_iter().chain([name]).collect();
149        let output = self.run(root, &call)?;
150        if output.status.code() == Some(1) {
151            return Ok(None);
152        }
153        Ok(Some(text(&succeeded(&call, output)?)))
154    }
155
156    /// The first line `<command> --version` prints when git runs it as it runs a merge driver:
157    /// through the shell, from the top of the working tree; none when it fails.
158    ///
159    /// # Errors
160    ///
161    /// git cannot be run.
162    pub fn launcher_version(
163        &self,
164        root: &Path,
165        command: &str,
166    ) -> Result<Option<String>, StoreError> {
167        let call = launcher_call(command);
168        let call: Vec<&str> = call.iter().map(String::as_str).collect();
169        let output = self.run(root, &call)?;
170        Ok(output.status.success().then(|| text(&output.stdout)))
171    }
172
173    /// Sets a name in the local configuration of the repository that holds `root`.
174    ///
175    /// # Errors
176    ///
177    /// git fails or cannot be run.
178    pub fn config_set(&self, root: &Path, name: &str, value: &str) -> Result<(), StoreError> {
179        let call: Vec<&str> = CONFIG_SET.into_iter().chain([name, value]).collect();
180        succeeded(&call, self.run(root, &call)?).map(drop)
181    }
182}
183
184impl GitCommand {
185    /// The merge or rebase stopped in the repository, where one is: a rebase first, since a
186    /// rebase replays each commit by merging it.
187    fn stopped(
188        &self,
189        root: &Path,
190        prefix: &str,
191        branch: Option<&String>,
192    ) -> Result<Option<Stopped>, StoreError> {
193        let (operation, branch) = if self.verified(root, &REBASE_HEAD)? {
194            (Operation::Rebase, self.rebased(root)?)
195        } else if self.verified(root, &MERGE_HEAD)? {
196            (Operation::Merge, branch.cloned())
197        } else {
198            return Ok(None);
199        };
200        let call = UNMERGED;
201        let listed = succeeded(&call, self.run(root, &call)?)?;
202        Ok(Some(Stopped {
203            operation,
204            branch,
205            head_lock: self.lock_at(root, "HEAD")?,
206            other_lock: self.lock_at(root, operation.other_head())?,
207            unmerged: unmerged(&listed, prefix),
208        }))
209    }
210
211    /// Whether a `rev-parse -q --verify` call names a commit.
212    fn verified(&self, root: &Path, call: &[&str]) -> Result<bool, StoreError> {
213        let output = self.run(root, call)?;
214        if output.status.code() == Some(1) {
215            return Ok(false);
216        }
217        succeeded(call, output).map(|_| true)
218    }
219
220    /// The branch a stopped rebase rebases, from the file either backend keeps it in; none
221    /// for a rebase of a detached HEAD.
222    fn rebased(&self, root: &Path) -> Result<Option<String>, StoreError> {
223        for name in REBASE_HEAD_NAMES {
224            let call: Vec<&str> = GIT_PATH.into_iter().chain([name]).collect();
225            let path = text(&succeeded(&call, self.run(root, &call)?)?);
226            match fs::read_to_string(root.join(path)) {
227                Ok(held) => {
228                    let branch = held.trim_end().strip_prefix("refs/heads/");
229                    return Ok(branch.map(str::to_owned));
230                }
231                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
232                Err(error) => {
233                    return Err(StoreError::Git {
234                        call: call.join(" "),
235                        message: error.to_string(),
236                    });
237                }
238            }
239        }
240        Ok(None)
241    }
242
243    /// The bytes of the lock beside `spec/` at `revision`, or none where it holds no lock:
244    /// `rev-parse -q --verify` names the lock's object, or exits 1 without one, so no message of
245    /// git's is read.
246    fn lock_at(&self, root: &Path, revision: &str) -> Result<Option<Vec<u8>>, StoreError> {
247        let object = format!("{revision}:./{LOCK}");
248        let call = ["rev-parse", "-q", "--verify", object.as_str()];
249        let output = self.run(root, &call)?;
250        if output.status.code() == Some(1) {
251            return Ok(None);
252        }
253        let object = text(&succeeded(&call, output)?);
254        let call = ["cat-file", "blob", object.as_str()];
255        succeeded(&call, self.run(root, &call)?).map(Some)
256    }
257}
258
259impl Repository for GitCommand {
260    fn state(&self, root: &Path) -> Result<RepoState, StoreError> {
261        let prefix = self.prefix(root)?;
262        let branch = self.branch(root)?;
263        let status = self.status(root)?;
264        let stopped = self.stopped(root, &prefix, branch.as_ref())?;
265        Ok(RepoState {
266            branch,
267            modified: modified(&status, &prefix),
268            stopped,
269        })
270    }
271}
272
273/// The paths of `ls-files -u -z` records, sorted and without duplicates: from the directory
274/// whose path from the top level is `prefix` where they are under it, and otherwise from the
275/// top level after `:/`.
276fn unmerged(listed: &[u8], prefix: &str) -> Vec<String> {
277    let mut paths: Vec<String> = listed
278        .split(|byte| *byte == 0)
279        .filter_map(|record| {
280            let record = String::from_utf8_lossy(record);
281            let (_, path) = record.split_once('\t')?;
282            Some(
283                path.strip_prefix(prefix)
284                    .map_or_else(|| format!(":/{path}"), str::to_owned),
285            )
286        })
287        .collect();
288    paths.sort();
289    paths.dedup();
290    paths
291}
292
293fn succeeded(call: &[&str], output: Output) -> Result<Vec<u8>, StoreError> {
294    if output.status.success() {
295        Ok(output.stdout)
296    } else {
297        Err(StoreError::Git {
298            call: call.join(" "),
299            message: String::from_utf8_lossy(&output.stderr)
300                .trim_end()
301                .to_owned(),
302        })
303    }
304}
305
306/// The first line of a call's output.
307fn text(stdout: &[u8]) -> String {
308    String::from_utf8_lossy(stdout)
309        .lines()
310        .next()
311        .unwrap_or_default()
312        .to_owned()
313}
314
315/// The paths of porcelain v1 `-z` records, from the directory whose path from the top level is
316/// `prefix`, leaving out a path with a component that begins with `.`, as the reader does.
317fn modified(status: &[u8], prefix: &str) -> Vec<String> {
318    let mut fields = status
319        .split(|byte| *byte == 0)
320        .map(|field| String::from_utf8_lossy(field).into_owned());
321    let mut paths = Vec::new();
322    while let Some(record) = fields.next() {
323        let Some((code, path)) = record.split_at_checked(3) else {
324            continue;
325        };
326        paths.push(path.to_owned());
327        if code.contains(['R', 'C'])
328            && let Some(from) = fields.next()
329        {
330            paths.push(from);
331        }
332    }
333    let mut paths: Vec<String> = paths
334        .into_iter()
335        .map(|path| path.strip_prefix(prefix).map(str::to_owned).unwrap_or(path))
336        .filter(|path| !path.split('/').any(hidden))
337        .collect();
338    paths.sort();
339    paths.dedup();
340    paths
341}