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}