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