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)]
71pub struct ConflictedFile {
72 pub path: String,
74 pub base: Option<String>,
76 pub ours: Option<String>,
78 pub theirs: Option<String>,
80}
81
82#[derive(Debug, Clone)]
84pub struct RepoVitals {
85 pub branch: String,
87 pub changes: usize,
89 pub stashes: usize,
91 pub ahead: Option<usize>,
93 pub level: usize,
95}
96
97pub struct Git {
99 top: PathBuf,
101 verbose: bool,
103}
104
105fn spawn_err(e: std::io::Error) -> GitError {
107 if e.kind() == std::io::ErrorKind::NotFound {
108 GitError::NotFound
109 } else {
110 GitError::Io(e)
111 }
112}
113
114const SCRUBBED_GIT_ENV: [&str; 4] = [
120 "GIT_DIR",
121 "GIT_WORK_TREE",
122 "GIT_INDEX_FILE",
123 "GIT_OBJECT_DIRECTORY",
124];
125
126fn base_git(dir: &Path) -> Command {
128 let mut cmd = Command::new("git");
129 cmd.arg("-C").arg(dir).env("GIT_EDITOR", "true");
130 for var in SCRUBBED_GIT_ENV {
131 cmd.env_remove(var);
132 }
133 cmd
134}
135
136impl Git {
137 pub fn discover(dir: &Path, verbose: bool) -> Result<Self, GitError> {
139 let out = base_git(dir)
140 .args(["rev-parse", "--show-toplevel"])
141 .output()
142 .map_err(spawn_err)?;
143 if !out.status.success() {
144 return Err(GitError::NotARepo);
145 }
146 let top = String::from_utf8_lossy(&out.stdout).trim().to_owned();
147 Ok(Self {
148 top: PathBuf::from(top),
149 verbose,
150 })
151 }
152
153 pub fn top(&self) -> &Path {
155 &self.top
156 }
157
158 pub fn run(&self, args: &[&str]) -> Result<Output, GitError> {
160 if self.verbose {
161 eprintln!("[git] git {}", args.join(" "));
162 }
163 base_git(&self.top).args(args).output().map_err(spawn_err)
164 }
165
166 pub fn run_ok(&self, args: &[&str]) -> Result<Output, GitError> {
168 let out = self.run(args)?;
169 if !out.status.success() {
170 return Err(GitError::Failed {
171 cmd: args.join(" "),
172 stderr: String::from_utf8_lossy(&out.stderr).trim().to_owned(),
173 });
174 }
175 Ok(out)
176 }
177
178 pub fn run_inherit(&self, args: &[&str]) -> Result<ExitStatus, GitError> {
181 if self.verbose {
182 eprintln!("[git] git {}", args.join(" "));
183 }
184 base_git(&self.top).args(args).status().map_err(spawn_err)
185 }
186
187 pub fn state(&self) -> Result<RepoState, GitError> {
189 let out = self.run_ok(&["rev-parse", "--git-dir"])?;
190 let raw = String::from_utf8_lossy(&out.stdout).trim().to_owned();
191 let git_dir = {
193 let p = PathBuf::from(&raw);
194 if p.is_absolute() { p } else { self.top.join(p) }
195 };
196 if git_dir.join("rebase-apply").exists() {
199 if git_dir.join("rebase-apply/applying").exists() {
201 Ok(RepoState::Am)
202 } else {
203 Ok(RepoState::Rebasing)
204 }
205 } else if git_dir.join("rebase-merge").exists() {
206 Ok(RepoState::Rebasing)
207 } else if git_dir.join("CHERRY_PICK_HEAD").exists() {
208 Ok(RepoState::CherryPicking)
209 } else if git_dir.join("REVERT_HEAD").exists() {
210 Ok(RepoState::Reverting)
211 } else if git_dir.join("MERGE_HEAD").exists() {
212 Ok(RepoState::Merging)
213 } else {
214 Ok(RepoState::Clean)
215 }
216 }
217
218 pub fn conflicted_files(&self) -> Result<Vec<ConflictedFile>, GitError> {
220 let out = self.run_ok(&["ls-files", "-u", "-z"])?;
221 Ok(parse_ls_files_unmerged(&String::from_utf8_lossy(
222 &out.stdout,
223 )))
224 }
225
226 pub fn read_stage(&self, path: &str, stage: u8) -> Result<Vec<u8>, GitError> {
228 let spec = format!(":{stage}:{path}");
229 Ok(self.run_ok(&["show", &spec])?.stdout)
230 }
231
232 pub fn read_blobs(&self, oids: &[&str]) -> Result<Vec<Vec<u8>>, GitError> {
238 use std::io::{BufRead, BufReader, Read, Write};
239
240 if oids.is_empty() {
241 return Ok(Vec::new());
242 }
243 if self.verbose {
244 eprintln!("[git] git cat-file --batch ({} blobs)", oids.len());
245 }
246 let mut child = base_git(&self.top)
247 .args(["cat-file", "--batch"])
248 .stdin(std::process::Stdio::piped())
249 .stdout(std::process::Stdio::piped())
250 .stderr(std::process::Stdio::null())
251 .spawn()
252 .map_err(spawn_err)?;
253 let mut stdin = child.stdin.take().ok_or(GitError::NotFound)?;
255 let mut stdout = BufReader::new(child.stdout.take().ok_or(GitError::NotFound)?);
256 let failed = |detail: String| GitError::Failed {
257 cmd: "cat-file --batch".to_owned(),
258 stderr: detail,
259 };
260
261 let mut blobs = Vec::with_capacity(oids.len());
262 for oid in oids {
263 stdin.write_all(format!("{oid}\n").as_bytes())?;
264 stdin.flush()?;
265 let mut header = String::new();
267 stdout.read_line(&mut header)?;
268 let size: usize = header
269 .split_whitespace()
270 .nth(2)
271 .and_then(|s| s.parse().ok())
272 .ok_or_else(|| failed(format!("对象 {oid} 不可读: {}", header.trim())))?;
273 let mut content = vec![0u8; size];
274 stdout.read_exact(&mut content)?;
275 stdout.read_exact(&mut [0u8; 1])?;
277 blobs.push(content);
278 }
279 drop(stdin);
280 child.wait()?;
281 Ok(blobs)
282 }
283
284 pub fn list_branches(&self) -> Result<Vec<String>, GitError> {
287 let current = {
288 let out = self.run_ok(&["branch", "--show-current"])?;
289 String::from_utf8_lossy(&out.stdout).trim().to_owned()
290 };
291 let queries: [&[&str]; 2] = [
292 &["branch", "--format=%(refname:short)"],
293 &["branch", "-r", "--format=%(refname:short)"],
294 ];
295 let mut branches = Vec::new();
296 for args in queries {
297 let out = self.run_ok(args)?;
298 for line in String::from_utf8_lossy(&out.stdout).lines() {
299 let name = line.trim();
300 if name.is_empty() || name == current || name.contains("HEAD") {
301 continue;
302 }
303 branches.push(name.to_owned());
304 }
305 }
306 Ok(branches)
307 }
308
309 pub fn recent_commits(&self, others_only: bool, limit: usize) -> Result<Vec<String>, GitError> {
315 let n = format!("-n{limit}");
316 let mut args = vec!["log", "--oneline", &n];
317 if others_only {
318 args.extend(["--all", "--not", "HEAD"]);
319 }
320 let out = self.run(&args)?;
321 if !out.status.success() {
322 return Ok(Vec::new());
323 }
324 Ok(String::from_utf8_lossy(&out.stdout)
325 .lines()
326 .map(str::to_owned)
327 .collect())
328 }
329
330 pub fn vitals(&self) -> Result<RepoVitals, GitError> {
337 fn joined<T>(handle: std::thread::ScopedJoinHandle<'_, T>) -> Result<T, GitError> {
339 handle.join().map_err(|_| GitError::Failed {
340 cmd: "vitals".to_owned(),
341 stderr: "worker thread panicked".to_owned(),
342 })
343 }
344 let (branch, changes, stashes, ahead, level) = std::thread::scope(|s| {
345 let branch = s.spawn(|| self.run_ok(&["branch", "--show-current"]));
346 let changes = s.spawn(|| self.run_ok(&["status", "--porcelain"]));
347 let stashes = s.spawn(|| self.run_ok(&["stash", "list"]));
348 let ahead = s.spawn(|| self.run(&["rev-list", "--count", "@{upstream}..HEAD"]));
349 let level = s.spawn(|| self.run(&["rev-list", "--count", "HEAD"]));
350 (
351 joined(branch),
352 joined(changes),
353 joined(stashes),
354 joined(ahead),
355 joined(level),
356 )
357 });
358
359 let branch = {
360 let name = String::from_utf8_lossy(&branch??.stdout).trim().to_owned();
361 if name.is_empty() {
362 "HEAD".to_owned()
363 } else {
364 name
365 }
366 };
367 let lines = |out: Output| String::from_utf8_lossy(&out.stdout).lines().count();
368 let count = |out: Output| -> Option<usize> {
369 if !out.status.success() {
370 return None;
371 }
372 String::from_utf8_lossy(&out.stdout).trim().parse().ok()
373 };
374 Ok(RepoVitals {
375 branch,
376 changes: lines(changes??),
377 stashes: lines(stashes??),
378 ahead: count(ahead??),
379 level: count(level??).unwrap_or(0),
380 })
381 }
382
383 pub fn stage_resolved(&self, path: &str, content: &[u8]) -> Result<(), GitError> {
385 std::fs::write(self.top.join(path), content)?;
386 self.run_ok(&["add", "--", path])?;
387 Ok(())
388 }
389
390 pub fn continue_op(&self, state: RepoState) -> Result<ExitStatus, GitError> {
396 let op = match state {
397 RepoState::Clean => {
398 return Err(GitError::Failed {
399 cmd: "--continue".to_owned(),
400 stderr: crate::i18n::tr("git.no_op").to_owned(),
401 });
402 }
403 other => other.op_name(),
404 };
405 self.run_inherit(&["-c", "core.editor=true", op, "--continue"])
406 }
407
408 pub fn abort_op(&self, state: RepoState) -> Result<(), GitError> {
410 let op = match state {
411 RepoState::Clean => {
412 return Err(GitError::Failed {
413 cmd: "--abort".to_owned(),
414 stderr: crate::i18n::tr("git.no_op").to_owned(),
415 });
416 }
417 other => other.op_name(),
418 };
419 self.run_ok(&[op, "--abort"]).map(|_| ())
420 }
421}
422
423fn parse_ls_files_unmerged(text: &str) -> Vec<ConflictedFile> {
428 let mut files: Vec<ConflictedFile> = Vec::new();
429 for entry in text.split('\0').filter(|e| !e.is_empty()) {
430 let Some((meta, path)) = entry.split_once('\t') else {
431 continue;
432 };
433 let mut fields = meta.split_whitespace().skip(1);
434 let (Some(oid), Some(stage)) = (fields.next(), fields.next()) else {
435 continue;
436 };
437 let idx = match files.iter().position(|f| f.path == path) {
438 Some(i) => i,
439 None => {
440 files.push(ConflictedFile {
441 path: path.to_owned(),
442 base: None,
443 ours: None,
444 theirs: None,
445 });
446 files.len() - 1
447 }
448 };
449 match stage {
450 "1" => files[idx].base = Some(oid.to_owned()),
451 "2" => files[idx].ours = Some(oid.to_owned()),
452 "3" => files[idx].theirs = Some(oid.to_owned()),
453 _ => {}
454 }
455 }
456 files
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 #[test]
464 fn parses_unmerged_entries_grouped_by_path() {
465 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";
466 let files = parse_ls_files_unmerged(text);
467 assert_eq!(files.len(), 2);
468 assert_eq!(
469 files[0],
470 ConflictedFile {
471 path: "src/a.rs".to_owned(),
472 base: Some("aaaa".to_owned()),
473 ours: Some("bbbb".to_owned()),
474 theirs: Some("cccc".to_owned()),
475 }
476 );
477 assert!(files[1].base.is_none());
479 assert!(files[1].ours.is_some() && files[1].theirs.is_some());
480 }
481
482 #[test]
483 fn parses_empty_output() {
484 assert!(parse_ls_files_unmerged("").is_empty());
485 }
486
487 #[test]
488 fn tolerates_tab_in_path() {
489 let text = "100644 aaaa 2\ta\tb.txt\0";
491 let files = parse_ls_files_unmerged(text);
492 assert_eq!(files[0].path, "a\tb.txt");
493 }
494}