//! Read-only snapshots and patches. Ordinary Git state only; never refresh/write the index. use crate::Result; use crate::{ HeadInfo, head_info, repo::{self, error}, }; use gix::bstr::ByteSlice; use std::collections::{BTreeMap, BTreeSet}; /// Change classification. Renames are exact-content matches, not similarity guesses. #[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq, Debug)] #[serde(rename_all = "snake_case")] pub enum Kind { Added, Modified, Deleted, Renamed, } /// One changed repository-relative path. #[derive(serde::Serialize, serde::Deserialize)] pub struct Change { /// Destination path (old path for a deletion), invalid UTF-8 replaced. pub path: String, /// Classification; mode/type changes are modified. pub status: Kind, /// Source path for exact-content renames. pub old_path: Option, } /// Repository status without index refresh or other writes. #[derive(serde::Serialize, serde::Deserialize)] pub struct Status { /// Symbolic, detached or unborn HEAD. pub head: HeadInfo, /// HEAD tree to stage-zero index, excluding conflicts. pub staged: Vec, /// Index to worktree; conflicts listed separately. pub unstaged: Vec, /// Individual untracked files, excluding ignored paths. pub untracked: Vec, /// Paths with nonzero index stages. pub conflicted: Vec, } /// Diff query with mutually exclusive modes. #[derive(serde::Serialize, serde::Deserialize)] pub struct DiffParams { /// Plain name under /repos (a-z0-9._-, no '..'), or absolute path under /plugins/ or /repos/ (no empty, '.' or '..' segments or trailing slash). pub repo: String, /// Old revision; must be supplied together with to, without staged=true. pub from: Option, /// New revision (commit, tag or tree). pub to: Option, /// Compare HEAD to index instead of index to worktree; default false. pub staged: Option, /// Literal relative file/directory filter, matches either rename endpoint. pub path: Option, /// Unchanged lines around each hunk; default 3. pub context: Option, } /// One changed file and its patch. #[derive(serde::Serialize, serde::Deserialize)] pub struct FileDiff { /// Destination path, or old path for deletion. pub path: String, /// Added, modified, deleted or exact-content renamed. pub status: Kind, /// Rename source, otherwise null. pub old_path: Option, /// True for NUL-containing or non-UTF-8 content; patch is empty. pub binary: bool, /// Unified patch with file headers and mode/rename metadata. Empty for binary files. pub patch: String, } #[derive(Clone, PartialEq, Eq)] struct Entry { id: gix::ObjectId, mode: u32, } type Snapshot = BTreeMap, Entry>; fn index(repo: &gix::Repository) -> Result<(Snapshot, BTreeSet>)> { let index = repo.index_or_empty().map_err(|e| error("index", e))?; let mut entries = Snapshot::new(); let mut conflicts = BTreeSet::new(); for entry in index.entries() { let path = entry.path(&index).to_vec(); if entry.mode == gix::index::entry::Mode::DIR || entry .flags .contains(gix::index::entry::Flags::INTENT_TO_ADD) { return Err(error( "index", "sparse-directory and intent-to-add entries are not supported by status/diff", )); } if entry.stage() != gix::index::entry::Stage::Unconflicted { conflicts.insert(path); } else { entries.insert( path, Entry { id: entry.id, mode: entry.mode.bits(), }, ); } } Ok((entries, conflicts)) } fn tree(repo: &gix::Repository, rev: &str) -> Result { if rev == "HEAD" && repo.head().map_err(|e| error("diff HEAD", e))?.is_unborn() { return Ok(Snapshot::new()); } let obj = repo .rev_parse_single(rev) .map_err(|e| error("diff revision", e))? .object() .map_err(|e| error("diff object", e))? .peel_to_kind(gix::objs::Kind::Tree) .map_err(|e| error("diff tree", e))?; let mut result = Snapshot::new(); let mut pending = vec![(Vec::new(), obj.id)]; while let Some((prefix, id)) = pending.pop() { let tree = repo.find_tree(id).map_err(|e| error("diff tree", e))?; for entry in tree.iter() { let entry = entry.map_err(|e| error("diff entry", e))?; let mut path = prefix.clone(); path.extend_from_slice(entry.filename()); if entry.mode().is_tree() { path.push(b'/'); pending.push((path, entry.oid().to_owned())); } else { result.insert( path, Entry { id: entry.oid().to_owned(), mode: entry.mode().kind() as u32, }, ); } } } Ok(result) } fn display(path: &[u8]) -> String { String::from_utf8_lossy(path).into_owned() } /// Git C-style byte quoting, so patch paths remain exact even when RPC display paths are lossy. fn quote(prefix: &str, path: &[u8]) -> String { if prefix .as_bytes() .iter() .chain(path) .all(|b| matches!(b, 32..=126) && !matches!(b, b'"' | b'\\')) { return format!("{prefix}{}", display(path)); } let mut out = String::from("\""); for &byte in prefix.as_bytes().iter().chain(path) { match byte { b'"' => out.push_str("\\\""), b'\\' => out.push_str("\\\\"), b'\n' => out.push_str("\\n"), b'\r' => out.push_str("\\r"), b'\t' => out.push_str("\\t"), 32..=126 => out.push(byte as char), _ => out.push_str(&format!("\\{byte:03o}")), } } out.push('"'); out } // Internal byte paths preserve identity even when the RPC display is lossy. fn compare(old: &Snapshot, new: &Snapshot) -> Vec<(Vec, Option>, Kind)> { compare_with_renames(old, new, true) } fn compare_with_renames( old: &Snapshot, new: &Snapshot, renames: bool, ) -> Vec<(Vec, Option>, Kind)> { let mut deleted: BTreeSet<_> = old .keys() .filter(|p| !new.contains_key(*p)) .cloned() .collect(); let mut result = Vec::new(); for (path, entry) in new { match old.get(path) { Some(before) if before == entry => (), Some(_) => result.push((path.clone(), None, Kind::Modified)), None => { // Git does not pair empty blobs as renames by default. let empty = gix::objs::compute_hash(entry.id.kind(), gix::objs::Kind::Blob, b"").ok(); let source = if !renames || Some(entry.id) == empty { None } else { deleted.iter().find(|p| old.get(*p) == Some(entry)).cloned() }; if let Some(source) = source { deleted.remove(&source); result.push((path.clone(), Some(source), Kind::Renamed)); } else { result.push((path.clone(), None, Kind::Added)); } } } } result.extend(deleted.into_iter().map(|p| (p, None, Kind::Deleted))); result.sort_by(|a, b| a.0.cmp(&b.0)); result } fn changes(old: &Snapshot, new: &Snapshot) -> Vec { compare(old, new) .into_iter() .map(|(path, source, status)| Change { path: display(&path), old_path: source.map(|p| display(&p)), status, }) .collect() } pub fn status(repo: &gix::Repository) -> Result { repo::worktree(repo)?; let (index, conflicts) = index(repo)?; let mut head = tree(repo, "HEAD")?; for path in &conflicts { head.remove(path); } let mut result = Status { head: head_info(repo)?, staged: changes(&head, &index), unstaged: vec![], untracked: vec![], conflicted: conflicts.iter().map(|p| display(p)).collect(), }; use gix::status::index_worktree::{Item, iter::Summary}; let iter = repo .status(gix::progress::Discard) .map_err(|e| error("status", e))? .untracked_files(gix::status::UntrackedFiles::Files) .into_index_worktree_iter(Vec::::new()) .map_err(|e| error("status", e))?; for item in iter { let item = item.map_err(|e| error("status", e))?; #[cfg(target_os = "wasi")] if matches!( &item, Item::Modification { status: gix::status::plumbing::index_as_worktree::EntryStatus::Change( gix::status::plumbing::index_as_worktree::Change::Modification { executable_bit_changed: true, content_change: None, .. } ), .. } ) { continue; } let path = item.rela_path().to_str_lossy().into_owned(); let Some(summary) = item.summary() else { continue; }; if conflicts.contains(item.rela_path().as_bytes()) { continue; } if matches!(item, Item::DirectoryContents { .. }) { result.untracked.push(path); continue; } if summary == Summary::Conflict { continue; } result.unstaged.push(Change { path, old_path: None, status: if summary == Summary::Removed { Kind::Deleted } else { Kind::Modified }, }); } result.unstaged.sort_by(|a, b| a.path.cmp(&b.path)); result.untracked.sort(); Ok(result) } fn blob(repo: &gix::Repository, entry: Option<&Entry>) -> Result> { match entry { None => Ok(vec![]), Some(e) if e.mode == 0o160000 => Ok(format!("Subproject commit {}\n", e.id).into_bytes()), Some(e) => Ok(repo .find_blob(e.id) .map_err(|e| error("diff blob", e))? .data .clone()), } } fn matches_path(path: &[u8], filter: Option<&str>) -> bool { filter.is_none_or(|filter| { path == filter.as_bytes() || path .strip_prefix(filter.as_bytes()) .is_some_and(|rest| rest.starts_with(b"/")) }) } // Read at most one worktree file at a time. No repository-wide blob-byte cache. fn worktree_file( repo: &gix::Repository, path: &[u8], entry: &Entry, ) -> Result)>> { let root = repo.workdir().ok_or_else(|| error("diff", "no worktree"))?; let caps = repo .filesystem_options() .map_err(|e| error("diff filesystem", e))?; if entry.mode == 0o160000 { return Err(error( "diff", "worktree submodule patches are not supported", )); } let file = root.join(gix::path::from_bstr(path.as_bstr()).map_err(|e| error("diff path", e))?); let meta = match std::fs::symlink_metadata(&file) { Ok(m) => m, Err(e) if matches!( e.kind(), std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory ) => { return Ok(None); } Err(e) => return Err(error("diff stat", e)), }; if meta.is_dir() { return Ok(None); } let bytes = if meta.file_type().is_symlink() { gix::path::into_bstr(std::fs::read_link(&file).map_err(|e| error("diff link", e))?) .map_err(|e| error("diff link path", e))? .into_owned() .to_vec() } else { std::fs::read(&file).map_err(|e| error("diff read", e))? }; let mode = if meta.file_type().is_symlink() { 0o120000 } else if entry.mode == 0o120000 && caps.symlink { 0o100644 } else { entry.mode }; #[cfg(all(unix, not(target_os = "wasi")))] let mode = if mode != 0o120000 && caps.executable_bit { use std::os::unix::fs::PermissionsExt; if meta.permissions().mode() & 0o111 != 0 { 0o100755 } else { 0o100644 } } else { mode }; let id = gix::objs::compute_hash(repo.object_hash(), gix::objs::Kind::Blob, &bytes) .map_err(|e| error("diff hash", e))?; Ok(Some((Entry { id, mode }, bytes))) } fn worktree(repo: &gix::Repository, index: &Snapshot, filter: Option<&str>) -> Result { repo::worktree(repo)?; let mut snapshot = index.clone(); for (path, entry) in index { // Filter before stat, read, or hashing (including unsupported submodules). if !matches_path(path, filter) { continue; } match worktree_file(repo, path, entry)? { Some((entry, _bytes)) => { snapshot.insert(path.clone(), entry); } None => { snapshot.remove(path); } } } Ok(snapshot) } pub fn diff(p: DiffParams) -> Result> { diff_with_renames(p, true) } pub fn diff_with_renames(p: DiffParams, renames: bool) -> Result> { if let Some(path) = &p.path { repo::validate_path(path)?; } let repo = repo::open(&p.repo)?; let (old, new, work) = match (&p.from, &p.to, p.staged.unwrap_or(false)) { (Some(a), Some(b), false) => (tree(&repo, a)?, tree(&repo, b)?, false), (Some(a), None, staged) => { let (index, _) = index(&repo)?; if staged { (tree(&repo, a)?, index, false) } else { ( tree(&repo, a)?, worktree(&repo, &index, p.path.as_deref())?, true, ) } } (None, None, staged) => { repo::worktree(&repo)?; let (index, conflicts) = index(&repo)?; if !conflicts.is_empty() { return Err(error( "diff", "unmerged index: resolve conflicts first (see status.conflicted)", )); } if staged { (tree(&repo, "HEAD")?, index, false) } else { let new = worktree(&repo, &index, p.path.as_deref())?; (index, new, true) } } _ => { return Err(error( "diff", "supply both from and to, without staged=true, or neither revision", )); } }; let mut result = Vec::new(); for (path, source, status) in compare_with_renames(&old, &new, renames) { let before_path = source.as_ref().unwrap_or(&path); if !matches_path(&path, p.path.as_deref()) && !matches_path(before_path, p.path.as_deref()) { continue; } let before = old.get(before_path); let after = new.get(&path); let a = blob(&repo, before)?; let b = if work { match after { Some(expected) => { let (actual, bytes) = worktree_file(&repo, &path, expected)? .ok_or_else(|| error("diff", "worktree changed during diff; retry"))?; if &actual != expected { return Err(error("diff", "worktree changed during diff; retry")); } bytes } None => Vec::new(), } } else { blob(&repo, after)? }; let binary = a.contains(&0) || b.contains(&0) || std::str::from_utf8(&a).is_err() || std::str::from_utf8(&b).is_err(); let mut patch = String::new(); if !binary { patch.push_str(&format!( "diff --git {} {}\n", quote("a/", before_path), quote("b/", &path) )); match (before, after) { (None, Some(e)) => patch.push_str(&format!("new file mode {:06o}\n", e.mode)), (Some(e), None) => patch.push_str(&format!("deleted file mode {:06o}\n", e.mode)), (Some(a), Some(b)) if a.mode != b.mode => patch.push_str(&format!( "old mode {:06o}\nnew mode {:06o}\n", a.mode, b.mode )), _ => (), } if status == Kind::Renamed { patch.push_str(&format!( "similarity index 100%\nrename from {}\nrename to {}\n", quote("", before_path), quote("", &path) )); } if a != b { let old_id = before .map(|e| e.id.to_string()) .unwrap_or_else(|| "0".repeat(40)); let new_id = after .map(|e| e.id.to_string()) .unwrap_or_else(|| "0".repeat(40)); patch.push_str(&format!( "index {}..{}{}\n", &old_id[..7], &new_id[..7], match (before, after) { (Some(a), Some(b)) if a.mode == b.mode => format!(" {:06o}", a.mode), _ => String::new(), } )); patch.push_str(&format!( "--- {}\n+++ {}\n", if before.is_some() { quote("a/", before_path) } else { "/dev/null".into() }, if after.is_some() { quote("b/", &path) } else { "/dev/null".into() } )); patch.push_str(&unified( std::str::from_utf8(&a).map_err(|e| error("diff text", e))?, std::str::from_utf8(&b).map_err(|e| error("diff text", e))?, p.context.unwrap_or(3) as usize, )); } } result.push(FileDiff { path: display(&path), old_path: source.map(|p| display(&p)), status, binary, patch, }); } Ok(result) } /// Format imara's edit ranges, coalescing context-overlapping hunks and preserving final-newline differences. fn unified(a: &str, b: &str, context: usize) -> String { use gix::diff::blob::{Algorithm, Diff, InternedInput}; let left: Vec<_> = a.split_inclusive('\n').collect(); let right: Vec<_> = b.split_inclusive('\n').collect(); let mut input = InternedInput::<&str>::default(); input.update_before(left.iter().copied()); input.update_after(right.iter().copied()); let diff = Diff::compute(Algorithm::Myers, &input); let hunks: Vec<_> = diff.hunks().collect(); let mut out = String::new(); let mut i = 0; let line = |out: &mut String, prefix: char, s: &str| { out.push(prefix); out.push_str(s); if !s.ends_with('\n') { out.push_str("\n\\ No newline at end of file\n"); } }; while i < hunks.len() { let first = &hunks[i]; let start_a = (first.before.start as usize).saturating_sub(context); let start_b = (first.after.start as usize).saturating_sub(context); let mut last = i; while last + 1 < hunks.len() && (hunks[last + 1].before.start as usize) .saturating_sub(hunks[last].before.end as usize) <= context.saturating_mul(2) { last += 1; } let end_a = (hunks[last].before.end as usize) .saturating_add(context) .min(left.len()); let end_b = (hunks[last].after.end as usize) .saturating_add(context) .min(right.len()); let range = |start: usize, end: usize| { let count = end - start; if count == 1 { format!("{}", start + 1) } else { format!("{},{}", if count == 0 { start } else { start + 1 }, count) } }; out.push_str(&format!( "@@ -{} +{} @@\n", range(start_a, end_a), range(start_b, end_b) )); let mut cursor = start_a; for h in &hunks[i..=last] { for s in &left[cursor..h.before.start as usize] { line(&mut out, ' ', s); } for s in &left[h.before.start as usize..h.before.end as usize] { line(&mut out, '-', s); } for s in &right[h.after.start as usize..h.after.end as usize] { line(&mut out, '+', s); } cursor = h.before.end as usize; } for s in &left[cursor..end_a] { line(&mut out, ' ', s); } i = last + 1; } out } #[cfg(test)] mod tests { use super::*; #[test] fn unified_context_coalescing_and_final_newline() { assert_eq!( unified( "a\nb\nc\nd\ne\nf\ng\nh\ni\n", "a\nB\nc\nd\ne\nf\ng\nH\ni\n", 1 ), "@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n@@ -7,3 +7,3 @@\n g\n-h\n+H\n i\n" ); assert_eq!( unified("a\nb\nc\nd\ne\n", "a\nB\nc\nD\ne\n", 1), "@@ -1,5 +1,5 @@\n a\n-b\n+B\n c\n-d\n+D\n e\n" ); assert_eq!( unified("a\n", "a", 0), "@@ -1 +1 @@\n-a\n+a\n\\ No newline at end of file\n" ); assert_eq!(unified("a\n", "a\nb\n", 0), "@@ -1,0 +2 @@\n+b\n"); assert_eq!(unified("a\nb\n", "a\n", 0), "@@ -2 +1,0 @@\n-b\n"); assert_eq!(unified("", "x\n", usize::MAX), "@@ -0,0 +1 @@\n+x\n"); assert_eq!(quote("a/", b"plain/file"), "a/plain/file"); assert_eq!(quote("", b"with space"), "with space"); assert_eq!( quote("a/", b"x\t\"\\\n\x01\xff"), "\"a/x\\t\\\"\\\\\\n\\001\\377\"" ); } }