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
15pub const SHOW_PREFIX: [&str; 2] = ["rev-parse", "--show-prefix"];
17pub const CURRENT_BRANCH: [&str; 4] = ["symbolic-ref", "--quiet", "--short", "HEAD"];
19pub const STATUS: [&str; 6] = [
21 "status",
22 "--porcelain=v1",
23 "-z",
24 "--untracked-files=all",
25 "--",
26 SPEC,
27];
28
29pub const CONFIG_GET: [&str; 3] = ["config", "--local", "--get"];
31pub const CONFIG_SET: [&str; 2] = ["config", "--local"];
33
34pub const MERGE_HEAD: [&str; 4] = ["rev-parse", "-q", "--verify", "MERGE_HEAD"];
36pub const REBASE_HEAD: [&str; 4] = ["rev-parse", "-q", "--verify", "REBASE_HEAD"];
38pub const GIT_PATH: [&str; 2] = ["rev-parse", "--git-path"];
40pub const REBASE_HEAD_NAMES: [&str; 2] = ["rebase-merge/head-name", "rebase-apply/head-name"];
42pub const UNMERGED: [&str; 6] = ["ls-files", "-u", "-z", "--full-name", "--", ":/"];
45
46#[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
56pub trait Repository {
58 fn state(&self, root: &Path) -> Result<RepoState, StoreError>;
64}
65
66pub struct GitCommand {
68 command: Box<dyn Fn() -> Command>,
69}
70
71impl Default for GitCommand {
72 fn default() -> Self {
74 Self::from_command(|| Command::new("git"))
75 }
76}
77
78impl GitCommand {
79 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 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 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 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 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 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 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 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 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 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
273fn 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
306fn text(stdout: &[u8]) -> String {
308 String::from_utf8_lossy(stdout)
309 .lines()
310 .next()
311 .unwrap_or_default()
312 .to_owned()
313}
314
315fn 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}