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 fn branch(&self, root: &Path) -> Result<Option<String>, StoreError> {
118 let call = CURRENT_BRANCH;
119 let output = self.run(root, &call)?;
120 if output.status.code() == Some(1) {
121 return Ok(None);
122 }
123 Ok(Some(text(&succeeded(&call, output)?)))
124 }
125
126 fn status(&self, root: &Path) -> Result<Vec<u8>, StoreError> {
127 let call = STATUS;
128 succeeded(&call, self.run(root, &call)?)
129 }
130
131 pub fn config_get(&self, root: &Path, name: &str) -> Result<Option<String>, StoreError> {
137 let call: Vec<&str> = CONFIG_GET.into_iter().chain([name]).collect();
138 let output = self.run(root, &call)?;
139 if output.status.code() == Some(1) {
140 return Ok(None);
141 }
142 Ok(Some(text(&succeeded(&call, output)?)))
143 }
144
145 pub fn launcher_version(
152 &self,
153 root: &Path,
154 command: &str,
155 ) -> Result<Option<String>, StoreError> {
156 let call = launcher_call(command);
157 let call: Vec<&str> = call.iter().map(String::as_str).collect();
158 let output = self.run(root, &call)?;
159 Ok(output.status.success().then(|| text(&output.stdout)))
160 }
161
162 pub fn config_set(&self, root: &Path, name: &str, value: &str) -> Result<(), StoreError> {
168 let call: Vec<&str> = CONFIG_SET.into_iter().chain([name, value]).collect();
169 succeeded(&call, self.run(root, &call)?).map(drop)
170 }
171}
172
173impl GitCommand {
174 fn stopped(
177 &self,
178 root: &Path,
179 prefix: &str,
180 branch: Option<&String>,
181 ) -> Result<Option<Stopped>, StoreError> {
182 let (operation, branch) = if self.verified(root, &REBASE_HEAD)? {
183 (Operation::Rebase, self.rebased(root)?)
184 } else if self.verified(root, &MERGE_HEAD)? {
185 (Operation::Merge, branch.cloned())
186 } else {
187 return Ok(None);
188 };
189 let call = UNMERGED;
190 let listed = succeeded(&call, self.run(root, &call)?)?;
191 Ok(Some(Stopped {
192 operation,
193 branch,
194 head_lock: self.lock_at(root, "HEAD")?,
195 other_lock: self.lock_at(root, operation.other_head())?,
196 unmerged: unmerged(&listed, prefix),
197 }))
198 }
199
200 fn verified(&self, root: &Path, call: &[&str]) -> Result<bool, StoreError> {
202 let output = self.run(root, call)?;
203 if output.status.code() == Some(1) {
204 return Ok(false);
205 }
206 succeeded(call, output).map(|_| true)
207 }
208
209 fn rebased(&self, root: &Path) -> Result<Option<String>, StoreError> {
212 for name in REBASE_HEAD_NAMES {
213 let call: Vec<&str> = GIT_PATH.into_iter().chain([name]).collect();
214 let path = text(&succeeded(&call, self.run(root, &call)?)?);
215 match fs::read_to_string(root.join(path)) {
216 Ok(held) => {
217 let branch = held.trim_end().strip_prefix("refs/heads/");
218 return Ok(branch.map(str::to_owned));
219 }
220 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
221 Err(error) => {
222 return Err(StoreError::Git {
223 call: call.join(" "),
224 message: error.to_string(),
225 });
226 }
227 }
228 }
229 Ok(None)
230 }
231
232 fn lock_at(&self, root: &Path, revision: &str) -> Result<Option<Vec<u8>>, StoreError> {
236 let object = format!("{revision}:./{LOCK}");
237 let call = ["rev-parse", "-q", "--verify", object.as_str()];
238 let output = self.run(root, &call)?;
239 if output.status.code() == Some(1) {
240 return Ok(None);
241 }
242 let object = text(&succeeded(&call, output)?);
243 let call = ["cat-file", "blob", object.as_str()];
244 succeeded(&call, self.run(root, &call)?).map(Some)
245 }
246}
247
248impl Repository for GitCommand {
249 fn state(&self, root: &Path) -> Result<RepoState, StoreError> {
250 let prefix = self.prefix(root)?;
251 let branch = self.branch(root)?;
252 let status = self.status(root)?;
253 let stopped = self.stopped(root, &prefix, branch.as_ref())?;
254 Ok(RepoState {
255 branch,
256 modified: modified(&status, &prefix),
257 stopped,
258 })
259 }
260}
261
262fn unmerged(listed: &[u8], prefix: &str) -> Vec<String> {
266 let mut paths: Vec<String> = listed
267 .split(|byte| *byte == 0)
268 .filter_map(|record| {
269 let record = String::from_utf8_lossy(record);
270 let (_, path) = record.split_once('\t')?;
271 Some(
272 path.strip_prefix(prefix)
273 .map_or_else(|| format!(":/{path}"), str::to_owned),
274 )
275 })
276 .collect();
277 paths.sort();
278 paths.dedup();
279 paths
280}
281
282fn succeeded(call: &[&str], output: Output) -> Result<Vec<u8>, StoreError> {
283 if output.status.success() {
284 Ok(output.stdout)
285 } else {
286 Err(StoreError::Git {
287 call: call.join(" "),
288 message: String::from_utf8_lossy(&output.stderr)
289 .trim_end()
290 .to_owned(),
291 })
292 }
293}
294
295fn text(stdout: &[u8]) -> String {
297 String::from_utf8_lossy(stdout)
298 .lines()
299 .next()
300 .unwrap_or_default()
301 .to_owned()
302}
303
304fn modified(status: &[u8], prefix: &str) -> Vec<String> {
307 let mut fields = status
308 .split(|byte| *byte == 0)
309 .map(|field| String::from_utf8_lossy(field).into_owned());
310 let mut paths = Vec::new();
311 while let Some(record) = fields.next() {
312 let Some((code, path)) = record.split_at_checked(3) else {
313 continue;
314 };
315 paths.push(path.to_owned());
316 if code.contains(['R', 'C'])
317 && let Some(from) = fields.next()
318 {
319 paths.push(from);
320 }
321 }
322 let mut paths: Vec<String> = paths
323 .into_iter()
324 .map(|path| path.strip_prefix(prefix).map(str::to_owned).unwrap_or(path))
325 .filter(|path| !path.split('/').any(hidden))
326 .collect();
327 paths.sort();
328 paths.dedup();
329 paths
330}