use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Output};
#[derive(Debug, thiserror::Error)]
pub enum GitError {
#[error("Cannot find the git executable file, please confirm it is installed and in the PATH.")]
NotFound,
#[error("The specified path is not a Git repository")]
NotARepo,
#[error("git {cmd} execution failed: {stderr}")]
Failed {
cmd: String,
stderr: String,
},
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RepoState {
Clean,
Merging,
Rebasing,
CherryPicking,
Reverting,
Am,
}
impl RepoState {
pub fn op_name(self) -> &'static str {
match self {
RepoState::Clean => "clean",
RepoState::Merging => "merge",
RepoState::Rebasing => "rebase",
RepoState::CherryPicking => "cherry-pick",
RepoState::Reverting => "revert",
RepoState::Am => "am",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConflictedFile {
pub path: String,
pub base: Option<String>,
pub ours: Option<String>,
pub theirs: Option<String>,
}
#[derive(Debug, Clone)]
pub struct RepoVitals {
pub branch: String,
pub changes: usize,
pub stashes: usize,
pub ahead: Option<usize>,
pub level: usize,
}
pub struct Git {
top: PathBuf,
verbose: bool,
}
fn spawn_err(e: std::io::Error) -> GitError {
if e.kind() == std::io::ErrorKind::NotFound {
GitError::NotFound
} else {
GitError::Io(e)
}
}
const SCRUBBED_GIT_ENV: [&str; 4] = [
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
];
fn base_git(dir: &Path) -> Command {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(dir).env("GIT_EDITOR", "true");
for var in SCRUBBED_GIT_ENV {
cmd.env_remove(var);
}
cmd
}
impl Git {
pub fn discover(dir: &Path, verbose: bool) -> Result<Self, GitError> {
let out = base_git(dir)
.args(["rev-parse", "--show-toplevel"])
.output()
.map_err(spawn_err)?;
if !out.status.success() {
return Err(GitError::NotARepo);
}
let top = String::from_utf8_lossy(&out.stdout).trim().to_owned();
Ok(Self {
top: PathBuf::from(top),
verbose,
})
}
pub fn top(&self) -> &Path {
&self.top
}
pub fn run(&self, args: &[&str]) -> Result<Output, GitError> {
if self.verbose {
eprintln!("[git] git {}", args.join(" "));
}
base_git(&self.top).args(args).output().map_err(spawn_err)
}
pub fn run_ok(&self, args: &[&str]) -> Result<Output, GitError> {
let out = self.run(args)?;
if !out.status.success() {
return Err(GitError::Failed {
cmd: args.join(" "),
stderr: String::from_utf8_lossy(&out.stderr).trim().to_owned(),
});
}
Ok(out)
}
pub fn run_inherit(&self, args: &[&str]) -> Result<ExitStatus, GitError> {
if self.verbose {
eprintln!("[git] git {}", args.join(" "));
}
base_git(&self.top).args(args).status().map_err(spawn_err)
}
pub fn state(&self) -> Result<RepoState, GitError> {
let out = self.run_ok(&["rev-parse", "--git-dir"])?;
let raw = String::from_utf8_lossy(&out.stdout).trim().to_owned();
let git_dir = {
let p = PathBuf::from(&raw);
if p.is_absolute() { p } else { self.top.join(p) }
};
if git_dir.join("rebase-apply").exists() {
if git_dir.join("rebase-apply/applying").exists() {
Ok(RepoState::Am)
} else {
Ok(RepoState::Rebasing)
}
} else if git_dir.join("rebase-merge").exists() {
Ok(RepoState::Rebasing)
} else if git_dir.join("CHERRY_PICK_HEAD").exists() {
Ok(RepoState::CherryPicking)
} else if git_dir.join("REVERT_HEAD").exists() {
Ok(RepoState::Reverting)
} else if git_dir.join("MERGE_HEAD").exists() {
Ok(RepoState::Merging)
} else {
Ok(RepoState::Clean)
}
}
pub fn conflicted_files(&self) -> Result<Vec<ConflictedFile>, GitError> {
let out = self.run_ok(&["ls-files", "-u", "-z"])?;
Ok(parse_ls_files_unmerged(&String::from_utf8_lossy(
&out.stdout,
)))
}
pub fn read_stage(&self, path: &str, stage: u8) -> Result<Vec<u8>, GitError> {
let spec = format!(":{stage}:{path}");
Ok(self.run_ok(&["show", &spec])?.stdout)
}
pub fn read_blobs(&self, oids: &[&str]) -> Result<Vec<Vec<u8>>, GitError> {
use std::io::{BufRead, BufReader, Read, Write};
if oids.is_empty() {
return Ok(Vec::new());
}
if self.verbose {
eprintln!("[git] git cat-file --batch ({} blobs)", oids.len());
}
let mut child = base_git(&self.top)
.args(["cat-file", "--batch"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(spawn_err)?;
let mut stdin = child.stdin.take().ok_or(GitError::NotFound)?;
let mut stdout = BufReader::new(child.stdout.take().ok_or(GitError::NotFound)?);
let failed = |detail: String| GitError::Failed {
cmd: "cat-file --batch".to_owned(),
stderr: detail,
};
let mut blobs = Vec::with_capacity(oids.len());
for oid in oids {
stdin.write_all(format!("{oid}\n").as_bytes())?;
stdin.flush()?;
let mut header = String::new();
stdout.read_line(&mut header)?;
let size: usize = header
.split_whitespace()
.nth(2)
.and_then(|s| s.parse().ok())
.ok_or_else(|| failed(format!("对象 {oid} 不可读: {}", header.trim())))?;
let mut content = vec![0u8; size];
stdout.read_exact(&mut content)?;
stdout.read_exact(&mut [0u8; 1])?;
blobs.push(content);
}
drop(stdin);
child.wait()?;
Ok(blobs)
}
pub fn list_branches(&self) -> Result<Vec<String>, GitError> {
let current = {
let out = self.run_ok(&["branch", "--show-current"])?;
String::from_utf8_lossy(&out.stdout).trim().to_owned()
};
let queries: [&[&str]; 2] = [
&["branch", "--format=%(refname:short)"],
&["branch", "-r", "--format=%(refname:short)"],
];
let mut branches = Vec::new();
for args in queries {
let out = self.run_ok(args)?;
for line in String::from_utf8_lossy(&out.stdout).lines() {
let name = line.trim();
if name.is_empty() || name == current || name.contains("HEAD") {
continue;
}
branches.push(name.to_owned());
}
}
Ok(branches)
}
pub fn recent_commits(&self, others_only: bool, limit: usize) -> Result<Vec<String>, GitError> {
let n = format!("-n{limit}");
let mut args = vec!["log", "--oneline", &n];
if others_only {
args.extend(["--all", "--not", "HEAD"]);
}
let out = self.run(&args)?;
if !out.status.success() {
return Ok(Vec::new());
}
Ok(String::from_utf8_lossy(&out.stdout)
.lines()
.map(str::to_owned)
.collect())
}
pub fn vitals(&self) -> Result<RepoVitals, GitError> {
fn joined<T>(handle: std::thread::ScopedJoinHandle<'_, T>) -> Result<T, GitError> {
handle.join().map_err(|_| GitError::Failed {
cmd: "vitals".to_owned(),
stderr: "worker thread panicked".to_owned(),
})
}
let (branch, changes, stashes, ahead, level) = std::thread::scope(|s| {
let branch = s.spawn(|| self.run_ok(&["branch", "--show-current"]));
let changes = s.spawn(|| self.run_ok(&["status", "--porcelain"]));
let stashes = s.spawn(|| self.run_ok(&["stash", "list"]));
let ahead = s.spawn(|| self.run(&["rev-list", "--count", "@{upstream}..HEAD"]));
let level = s.spawn(|| self.run(&["rev-list", "--count", "HEAD"]));
(
joined(branch),
joined(changes),
joined(stashes),
joined(ahead),
joined(level),
)
});
let branch = {
let name = String::from_utf8_lossy(&branch??.stdout).trim().to_owned();
if name.is_empty() {
"HEAD".to_owned()
} else {
name
}
};
let lines = |out: Output| String::from_utf8_lossy(&out.stdout).lines().count();
let count = |out: Output| -> Option<usize> {
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout).trim().parse().ok()
};
Ok(RepoVitals {
branch,
changes: lines(changes??),
stashes: lines(stashes??),
ahead: count(ahead??),
level: count(level??).unwrap_or(0),
})
}
pub fn stage_resolved(&self, path: &str, content: &[u8]) -> Result<(), GitError> {
std::fs::write(self.top.join(path), content)?;
self.run_ok(&["add", "--", path])?;
Ok(())
}
pub fn continue_op(&self, state: RepoState) -> Result<ExitStatus, GitError> {
let op = match state {
RepoState::Clean => {
return Err(GitError::Failed {
cmd: "--continue".to_owned(),
stderr: crate::i18n::tr("git.no_op").to_owned(),
});
}
other => other.op_name(),
};
self.run_inherit(&["-c", "core.editor=true", op, "--continue"])
}
pub fn abort_op(&self, state: RepoState) -> Result<(), GitError> {
let op = match state {
RepoState::Clean => {
return Err(GitError::Failed {
cmd: "--abort".to_owned(),
stderr: crate::i18n::tr("git.no_op").to_owned(),
});
}
other => other.op_name(),
};
self.run_ok(&[op, "--abort"]).map(|_| ())
}
}
fn parse_ls_files_unmerged(text: &str) -> Vec<ConflictedFile> {
let mut files: Vec<ConflictedFile> = Vec::new();
for entry in text.split('\0').filter(|e| !e.is_empty()) {
let Some((meta, path)) = entry.split_once('\t') else {
continue;
};
let mut fields = meta.split_whitespace().skip(1);
let (Some(oid), Some(stage)) = (fields.next(), fields.next()) else {
continue;
};
let idx = match files.iter().position(|f| f.path == path) {
Some(i) => i,
None => {
files.push(ConflictedFile {
path: path.to_owned(),
base: None,
ours: None,
theirs: None,
});
files.len() - 1
}
};
match stage {
"1" => files[idx].base = Some(oid.to_owned()),
"2" => files[idx].ours = Some(oid.to_owned()),
"3" => files[idx].theirs = Some(oid.to_owned()),
_ => {}
}
}
files
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_unmerged_entries_grouped_by_path() {
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";
let files = parse_ls_files_unmerged(text);
assert_eq!(files.len(), 2);
assert_eq!(
files[0],
ConflictedFile {
path: "src/a.rs".to_owned(),
base: Some("aaaa".to_owned()),
ours: Some("bbbb".to_owned()),
theirs: Some("cccc".to_owned()),
}
);
assert!(files[1].base.is_none());
assert!(files[1].ours.is_some() && files[1].theirs.is_some());
}
#[test]
fn parses_empty_output() {
assert!(parse_ls_files_unmerged("").is_empty());
}
#[test]
fn tolerates_tab_in_path() {
let text = "100644 aaaa 2\ta\tb.txt\0";
let files = parse_ls_files_unmerged(text);
assert_eq!(files[0].path, "a\tb.txt");
}
}