use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
pub const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
pub const DEFAULT_COMMIT_LIMIT: usize = 20;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FileStatus {
Added,
Modified,
Deleted,
Renamed,
Copied,
TypeChanged,
Unmerged,
Untracked,
Unknown,
}
impl FileStatus {
fn from_letter(letter: char) -> Self {
match letter {
'A' => FileStatus::Added,
'M' => FileStatus::Modified,
'D' => FileStatus::Deleted,
'R' => FileStatus::Renamed,
'C' => FileStatus::Copied,
'T' => FileStatus::TypeChanged,
'U' => FileStatus::Unmerged,
'?' => FileStatus::Untracked,
_ => FileStatus::Unknown,
}
}
pub fn letter(self) -> char {
match self {
FileStatus::Added => 'A',
FileStatus::Modified => 'M',
FileStatus::Deleted => 'D',
FileStatus::Renamed => 'R',
FileStatus::Copied => 'C',
FileStatus::TypeChanged => 'T',
FileStatus::Unmerged => 'U',
FileStatus::Untracked => '?',
FileStatus::Unknown => 'X',
}
}
fn takes_two_paths(self) -> bool {
matches!(self, FileStatus::Renamed | FileStatus::Copied)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChangedFile {
pub status: FileStatus,
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub old_path: Option<String>,
}
impl ChangedFile {
pub fn label(&self) -> String {
match &self.old_path {
Some(old) => format!("{} {} -> {}", self.status.letter(), old, self.path),
None => format!("{} {}", self.status.letter(), self.path),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Commit {
pub hash: String,
pub short: String,
pub author: String,
pub date: String,
pub subject: String,
pub files: Vec<ChangedFile>,
}
impl Commit {
pub fn label(&self) -> String {
format!(
"{} {} {} ({})",
self.short, self.date, self.subject, self.author
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ChangeSet {
WorkingTree,
Staged,
Commit { hash: String },
}
impl ChangeSet {
pub fn label(&self) -> String {
match self {
ChangeSet::WorkingTree => "working tree".to_string(),
ChangeSet::Staged => "staged".to_string(),
ChangeSet::Commit { hash } => hash.chars().take(7).collect(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReviewTarget {
pub set: ChangeSet,
pub file: ChangedFile,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Review {
pub root: PathBuf,
pub working_tree: Vec<ChangedFile>,
pub staged: Vec<ChangedFile>,
pub commits: Vec<Commit>,
}
impl Review {
pub fn files_of(&self, set: &ChangeSet) -> &[ChangedFile] {
match set {
ChangeSet::WorkingTree => &self.working_tree,
ChangeSet::Staged => &self.staged,
ChangeSet::Commit { hash } => self
.commits
.iter()
.find(|commit| &commit.hash == hash)
.map(|commit| commit.files.as_slice())
.unwrap_or(&[]),
}
}
}
fn git(root: &Path, args: &[&str]) -> Result<Vec<u8>> {
let output = Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.output()
.with_context(|| format!("cannot run git {}", args.join(" ")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!(
"git {} failed: {}",
args.join(" "),
stderr.trim().lines().next().unwrap_or("no message")
);
}
Ok(output.stdout)
}
pub fn repository_root(from: &Path) -> Result<PathBuf> {
let out = git(from, &["rev-parse", "--show-toplevel"])
.with_context(|| format!("{} is not inside a git repository", from.display()))?;
Ok(PathBuf::from(String::from_utf8_lossy(&out).trim_end()))
}
pub fn parse_name_status(bytes: &[u8]) -> Vec<ChangedFile> {
let mut fields = bytes
.split(|&b| b == 0)
.map(|field| String::from_utf8_lossy(field).into_owned())
.filter(|field| !field.is_empty());
let mut files = Vec::new();
while let Some(status_field) = fields.next() {
let status = FileStatus::from_letter(status_field.chars().next().unwrap_or('X'));
let Some(first) = fields.next() else {
break;
};
if status.takes_two_paths() {
let Some(second) = fields.next() else {
break;
};
files.push(ChangedFile {
status,
path: second,
old_path: Some(first),
});
} else {
files.push(ChangedFile {
status,
path: first,
old_path: None,
});
}
}
files
}
const LOG_FORMAT: &str = "--format=%H%x1f%h%x1f%an%x1f%ad%x1f%s";
pub fn parse_log(bytes: &[u8]) -> Vec<Commit> {
bytes
.split(|&b| b == 0)
.filter(|record| !record.is_empty())
.filter_map(|record| {
let text = String::from_utf8_lossy(record);
let mut fields = text.trim_end_matches('\n').split('\x1f');
Some(Commit {
hash: fields.next()?.to_string(),
short: fields.next()?.to_string(),
author: fields.next()?.to_string(),
date: fields.next()?.to_string(),
subject: fields.next().unwrap_or("").to_string(),
files: Vec::new(),
})
})
.collect()
}
fn diff_base(root: &Path, hash: &str) -> Result<String> {
let out = git(root, &["rev-list", "--parents", "-n", "1", hash])?;
let text = String::from_utf8_lossy(&out);
Ok(text
.split_whitespace()
.nth(1)
.unwrap_or(EMPTY_TREE)
.to_string())
}
fn has_commits(root: &Path) -> bool {
git(root, &["rev-parse", "--verify", "--quiet", "HEAD"]).is_ok()
}
pub fn load(from: &Path, commit_limit: usize) -> Result<Review> {
let root = repository_root(from)?;
let mut working_tree = parse_name_status(&git(&root, &["diff", "--name-status", "-z"])?);
let untracked = git(&root, &["ls-files", "--others", "--exclude-standard", "-z"])?;
working_tree.extend(
untracked
.split(|&b| b == 0)
.filter(|path| !path.is_empty())
.map(|path| ChangedFile {
status: FileStatus::Untracked,
path: String::from_utf8_lossy(path).into_owned(),
old_path: None,
}),
);
let staged = parse_name_status(&git(
&root,
&["diff", "--cached", "--name-status", "-M", "-z"],
)?);
let mut commits = Vec::new();
if has_commits(&root) {
let limit = commit_limit.to_string();
commits = parse_log(&git(
&root,
&["log", "-z", "-n", &limit, "--date=short", LOG_FORMAT],
)?);
for commit in &mut commits {
let base = diff_base(&root, &commit.hash)?;
commit.files = parse_name_status(&git(
&root,
&["diff", "--name-status", "-M", "-z", &base, &commit.hash],
)?);
}
}
Ok(Review {
root,
working_tree,
staged,
commits,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Side {
Blob { rev: String, path: String },
WorkTree(String),
Absent(String),
}
fn sides(root: &Path, target: &ReviewTarget) -> Result<(Side, Side)> {
let file = &target.file;
let path = file.path.clone();
let old_path = file.old_path.clone().unwrap_or_else(|| path.clone());
let blob = |rev: &str, path: String| Side::Blob {
rev: rev.to_string(),
path,
};
Ok(match &target.set {
ChangeSet::WorkingTree => {
let before = match file.status {
FileStatus::Untracked | FileStatus::Added => Side::Absent(path.clone()),
FileStatus::Unmerged => blob("HEAD", old_path),
_ => blob("", old_path),
};
let after = match file.status {
FileStatus::Deleted => Side::Absent(path),
_ => Side::WorkTree(path),
};
(before, after)
}
ChangeSet::Staged => {
let before = match file.status {
FileStatus::Added => Side::Absent(path.clone()),
_ => blob("HEAD", old_path),
};
let after = match file.status {
FileStatus::Deleted => Side::Absent(path),
_ => blob("", path),
};
(before, after)
}
ChangeSet::Commit { hash } => {
let base = diff_base(root, hash)?;
let before = match file.status {
FileStatus::Added => Side::Absent(path.clone()),
_ => blob(&base, old_path),
};
let after = match file.status {
FileStatus::Deleted => Side::Absent(path),
_ => blob(hash, path),
};
(before, after)
}
})
}
#[derive(Debug)]
pub struct Workspace {
dir: PathBuf,
}
impl Workspace {
pub fn new() -> Result<Self> {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let mut builder = std::fs::DirBuilder::new();
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
for attempt in 0..100 {
let dir = std::env::temp_dir().join(format!(
"omnidiff-review-{}-{nanos}-{attempt}",
std::process::id()
));
match builder.create(&dir) {
Ok(()) => return Ok(Self { dir }),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => {
return Err(error).with_context(|| format!("cannot create {}", dir.display()));
}
}
}
anyhow::bail!(
"cannot create a review directory under {}: every name tried already exists",
std::env::temp_dir().display()
)
}
pub fn dir(&self) -> &Path {
&self.dir
}
fn write(&self, label: &str, path: &str, bytes: &[u8]) -> Result<PathBuf> {
let target = self.dir.join(label).join(path);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
}
std::fs::write(&target, bytes)
.with_context(|| format!("cannot write {}", target.display()))?;
Ok(target)
}
fn materialize_side(&self, root: &Path, side: &Side) -> Result<PathBuf> {
match side {
Side::WorkTree(path) => Ok(root.join(path)),
Side::Absent(path) => self.write("absent", path, b""),
Side::Blob { rev, path } => {
let spec = format!("{rev}:{path}");
let bytes = git(root, &["show", &spec])?;
let label = if rev.is_empty() {
"index"
} else {
rev.as_str()
};
let label: String = label
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
self.write(&label, path, &bytes)
}
}
}
pub fn materialize(&self, root: &Path, target: &ReviewTarget) -> Result<(PathBuf, PathBuf)> {
let (before, after) = sides(root, target)?;
Ok((
self.materialize_side(root, &before)?,
self.materialize_side(root, &after)?,
))
}
}
impl Drop for Workspace {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn run(root: &Path, args: &[&str]) {
let status = Command::new("git")
.arg("-C")
.arg(root)
.args([
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"-c",
"commit.gpgsign=false",
])
.args(args)
.status()
.expect("git runs");
assert!(status.success(), "git {args:?} failed");
}
fn sample_repo() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
run(root, &["init", "-q", "-b", "main"]);
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/a.rs"), "fn a() {}\n").unwrap();
std::fs::write(root.join("gone.rs"), "fn gone() {}\n").unwrap();
std::fs::write(root.join("src/old.rs"), "fn old() {}\n").unwrap();
run(root, &["add", "."]);
run(root, &["commit", "-q", "-m", "first"]);
run(root, &["mv", "src/old.rs", "src/new.rs"]);
run(root, &["commit", "-q", "-m", "rename old to new"]);
std::fs::write(root.join("src/a.rs"), "fn a() { 1 }\n").unwrap();
std::fs::remove_file(root.join("gone.rs")).unwrap();
std::fs::write(root.join("src/b.rs"), "fn b() {}\n").unwrap();
run(root, &["add", "src/b.rs"]);
std::fs::write(root.join("notes.txt"), "untracked\n").unwrap();
dir
}
#[test]
fn name_status_parses_plain_and_two_path_records() {
let files = parse_name_status(b"M\0src/a.rs\0R100\0old.rs\0new.rs\0A\0b.rs\0D\0c.rs\0");
assert_eq!(files.len(), 4);
assert_eq!(
files[0],
ChangedFile {
status: FileStatus::Modified,
path: "src/a.rs".into(),
old_path: None
}
);
assert_eq!(
files[1],
ChangedFile {
status: FileStatus::Renamed,
path: "new.rs".into(),
old_path: Some("old.rs".into())
}
);
assert_eq!(files[1].label(), "R old.rs -> new.rs");
assert_eq!(files[3].label(), "D c.rs");
assert!(parse_name_status(b"").is_empty());
assert!(
parse_name_status(b"M\0").is_empty(),
"a truncated record is dropped, not panicked on"
);
}
#[test]
fn log_records_split_on_the_unit_separator() {
let commits = parse_log(b"abc\x1fabc1234\x1fAda\x1f2026-09-10\x1fSubject here\0def\x1fdef5678\x1fBob\x1f2026-09-09\x1f\0");
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].label(), "abc1234 2026-09-10 Subject here (Ada)");
assert_eq!(commits[1].subject, "");
}
#[test]
fn change_set_labels_are_footer_sized() {
assert_eq!(ChangeSet::WorkingTree.label(), "working tree");
assert_eq!(ChangeSet::Staged.label(), "staged");
assert_eq!(
ChangeSet::Commit {
hash: "0123456789abcdef".into()
}
.label(),
"0123456"
);
let json = serde_json::to_string(&ChangeSet::Commit { hash: "x".into() }).unwrap();
assert_eq!(json, r#"{"kind":"commit","hash":"x"}"#);
}
#[test]
fn load_lists_the_three_sets_of_a_real_repository() {
let dir = sample_repo();
let review = load(&dir.path().join("src"), 10).unwrap();
assert_eq!(
review.root.canonicalize().unwrap(),
dir.path().canonicalize().unwrap()
);
let names =
|files: &[ChangedFile]| files.iter().map(ChangedFile::label).collect::<Vec<_>>();
assert_eq!(
names(&review.working_tree),
vec!["D gone.rs", "M src/a.rs", "? notes.txt"]
);
assert_eq!(names(&review.staged), vec!["A src/b.rs"]);
assert_eq!(review.commits.len(), 2);
assert_eq!(review.commits[0].subject, "rename old to new");
assert_eq!(
names(&review.commits[0].files),
vec!["R src/old.rs -> src/new.rs"]
);
assert_eq!(
names(&review.commits[1].files),
vec!["A gone.rs", "A src/a.rs", "A src/old.rs"],
"the root commit is diffed against the empty tree"
);
assert_eq!(review.files_of(&ChangeSet::Staged).len(), 1);
assert_eq!(
review
.files_of(&ChangeSet::Commit {
hash: review.commits[0].hash.clone()
})
.len(),
1
);
assert!(
review
.files_of(&ChangeSet::Commit {
hash: "nope".into()
})
.is_empty()
);
}
#[test]
fn an_empty_repository_lists_nothing_and_does_not_fail() {
let dir = tempfile::tempdir().unwrap();
run(dir.path(), &["init", "-q"]);
std::fs::write(dir.path().join("x.rs"), "").unwrap();
run(dir.path(), &["add", "x.rs"]);
let review = load(dir.path(), 5).unwrap();
assert!(review.commits.is_empty());
assert_eq!(review.staged[0].status, FileStatus::Added);
}
#[test]
fn outside_a_repository_is_an_error_naming_the_directory() {
let dir = tempfile::tempdir().unwrap();
let err = load(dir.path(), 5).unwrap_err().to_string();
assert!(err.contains("not inside a git repository"), "{err}");
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).unwrap()
}
#[test]
fn each_workspace_is_a_fresh_owner_only_directory() {
let first = Workspace::new().unwrap();
let second = Workspace::new().unwrap();
assert_ne!(first.dir(), second.dir());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(first.dir()).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700);
}
}
#[test]
fn materializing_each_kind_of_change_keeps_the_real_path_and_the_right_contents() {
let dir = sample_repo();
let review = load(dir.path(), 10).unwrap();
let workspace = Workspace::new().unwrap();
let root = &review.root;
let modified = ReviewTarget {
set: ChangeSet::WorkingTree,
file: review.working_tree[1].clone(),
};
let (before, after) = workspace.materialize(root, &modified).unwrap();
assert_eq!(read(&before), "fn a() {}\n");
assert!(before.starts_with(workspace.dir()));
assert!(before.ends_with("index/src/a.rs"), "{}", before.display());
assert_eq!(
after,
root.join("src/a.rs"),
"the working tree side is the real file"
);
let deleted = ReviewTarget {
set: ChangeSet::WorkingTree,
file: review.working_tree[0].clone(),
};
let (before, after) = workspace.materialize(root, &deleted).unwrap();
assert_eq!(read(&before), "fn gone() {}\n");
assert_eq!(read(&after), "");
assert!(after.ends_with("absent/gone.rs"));
let untracked = ReviewTarget {
set: ChangeSet::WorkingTree,
file: review.working_tree[2].clone(),
};
let (before, after) = workspace.materialize(root, &untracked).unwrap();
assert_eq!(read(&before), "");
assert_eq!(after, root.join("notes.txt"));
let staged = ReviewTarget {
set: ChangeSet::Staged,
file: review.staged[0].clone(),
};
let (before, after) = workspace.materialize(root, &staged).unwrap();
assert_eq!(read(&before), "");
assert_eq!(read(&after), "fn b() {}\n");
assert!(after.ends_with("index/src/b.rs"));
let commit = &review.commits[0];
let renamed = ReviewTarget {
set: ChangeSet::Commit {
hash: commit.hash.clone(),
},
file: commit.files[0].clone(),
};
let (before, after) = workspace.materialize(root, &renamed).unwrap();
assert!(before.ends_with("src/old.rs") && after.ends_with("src/new.rs"));
assert_eq!(read(&before), read(&after));
assert!(after.to_string_lossy().contains(&commit.hash));
let first = &review.commits[1];
let added = ReviewTarget {
set: ChangeSet::Commit {
hash: first.hash.clone(),
},
file: first.files[1].clone(),
};
let (before, after) = workspace.materialize(root, &added).unwrap();
assert_eq!(read(&before), "");
assert_eq!(read(&after), "fn a() {}\n");
let kept = workspace.dir().to_path_buf();
drop(workspace);
assert!(!kept.exists(), "the workspace is removed on drop");
}
#[test]
fn a_missing_blob_is_a_clear_error_not_a_panic() {
let dir = sample_repo();
let review = load(dir.path(), 10).unwrap();
let workspace = Workspace::new().unwrap();
let bogus = ReviewTarget {
set: ChangeSet::Staged,
file: ChangedFile {
status: FileStatus::Modified,
path: "no/such.rs".into(),
old_path: None,
},
};
let err = workspace
.materialize(&review.root, &bogus)
.unwrap_err()
.to_string();
assert!(err.starts_with("git show"), "{err}");
}
}