userland/git: git-compatible CLI on v2's gix code (T10)
Local workflow, smart-HTTP clone/fetch/push and public HTTPS clone/fetch match native git in stdout and exit codes. Only WASI imports (wasi:http, exit-with-code).
This commit is contained in:
@@ -0,0 +1,600 @@
|
||||
//! 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<String>,
|
||||
}
|
||||
/// 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<Change>,
|
||||
/// Index to worktree; conflicts listed separately.
|
||||
pub unstaged: Vec<Change>,
|
||||
/// Individual untracked files, excluding ignored paths.
|
||||
pub untracked: Vec<String>,
|
||||
/// Paths with nonzero index stages.
|
||||
pub conflicted: Vec<String>,
|
||||
}
|
||||
/// 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<String>,
|
||||
/// New revision (commit, tag or tree).
|
||||
pub to: Option<String>,
|
||||
/// Compare HEAD to index instead of index to worktree; default false.
|
||||
pub staged: Option<bool>,
|
||||
/// Literal relative file/directory filter, matches either rename endpoint.
|
||||
pub path: Option<String>,
|
||||
/// Unchanged lines around each hunk; default 3.
|
||||
pub context: Option<u32>,
|
||||
}
|
||||
/// 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<String>,
|
||||
/// 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<Vec<u8>, Entry>;
|
||||
|
||||
fn index(repo: &gix::Repository) -> Result<(Snapshot, BTreeSet<Vec<u8>>)> {
|
||||
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<Snapshot> {
|
||||
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<u8>, Option<Vec<u8>>, 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 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<Change> {
|
||||
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<Status> {
|
||||
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::<gix::bstr::BString>::new())
|
||||
.map_err(|e| error("status", e))?;
|
||||
for item in iter {
|
||||
let item = item.map_err(|e| error("status", e))?;
|
||||
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<Vec<u8>> {
|
||||
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<Option<(Entry, Vec<u8>)>> {
|
||||
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(unix)]
|
||||
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<Snapshot> {
|
||||
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<Vec<FileDiff>> {
|
||||
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),
|
||||
(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(&old, &new) {
|
||||
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\""
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user