1use 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
19pub const SHOW_PREFIX: [&str; 2] = ["rev-parse", "--show-prefix"];
21pub const CURRENT_BRANCH: [&str; 4] = ["symbolic-ref", "--quiet", "--short", "HEAD"];
23pub const STATUS: [&str; 6] = [
25 "status",
26 "--porcelain=v1",
27 "-z",
28 "--untracked-files=all",
29 "--",
30 SPEC,
31];
32
33pub const CONFIG_GET: [&str; 3] = ["config", "--local", "--get"];
35pub const CONFIG_SET: [&str; 2] = ["config", "--local"];
37
38pub const MERGE_HEAD: [&str; 4] = ["rev-parse", "-q", "--verify", "MERGE_HEAD"];
40pub const REBASE_HEAD: [&str; 4] = ["rev-parse", "-q", "--verify", "REBASE_HEAD"];
42pub const GIT_PATH: [&str; 2] = ["rev-parse", "--git-path"];
44pub const REBASE_HEAD_NAMES: [&str; 2] = ["rebase-merge/head-name", "rebase-apply/head-name"];
46pub const UNMERGED: [&str; 6] = ["ls-files", "-u", "-z", "--full-name", "--", ":/"];
49
50#[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
60pub trait Repository {
62 fn state(&self, root: &Path) -> Result<RepoState, StoreError>;
68}
69
70pub struct GitCommand {
72 command: Box<dyn Fn() -> Command>,
73}
74
75impl Default for GitCommand {
76 fn default() -> Self {
78 Self::from_command(|| Command::new("git"))
79 }
80}
81
82impl GitCommand {
83 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 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 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 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 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 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 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 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 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 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
277fn 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
310fn text(stdout: &[u8]) -> String {
312 String::from_utf8_lossy(stdout)
313 .lines()
314 .next()
315 .unwrap_or_default()
316 .to_owned()
317}
318
319fn 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}