use std::{
collections::HashMap,
path::{Path, PathBuf},
process::{Command, Output},
};
use anyhow::{anyhow, bail, Context};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum DiffSection {
Staged,
Unstaged,
Untracked,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum FileChange {
Modified,
Added,
Deleted,
Renamed,
Copied,
TypeChanged,
Unmerged,
Untracked,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct ChangedFile {
pub(super) section: DiffSection,
pub(super) change: FileChange,
pub(super) path: String,
pub(super) orig_path: Option<String>,
pub(super) stats: Option<(u64, u64)>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct WorktreeStatus {
pub(super) repo_root: PathBuf,
pub(super) branch: Option<String>,
pub(super) files: Vec<ChangedFile>,
}
type NumstatMap = HashMap<String, Option<(u64, u64)>>;
pub(super) fn collect_status(cwd: &Path) -> anyhow::Result<WorktreeStatus> {
let toplevel = checked_stdout(run(git(cwd).args(["rev-parse", "--show-toplevel"]))?)?;
let repo_root = path_from_git_bytes(toplevel.strip_suffix(b"\n").unwrap_or(&toplevel[..]));
let status = checked_stdout(run(git(&repo_root).args([
"status",
"--porcelain=v2",
"-z",
"--branch",
"--untracked-files=all",
"--renames",
]))?)?;
let (branch, mut files) = parse_status(&status)?;
let staged = parse_numstat(&checked_stdout(run(git(&repo_root).args([
"diff",
"--cached",
"--numstat",
"-z",
"-M",
]))?)?)?;
let unstaged = parse_numstat(&checked_stdout(run(git(&repo_root).args([
"diff",
"--numstat",
"-z",
"-M",
]))?)?)?;
for file in &mut files {
let counts = match file.section {
DiffSection::Staged => &staged,
DiffSection::Unstaged => &unstaged,
DiffSection::Untracked => continue,
};
file.stats = counts.get(&file.path).copied().flatten();
}
Ok(WorktreeStatus {
repo_root,
branch,
files,
})
}
pub(super) fn file_patch(repo_root: &Path, file: &ChangedFile) -> anyhow::Result<String> {
let mut command = git(repo_root);
command.args([
"diff",
"--no-color",
"--no-ext-diff",
"--src-prefix=a/",
"--dst-prefix=b/",
]);
match file.section {
DiffSection::Staged => {
command.args(["--cached", "-M"]);
}
DiffSection::Unstaged if file.change == FileChange::Unmerged => {
command.arg("--ours");
}
DiffSection::Unstaged => {
command.arg("-M");
}
DiffSection::Untracked => return untracked_patch(command, &file.path),
}
command.arg("--").arg(&file.path).args(&file.orig_path);
command_output(run(&mut command)?)
}
fn git(dir: &Path) -> Command {
let mut command = Command::new("git");
command
.current_dir(dir)
.env("GIT_LITERAL_PATHSPECS", "1")
.args(["--no-optional-locks", "-c", "core.quotePath=false"]);
command
}
fn run(command: &mut Command) -> anyhow::Result<Output> {
command
.output()
.map_err(|error| anyhow!("could not run git: {error}"))
}
fn untracked_patch(mut command: Command, path: &str) -> anyhow::Result<String> {
if path.ends_with('/') {
bail!("{path} is a nested git repository");
}
let output = run(command.args(["--no-index", "--", "/dev/null", path]))?;
if output.status.code() == Some(1) && !output.stdout.is_empty() {
return Ok(trimmed_text(&output.stdout));
}
command_output(output)
}
fn parse_status(bytes: &[u8]) -> anyhow::Result<(Option<String>, Vec<ChangedFile>)> {
let mut branch = None;
let mut staged = Vec::new();
let mut unstaged = Vec::new();
let mut untracked = Vec::new();
let mut records = bytes.split(|byte| *byte == 0).map(String::from_utf8_lossy);
while let Some(record) = records.next() {
if record.is_empty() {
continue;
}
let Some((kind, rest)) = record.split_once(' ') else {
bail!("unexpected git status record: {record:?}");
};
match kind {
"#" => {
if let Some(head) = rest.strip_prefix("branch.head ") {
branch = (head != "(detached)").then(|| head.to_string());
}
}
"1" => {
let (xy, path) = xy_and_path(rest, 8, &record)?;
push_tracked(&mut staged, &mut unstaged, xy, path, None)?;
}
"2" => {
let (xy, path) = xy_and_path(rest, 9, &record)?;
let orig = records
.next()
.filter(|orig| !orig.is_empty())
.with_context(|| format!("git status rename has no source: {record:?}"))?;
push_tracked(&mut staged, &mut unstaged, xy, path, Some(&*orig))?;
}
"u" => {
let (_, path) = xy_and_path(rest, 10, &record)?;
unstaged.push(ChangedFile {
section: DiffSection::Unstaged,
change: FileChange::Unmerged,
path: path.to_string(),
orig_path: None,
stats: None,
});
}
"?" => untracked.push(ChangedFile {
section: DiffSection::Untracked,
change: FileChange::Untracked,
path: rest.to_string(),
orig_path: None,
stats: None,
}),
"!" => {}
_ => bail!("unexpected git status record: {record:?}"),
}
}
staged.extend(unstaged);
staged.extend(untracked);
Ok((branch, staged))
}
fn xy_and_path<'a>(
rest: &'a str,
field_count: usize,
record: &str,
) -> anyhow::Result<(&'a str, &'a str)> {
let fields: Vec<&str> = rest.splitn(field_count, ' ').collect();
match (fields.first(), fields.get(field_count - 1)) {
(Some(&xy), Some(&path)) if fields.len() == field_count && !path.is_empty() => {
Ok((xy, path))
}
_ => bail!("unexpected git status record: {record:?}"),
}
}
fn push_tracked(
staged: &mut Vec<ChangedFile>,
unstaged: &mut Vec<ChangedFile>,
xy: &str,
path: &str,
orig: Option<&str>,
) -> anyhow::Result<()> {
let &[x, y] = xy.as_bytes() else {
bail!("unexpected git status code: {xy:?}");
};
for (letter, section, files) in [
(x, DiffSection::Staged, staged),
(y, DiffSection::Unstaged, unstaged),
] {
let Some(change) = file_change(letter)? else {
continue;
};
let orig_path = orig
.filter(|_| matches!(change, FileChange::Renamed | FileChange::Copied))
.map(str::to_string);
files.push(ChangedFile {
section,
change,
path: path.to_string(),
orig_path,
stats: None,
});
}
Ok(())
}
fn file_change(letter: u8) -> anyhow::Result<Option<FileChange>> {
let change = match letter {
b'.' => return Ok(None),
b'M' => FileChange::Modified,
b'A' => FileChange::Added,
b'D' => FileChange::Deleted,
b'R' => FileChange::Renamed,
b'C' => FileChange::Copied,
b'T' => FileChange::TypeChanged,
_ => bail!("unexpected git status code: {:?}", char::from(letter)),
};
Ok(Some(change))
}
fn parse_numstat(bytes: &[u8]) -> anyhow::Result<NumstatMap> {
let mut stats = HashMap::new();
let mut records = bytes.split(|byte| *byte == 0).map(String::from_utf8_lossy);
while let Some(record) = records.next() {
if record.is_empty() {
continue;
}
let malformed = || anyhow!("unexpected git numstat record: {record:?}");
let mut fields = record.splitn(3, '\t');
let (Some(added), Some(removed), Some(path)) =
(fields.next(), fields.next(), fields.next())
else {
return Err(malformed());
};
let counts = match (added, removed) {
("-", "-") => None,
_ => Some((
added.parse::<u64>().map_err(|_| malformed())?,
removed.parse::<u64>().map_err(|_| malformed())?,
)),
};
let path = if path.is_empty() {
records.next().ok_or_else(malformed)?;
records
.next()
.filter(|path| !path.is_empty())
.ok_or_else(malformed)?
.into_owned()
} else {
path.to_string()
};
stats.insert(path, counts);
}
Ok(stats)
}
fn command_output(output: Output) -> anyhow::Result<String> {
checked_stdout(output).map(|stdout| trimmed_text(&stdout))
}
fn checked_stdout(output: Output) -> anyhow::Result<Vec<u8>> {
if output.status.success() {
return Ok(output.stdout);
}
let message = String::from_utf8_lossy(&output.stderr);
let first_line = message.trim().lines().next().unwrap_or("").trim();
if first_line.is_empty() {
bail!("git command failed");
}
bail!("{first_line}")
}
fn trimmed_text(stdout: &[u8]) -> String {
String::from_utf8_lossy(stdout)
.trim_end_matches(['\r', '\n'])
.to_string()
}
#[cfg(unix)]
fn path_from_git_bytes(path: &[u8]) -> PathBuf {
use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
PathBuf::from(OsStr::from_bytes(path))
}
#[cfg(not(unix))]
fn path_from_git_bytes(path: &[u8]) -> PathBuf {
PathBuf::from(String::from_utf8_lossy(path).into_owned())
}
#[cfg(test)]
#[path = "local_diff_tests.rs"]
mod tests;