1use std::path::{Path, PathBuf};
11use std::process::{Command, ExitStatus, Output};
12
13#[derive(Debug, thiserror::Error)]
15pub enum GitError {
16 #[error("Cannot find the git executable file, please confirm it is installed and in the PATH.")]
18 NotFound,
19 #[error("The specified path is not a Git repository")]
21 NotARepo,
22 #[error("git {cmd} execution failed: {stderr}")]
24 Failed {
25 cmd: String,
27 stderr: String,
29 },
30 #[error("IO error: {0}")]
32 Io(#[from] std::io::Error),
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum RepoState {
38 Clean,
40 Merging,
42 Rebasing,
44 CherryPicking,
46 Reverting,
48 Am,
50}
51
52impl RepoState {
53 pub fn op_name(self) -> &'static str {
55 match self {
56 RepoState::Clean => "clean",
57 RepoState::Merging => "merge",
58 RepoState::Rebasing => "rebase",
59 RepoState::CherryPicking => "cherry-pick",
60 RepoState::Reverting => "revert",
61 RepoState::Am => "am",
62 }
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct ConflictedFile {
69 pub path: String,
71 pub has_base: bool,
73 pub has_ours: bool,
75 pub has_theirs: bool,
77}
78
79#[derive(Debug, Clone)]
81pub struct RepoVitals {
82 pub branch: String,
84 pub changes: usize,
86 pub stashes: usize,
88 pub ahead: Option<usize>,
90 pub level: usize,
92}
93
94pub struct Git {
96 top: PathBuf,
98 verbose: bool,
100}
101
102fn spawn_err(e: std::io::Error) -> GitError {
104 if e.kind() == std::io::ErrorKind::NotFound {
105 GitError::NotFound
106 } else {
107 GitError::Io(e)
108 }
109}
110
111const SCRUBBED_GIT_ENV: [&str; 4] = [
117 "GIT_DIR",
118 "GIT_WORK_TREE",
119 "GIT_INDEX_FILE",
120 "GIT_OBJECT_DIRECTORY",
121];
122
123fn base_git(dir: &Path) -> Command {
125 let mut cmd = Command::new("git");
126 cmd.arg("-C").arg(dir).env("GIT_EDITOR", "true");
127 for var in SCRUBBED_GIT_ENV {
128 cmd.env_remove(var);
129 }
130 cmd
131}
132
133impl Git {
134 pub fn discover(dir: &Path, verbose: bool) -> Result<Self, GitError> {
136 let out = base_git(dir)
137 .args(["rev-parse", "--show-toplevel"])
138 .output()
139 .map_err(spawn_err)?;
140 if !out.status.success() {
141 return Err(GitError::NotARepo);
142 }
143 let top = String::from_utf8_lossy(&out.stdout).trim().to_owned();
144 Ok(Self {
145 top: PathBuf::from(top),
146 verbose,
147 })
148 }
149
150 pub fn top(&self) -> &Path {
152 &self.top
153 }
154
155 pub fn run(&self, args: &[&str]) -> Result<Output, GitError> {
157 if self.verbose {
158 eprintln!("[git] git {}", args.join(" "));
159 }
160 base_git(&self.top).args(args).output().map_err(spawn_err)
161 }
162
163 pub fn run_ok(&self, args: &[&str]) -> Result<Output, GitError> {
165 let out = self.run(args)?;
166 if !out.status.success() {
167 return Err(GitError::Failed {
168 cmd: args.join(" "),
169 stderr: String::from_utf8_lossy(&out.stderr).trim().to_owned(),
170 });
171 }
172 Ok(out)
173 }
174
175 pub fn run_inherit(&self, args: &[&str]) -> Result<ExitStatus, GitError> {
178 if self.verbose {
179 eprintln!("[git] git {}", args.join(" "));
180 }
181 base_git(&self.top).args(args).status().map_err(spawn_err)
182 }
183
184 pub fn state(&self) -> Result<RepoState, GitError> {
186 let out = self.run_ok(&["rev-parse", "--git-dir"])?;
187 let raw = String::from_utf8_lossy(&out.stdout).trim().to_owned();
188 let git_dir = {
190 let p = PathBuf::from(&raw);
191 if p.is_absolute() { p } else { self.top.join(p) }
192 };
193 if git_dir.join("rebase-apply").exists() {
196 if git_dir.join("rebase-apply/applying").exists() {
198 Ok(RepoState::Am)
199 } else {
200 Ok(RepoState::Rebasing)
201 }
202 } else if git_dir.join("rebase-merge").exists() {
203 Ok(RepoState::Rebasing)
204 } else if git_dir.join("CHERRY_PICK_HEAD").exists() {
205 Ok(RepoState::CherryPicking)
206 } else if git_dir.join("REVERT_HEAD").exists() {
207 Ok(RepoState::Reverting)
208 } else if git_dir.join("MERGE_HEAD").exists() {
209 Ok(RepoState::Merging)
210 } else {
211 Ok(RepoState::Clean)
212 }
213 }
214
215 pub fn conflicted_files(&self) -> Result<Vec<ConflictedFile>, GitError> {
217 let out = self.run_ok(&["ls-files", "-u", "-z"])?;
218 Ok(parse_ls_files_unmerged(&String::from_utf8_lossy(
219 &out.stdout,
220 )))
221 }
222
223 pub fn read_stage(&self, path: &str, stage: u8) -> Result<Vec<u8>, GitError> {
225 let spec = format!(":{stage}:{path}");
226 Ok(self.run_ok(&["show", &spec])?.stdout)
227 }
228
229 pub fn list_branches(&self) -> Result<Vec<String>, GitError> {
232 let current = {
233 let out = self.run_ok(&["branch", "--show-current"])?;
234 String::from_utf8_lossy(&out.stdout).trim().to_owned()
235 };
236 let queries: [&[&str]; 2] = [
237 &["branch", "--format=%(refname:short)"],
238 &["branch", "-r", "--format=%(refname:short)"],
239 ];
240 let mut branches = Vec::new();
241 for args in queries {
242 let out = self.run_ok(args)?;
243 for line in String::from_utf8_lossy(&out.stdout).lines() {
244 let name = line.trim();
245 if name.is_empty() || name == current || name.contains("HEAD") {
246 continue;
247 }
248 branches.push(name.to_owned());
249 }
250 }
251 Ok(branches)
252 }
253
254 pub fn recent_commits(&self, others_only: bool, limit: usize) -> Result<Vec<String>, GitError> {
260 let n = format!("-n{limit}");
261 let mut args = vec!["log", "--oneline", &n];
262 if others_only {
263 args.extend(["--all", "--not", "HEAD"]);
264 }
265 let out = self.run(&args)?;
266 if !out.status.success() {
267 return Ok(Vec::new());
268 }
269 Ok(String::from_utf8_lossy(&out.stdout)
270 .lines()
271 .map(str::to_owned)
272 .collect())
273 }
274
275 pub fn vitals(&self) -> Result<RepoVitals, GitError> {
280 let branch = {
281 let out = self.run_ok(&["branch", "--show-current"])?;
282 let name = String::from_utf8_lossy(&out.stdout).trim().to_owned();
283 if name.is_empty() {
284 "HEAD".to_owned()
285 } else {
286 name
287 }
288 };
289 let changes = {
290 let out = self.run_ok(&["status", "--porcelain"])?;
291 String::from_utf8_lossy(&out.stdout).lines().count()
292 };
293 let stashes = {
294 let out = self.run_ok(&["stash", "list"])?;
295 String::from_utf8_lossy(&out.stdout).lines().count()
296 };
297 let count = |args: &[&str]| -> Result<Option<usize>, GitError> {
298 let out = self.run(args)?;
299 if !out.status.success() {
300 return Ok(None);
301 }
302 Ok(String::from_utf8_lossy(&out.stdout).trim().parse().ok())
303 };
304 let ahead = count(&["rev-list", "--count", "@{upstream}..HEAD"])?;
305 let level = count(&["rev-list", "--count", "HEAD"])?.unwrap_or(0);
306 Ok(RepoVitals {
307 branch,
308 changes,
309 stashes,
310 ahead,
311 level,
312 })
313 }
314
315 pub fn stage_resolved(&self, path: &str, content: &[u8]) -> Result<(), GitError> {
317 std::fs::write(self.top.join(path), content)?;
318 self.run_ok(&["add", "--", path])?;
319 Ok(())
320 }
321
322 pub fn continue_op(&self, state: RepoState) -> Result<ExitStatus, GitError> {
328 let op = match state {
329 RepoState::Clean => {
330 return Err(GitError::Failed {
331 cmd: "--continue".to_owned(),
332 stderr: crate::i18n::tr("git.no_op").to_owned(),
333 });
334 }
335 other => other.op_name(),
336 };
337 self.run_inherit(&["-c", "core.editor=true", op, "--continue"])
338 }
339
340 pub fn abort_op(&self, state: RepoState) -> Result<(), GitError> {
342 let op = match state {
343 RepoState::Clean => {
344 return Err(GitError::Failed {
345 cmd: "--abort".to_owned(),
346 stderr: crate::i18n::tr("git.no_op").to_owned(),
347 });
348 }
349 other => other.op_name(),
350 };
351 self.run_ok(&[op, "--abort"]).map(|_| ())
352 }
353}
354
355fn parse_ls_files_unmerged(text: &str) -> Vec<ConflictedFile> {
360 let mut files: Vec<ConflictedFile> = Vec::new();
361 for entry in text.split('\0').filter(|e| !e.is_empty()) {
362 let Some((meta, path)) = entry.split_once('\t') else {
363 continue;
364 };
365 let stage = meta.split_whitespace().nth(2).unwrap_or("0");
366 let idx = match files.iter().position(|f| f.path == path) {
367 Some(i) => i,
368 None => {
369 files.push(ConflictedFile {
370 path: path.to_owned(),
371 has_base: false,
372 has_ours: false,
373 has_theirs: false,
374 });
375 files.len() - 1
376 }
377 };
378 match stage {
379 "1" => files[idx].has_base = true,
380 "2" => files[idx].has_ours = true,
381 "3" => files[idx].has_theirs = true,
382 _ => {}
383 }
384 }
385 files
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 #[test]
393 fn parses_unmerged_entries_grouped_by_path() {
394 let text = "100644 aaaa 1\tsrc/a.rs\x00100644 bbbb 2\tsrc/a.rs\x00100644 cccc 3\tsrc/a.rs\x00100644 dddd 2\tREADME.md\x00100644 eeee 3\tREADME.md\x00";
395 let files = parse_ls_files_unmerged(text);
396 assert_eq!(files.len(), 2);
397 assert_eq!(
398 files[0],
399 ConflictedFile {
400 path: "src/a.rs".to_owned(),
401 has_base: true,
402 has_ours: true,
403 has_theirs: true,
404 }
405 );
406 assert!(!files[1].has_base);
408 assert!(files[1].has_ours && files[1].has_theirs);
409 }
410
411 #[test]
412 fn parses_empty_output() {
413 assert!(parse_ls_files_unmerged("").is_empty());
414 }
415
416 #[test]
417 fn tolerates_tab_in_path() {
418 let text = "100644 aaaa 2\ta\tb.txt\0";
420 let files = parse_ls_files_unmerged(text);
421 assert_eq!(files[0].path, "a\tb.txt");
422 }
423}