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 has_base: bool,
pub has_ours: bool,
pub has_theirs: bool,
}
#[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 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> {
let branch = {
let out = self.run_ok(&["branch", "--show-current"])?;
let name = String::from_utf8_lossy(&out.stdout).trim().to_owned();
if name.is_empty() {
"HEAD".to_owned()
} else {
name
}
};
let changes = {
let out = self.run_ok(&["status", "--porcelain"])?;
String::from_utf8_lossy(&out.stdout).lines().count()
};
let stashes = {
let out = self.run_ok(&["stash", "list"])?;
String::from_utf8_lossy(&out.stdout).lines().count()
};
let count = |args: &[&str]| -> Result<Option<usize>, GitError> {
let out = self.run(args)?;
if !out.status.success() {
return Ok(None);
}
Ok(String::from_utf8_lossy(&out.stdout).trim().parse().ok())
};
let ahead = count(&["rev-list", "--count", "@{upstream}..HEAD"])?;
let level = count(&["rev-list", "--count", "HEAD"])?.unwrap_or(0);
Ok(RepoVitals {
branch,
changes,
stashes,
ahead,
level,
})
}
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 stage = meta.split_whitespace().nth(2).unwrap_or("0");
let idx = match files.iter().position(|f| f.path == path) {
Some(i) => i,
None => {
files.push(ConflictedFile {
path: path.to_owned(),
has_base: false,
has_ours: false,
has_theirs: false,
});
files.len() - 1
}
};
match stage {
"1" => files[idx].has_base = true,
"2" => files[idx].has_ours = true,
"3" => files[idx].has_theirs = true,
_ => {}
}
}
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(),
has_base: true,
has_ours: true,
has_theirs: true,
}
);
assert!(!files[1].has_base);
assert!(files[1].has_ours && files[1].has_theirs);
}
#[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");
}
}