Skip to main content

lattice_vcs/
repository.rs

1use std::path::Path;
2
3use crate::{Result, VcsError};
4
5/// Passed to EVERY git invocation, before the subcommand.
6///
7/// Several commands that only look like reads — `status` above all — take
8/// `.git/index.lock` in order to opportunistically rewrite the index with
9/// refreshed stat data. That write is optional; the lock it takes is not
10/// optional for anybody else. Git does not retry a contended index lock, it
11/// fails:
12///
13/// ```text
14/// fatal: Unable to create '.../.git/index.lock': File exists.
15/// Another git process seems to be running in this repository [...]
16/// ```
17///
18/// Magit runs its reads on `spawn_blocking` and refreshes every live status
19/// buffer after every mutation, so reads and index writes overlap by
20/// construction. On a 4418-file repository one refresh measures ~300ms, and
21/// staging several entries in a row is the commonest magit workflow — so the
22/// window is neither rare nor avoidable by the user, who gets an error
23/// blaming them for a race the editor caused. Reported 2026-09-23; racing
24/// `git status` against `git add` failed 28 of 200 attempts, and 0 of 200
25/// with this flag.
26///
27/// Applied globally rather than only to reads because it is precisely scoped
28/// already: it suppresses *optional* locks only. `git --no-optional-locks add`
29/// still takes the index lock and still stages, because there the lock is
30/// required. So there is no read/write classification to get wrong, and no
31/// call site that can forget it.
32///
33/// The cost, named honestly: a read no longer persists its refreshed stat
34/// cache, so the next one redoes that `lstat` work. That is real, and it is
35/// the right trade — the work happens off the UI thread, and a stage that
36/// fails outright is a correctness bug the user sees immediately.
37///
38/// Requires git ≥ 2.15 (2017).
39const NO_OPTIONAL_LOCKS: &str = "--no-optional-locks";
40
41/// Wraps a [`gix::Repository`], representing an open git repository.
42///
43/// Created via [`Repository::discover`], which walks up from `path`
44/// until it finds a `.git` directory (matching `git`'s behaviour).
45pub struct Repository {
46    inner: gix::Repository,
47}
48
49impl Repository {
50    /// Walk up from `path` to find the nearest git repository.
51    ///
52    /// Returns an error if no `.git` directory is found in any
53    /// ancestor directory.
54    pub fn discover(path: impl AsRef<Path>) -> Result<Self> {
55        let inner = gix::discover(path)?;
56        Ok(Self { inner })
57    }
58
59    /// The absolute path of the repository's working tree root.
60    ///
61    /// Returns `None` for bare repositories.
62    pub fn workdir(&self) -> Option<&Path> {
63        self.inner.workdir()
64    }
65
66    /// The absolute path of the repository's `.git` directory.
67    pub fn gitdir(&self) -> &Path {
68        self.inner.git_dir()
69    }
70
71    /// Access the inner [`gix::Repository`] for operations that need
72    /// direct access to the gix API.
73    pub fn inner(&self) -> &gix::Repository {
74        &self.inner
75    }
76
77    /// Check whether this is a bare repository (has no working tree).
78    pub fn is_bare(&self) -> bool {
79        self.inner.is_bare()
80    }
81
82    /// Run a git command in the working directory and return its stdout
83    /// as bytes. Runs on the calling thread.
84    pub fn run_git<I, S>(&self, args: I) -> Result<Vec<u8>>
85    where
86        I: IntoIterator<Item = S>,
87        S: AsRef<std::ffi::OsStr>,
88    {
89        let workdir = self
90            .workdir()
91            .ok_or_else(|| VcsError::BareRepo("run_git".into()))?;
92        let output = std::process::Command::new("git")
93            .arg(NO_OPTIONAL_LOCKS)
94            .args(args)
95            .current_dir(workdir)
96            .output()
97            .map_err(|e| VcsError::GitCommand {
98                context: "run_git".into(),
99                source: e,
100            })?;
101        if !output.status.success() {
102            let stderr = String::from_utf8_lossy(&output.stderr);
103            return Err(VcsError::GitCommandFailed {
104                stderr: stderr.into_owned(),
105            });
106        }
107        Ok(output.stdout)
108    }
109
110    /// MG.18a: run a git command with `input` piped to its stdin,
111    /// returning stdout as bytes. Runs on the calling thread.
112    ///
113    /// Needed by [`crate::Index::apply_patch`]: `git apply -` reads the
114    /// patch from stdin, and [`Self::run_git`]'s `.output()` gives the
115    /// child a null stdin. Writing the patch to a temp file instead
116    /// would leak it on a crash and race a concurrent magit in the same
117    /// repository.
118    ///
119    /// The write is completed and stdin dropped **before** waiting, so a
120    /// child that consumes its whole input can exit; holding the pipe
121    /// open past the write deadlocks against a child waiting on EOF.
122    pub fn run_git_stdin<I, S>(&self, args: I, input: &[u8]) -> Result<Vec<u8>>
123    where
124        I: IntoIterator<Item = S>,
125        S: AsRef<std::ffi::OsStr>,
126    {
127        use std::io::Write;
128        let workdir = self
129            .workdir()
130            .ok_or_else(|| VcsError::BareRepo("run_git_stdin".into()))?;
131        let mut child = std::process::Command::new("git")
132            .arg(NO_OPTIONAL_LOCKS)
133            .args(args)
134            .current_dir(workdir)
135            .stdin(std::process::Stdio::piped())
136            .stdout(std::process::Stdio::piped())
137            .stderr(std::process::Stdio::piped())
138            .spawn()
139            .map_err(|e| VcsError::GitCommand {
140                context: "run_git_stdin".into(),
141                source: e,
142            })?;
143        {
144            let mut stdin = child.stdin.take().ok_or_else(|| VcsError::GitCommand {
145                context: "run_git_stdin: stdin already taken".into(),
146                source: std::io::Error::other("no stdin"),
147            })?;
148            stdin.write_all(input).map_err(|e| VcsError::GitCommand {
149                context: "run_git_stdin: write".into(),
150                source: e,
151            })?;
152            // `stdin` drops here, closing the pipe.
153        }
154        let output = child.wait_with_output().map_err(|e| VcsError::GitCommand {
155            context: "run_git_stdin: wait".into(),
156            source: e,
157        })?;
158        if !output.status.success() {
159            let stderr = String::from_utf8_lossy(&output.stderr);
160            return Err(VcsError::GitCommandFailed {
161                stderr: stderr.into_owned(),
162            });
163        }
164        Ok(output.stdout)
165    }
166
167    /// Run a git command and return stdout as a UTF-8 string.
168    pub fn run_git_str<I, S>(&self, args: I) -> Result<String>
169    where
170        I: IntoIterator<Item = S>,
171        S: AsRef<std::ffi::OsStr>,
172    {
173        let bytes = self.run_git(args)?;
174        String::from_utf8(bytes).map_err(|e| VcsError::Utf8 {
175            context: "run_git_str".into(),
176            source: e,
177        })
178    }
179
180    /// Run a git command and return stdout lines as trimmed UTF-8 strings.
181    pub fn run_git_lines<I, S>(&self, args: I) -> Result<Vec<String>>
182    where
183        I: IntoIterator<Item = S>,
184        S: AsRef<std::ffi::OsStr>,
185    {
186        let out = self.run_git_str(args)?;
187        Ok(out
188            .lines()
189            .filter(|l| !l.is_empty())
190            .map(|s| s.to_string())
191            .collect())
192    }
193}
194
195impl std::fmt::Debug for Repository {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        f.debug_struct("Repository")
198            .field("workdir", &self.workdir())
199            .field("gitdir", &self.gitdir())
200            .field("is_bare", &self.is_bare())
201            .finish()
202    }
203}