The only file to watch is the cargo.toml All the others come from just fix + a few manual small fix The set of rules have been taken from the list of clippy rules arbitrarily while trying to optimise the learning and style of the code while limiting the loss of productivity
897 lines
30 KiB
Rust
897 lines
30 KiB
Rust
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
|
|
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use anyhow::anyhow;
|
|
use sha1::digest::Output;
|
|
use uuid::Uuid;
|
|
|
|
use crate::protocol::FileChange;
|
|
|
|
const ZERO_OID: &str = "0000000000000000000000000000000000000000";
|
|
const DEV_NULL: &str = "/dev/null";
|
|
|
|
struct BaselineFileInfo {
|
|
path: PathBuf,
|
|
content: Vec<u8>,
|
|
mode: FileMode,
|
|
oid: String,
|
|
}
|
|
|
|
/// Tracks sets of changes to files and exposes the overall unified diff.
|
|
/// Internally, the way this works is now:
|
|
/// 1. Maintain an in-memory baseline snapshot of files when they are first seen.
|
|
/// For new additions, do not create a baseline so that diffs are shown as proper additions (using /dev/null).
|
|
/// 2. Keep a stable internal filename (uuid) per external path for rename tracking.
|
|
/// 3. To compute the aggregated unified diff, compare each baseline snapshot to the current file on disk entirely in-memory
|
|
/// using the `similar` crate and emit unified diffs with rewritten external paths.
|
|
#[derive(Default)]
|
|
pub struct TurnDiffTracker {
|
|
/// Map external path -> internal filename (uuid).
|
|
external_to_temp_name: HashMap<PathBuf, String>,
|
|
/// Internal filename -> baseline file info.
|
|
baseline_file_info: HashMap<String, BaselineFileInfo>,
|
|
/// Internal filename -> external path as of current accumulated state (after applying all changes).
|
|
/// This is where renames are tracked.
|
|
temp_name_to_current_path: HashMap<String, PathBuf>,
|
|
/// Cache of known git worktree roots to avoid repeated filesystem walks.
|
|
git_root_cache: Vec<PathBuf>,
|
|
}
|
|
|
|
impl TurnDiffTracker {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Front-run apply patch calls to track the starting contents of any modified files.
|
|
/// - Creates an in-memory baseline snapshot for files that already exist on disk when first seen.
|
|
/// - For additions, we intentionally do not create a baseline snapshot so that diffs are proper additions.
|
|
/// - Also updates internal mappings for move/rename events.
|
|
pub fn on_patch_begin(&mut self, changes: &HashMap<PathBuf, FileChange>) {
|
|
for (path, change) in changes.iter() {
|
|
// Ensure a stable internal filename exists for this external path.
|
|
if !self.external_to_temp_name.contains_key(path) {
|
|
let internal = Uuid::new_v4().to_string();
|
|
self.external_to_temp_name
|
|
.insert(path.clone(), internal.clone());
|
|
self.temp_name_to_current_path
|
|
.insert(internal.clone(), path.clone());
|
|
|
|
// If the file exists on disk now, snapshot as baseline; else leave missing to represent /dev/null.
|
|
let baseline_file_info = if path.exists() {
|
|
let mode = file_mode_for_path(path);
|
|
let mode_val = mode.unwrap_or(FileMode::Regular);
|
|
let content = blob_bytes(path, mode_val).unwrap_or_default();
|
|
let oid = if mode == Some(FileMode::Symlink) {
|
|
format!("{:x}", git_blob_sha1_hex_bytes(&content))
|
|
} else {
|
|
self.git_blob_oid_for_path(path)
|
|
.unwrap_or_else(|| format!("{:x}", git_blob_sha1_hex_bytes(&content)))
|
|
};
|
|
Some(BaselineFileInfo {
|
|
path: path.clone(),
|
|
content,
|
|
mode: mode_val,
|
|
oid,
|
|
})
|
|
} else {
|
|
Some(BaselineFileInfo {
|
|
path: path.clone(),
|
|
content: vec![],
|
|
mode: FileMode::Regular,
|
|
oid: ZERO_OID.to_string(),
|
|
})
|
|
};
|
|
|
|
if let Some(baseline_file_info) = baseline_file_info {
|
|
self.baseline_file_info
|
|
.insert(internal.clone(), baseline_file_info);
|
|
}
|
|
}
|
|
|
|
// Track rename/move in current mapping if provided in an Update.
|
|
if let FileChange::Update {
|
|
move_path: Some(dest),
|
|
..
|
|
} = change
|
|
{
|
|
let uuid_filename = match self.external_to_temp_name.get(path) {
|
|
Some(i) => i.clone(),
|
|
None => {
|
|
// This should be rare, but if we haven't mapped the source, create it with no baseline.
|
|
let i = Uuid::new_v4().to_string();
|
|
self.baseline_file_info.insert(
|
|
i.clone(),
|
|
BaselineFileInfo {
|
|
path: path.clone(),
|
|
content: vec![],
|
|
mode: FileMode::Regular,
|
|
oid: ZERO_OID.to_string(),
|
|
},
|
|
);
|
|
i
|
|
}
|
|
};
|
|
// Update current external mapping for temp file name.
|
|
self.temp_name_to_current_path
|
|
.insert(uuid_filename.clone(), dest.clone());
|
|
// Update forward file_mapping: external current -> internal name.
|
|
self.external_to_temp_name.remove(path);
|
|
self.external_to_temp_name
|
|
.insert(dest.clone(), uuid_filename);
|
|
};
|
|
}
|
|
}
|
|
|
|
fn get_path_for_internal(&self, internal: &str) -> Option<PathBuf> {
|
|
self.temp_name_to_current_path
|
|
.get(internal)
|
|
.cloned()
|
|
.or_else(|| {
|
|
self.baseline_file_info
|
|
.get(internal)
|
|
.map(|info| info.path.clone())
|
|
})
|
|
}
|
|
|
|
/// Find the git worktree root for a file/directory by walking up to the first ancestor containing a `.git` entry.
|
|
/// Uses a simple cache of known roots and avoids negative-result caching for simplicity.
|
|
fn find_git_root_cached(&mut self, start: &Path) -> Option<PathBuf> {
|
|
let dir = if start.is_dir() {
|
|
start
|
|
} else {
|
|
start.parent()?
|
|
};
|
|
|
|
// Fast path: if any cached root is an ancestor of this path, use it.
|
|
if let Some(root) = self
|
|
.git_root_cache
|
|
.iter()
|
|
.find(|r| dir.starts_with(r))
|
|
.cloned()
|
|
{
|
|
return Some(root);
|
|
}
|
|
|
|
// Walk up to find a `.git` marker.
|
|
let mut cur = dir.to_path_buf();
|
|
loop {
|
|
let git_marker = cur.join(".git");
|
|
if git_marker.is_dir() || git_marker.is_file() {
|
|
if !self.git_root_cache.iter().any(|r| r == &cur) {
|
|
self.git_root_cache.push(cur.clone());
|
|
}
|
|
return Some(cur);
|
|
}
|
|
|
|
// On Windows, avoid walking above the drive or UNC share root.
|
|
#[cfg(windows)]
|
|
{
|
|
if is_windows_drive_or_unc_root(&cur) {
|
|
return None;
|
|
}
|
|
}
|
|
|
|
if let Some(parent) = cur.parent() {
|
|
cur = parent.to_path_buf();
|
|
} else {
|
|
return None;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Return a display string for `path` relative to its git root if found, else absolute.
|
|
fn relative_to_git_root_str(&mut self, path: &Path) -> String {
|
|
let s = if let Some(root) = self.find_git_root_cached(path) {
|
|
if let Ok(rel) = path.strip_prefix(&root) {
|
|
rel.display().to_string()
|
|
} else {
|
|
path.display().to_string()
|
|
}
|
|
} else {
|
|
path.display().to_string()
|
|
};
|
|
s.replace('\\', "/")
|
|
}
|
|
|
|
/// Ask git to compute the blob SHA-1 for the file at `path` within its repository.
|
|
/// Returns None if no repository is found or git invocation fails.
|
|
fn git_blob_oid_for_path(&mut self, path: &Path) -> Option<String> {
|
|
let root = self.find_git_root_cached(path)?;
|
|
// Compute a path relative to the repo root for better portability across platforms.
|
|
let rel = path.strip_prefix(&root).unwrap_or(path);
|
|
let output = Command::new("git")
|
|
.arg("-C")
|
|
.arg(&root)
|
|
.arg("hash-object")
|
|
.arg("--")
|
|
.arg(rel)
|
|
.output()
|
|
.ok()?;
|
|
if !output.status.success() {
|
|
return None;
|
|
}
|
|
let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
if s.len() == 40 { Some(s) } else { None }
|
|
}
|
|
|
|
/// Recompute the aggregated unified diff by comparing all of the in-memory snapshots that were
|
|
/// collected before the first time they were touched by apply_patch during this turn with
|
|
/// the current repo state.
|
|
pub fn get_unified_diff(&mut self) -> Result<Option<String>> {
|
|
let mut aggregated = String::new();
|
|
|
|
// Compute diffs per tracked internal file in a stable order by external path.
|
|
let mut baseline_file_names: Vec<String> =
|
|
self.baseline_file_info.keys().cloned().collect();
|
|
// Sort lexicographically by full repo-relative path to match git behavior.
|
|
baseline_file_names.sort_by_key(|internal| {
|
|
self.get_path_for_internal(internal)
|
|
.map(|p| self.relative_to_git_root_str(&p))
|
|
.unwrap_or_default()
|
|
});
|
|
|
|
for internal in baseline_file_names {
|
|
aggregated.push_str(self.get_file_diff(&internal).as_str());
|
|
if !aggregated.ends_with('\n') {
|
|
aggregated.push('\n');
|
|
}
|
|
}
|
|
|
|
if aggregated.trim().is_empty() {
|
|
Ok(None)
|
|
} else {
|
|
Ok(Some(aggregated))
|
|
}
|
|
}
|
|
|
|
fn get_file_diff(&mut self, internal_file_name: &str) -> String {
|
|
let mut aggregated = String::new();
|
|
|
|
// Snapshot lightweight fields only.
|
|
let (baseline_external_path, baseline_mode, left_oid) = {
|
|
if let Some(info) = self.baseline_file_info.get(internal_file_name) {
|
|
(info.path.clone(), info.mode, info.oid.clone())
|
|
} else {
|
|
(PathBuf::new(), FileMode::Regular, ZERO_OID.to_string())
|
|
}
|
|
};
|
|
let current_external_path = match self.get_path_for_internal(internal_file_name) {
|
|
Some(p) => p,
|
|
None => return aggregated,
|
|
};
|
|
|
|
let current_mode = file_mode_for_path(¤t_external_path).unwrap_or(FileMode::Regular);
|
|
let right_bytes = blob_bytes(¤t_external_path, current_mode);
|
|
|
|
// Compute displays with &mut self before borrowing any baseline content.
|
|
let left_display = self.relative_to_git_root_str(&baseline_external_path);
|
|
let right_display = self.relative_to_git_root_str(¤t_external_path);
|
|
|
|
// Compute right oid before borrowing baseline content.
|
|
let right_oid = if let Some(b) = right_bytes.as_ref() {
|
|
if current_mode == FileMode::Symlink {
|
|
format!("{:x}", git_blob_sha1_hex_bytes(b))
|
|
} else {
|
|
self.git_blob_oid_for_path(¤t_external_path)
|
|
.unwrap_or_else(|| format!("{:x}", git_blob_sha1_hex_bytes(b)))
|
|
}
|
|
} else {
|
|
ZERO_OID.to_string()
|
|
};
|
|
|
|
// Borrow baseline content only after all &mut self uses are done.
|
|
let left_present = left_oid.as_str() != ZERO_OID;
|
|
let left_bytes: Option<&[u8]> = if left_present {
|
|
self.baseline_file_info
|
|
.get(internal_file_name)
|
|
.map(|i| i.content.as_slice())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Fast path: identical bytes or both missing.
|
|
if left_bytes == right_bytes.as_deref() {
|
|
return aggregated;
|
|
}
|
|
|
|
aggregated.push_str(&format!("diff --git a/{left_display} b/{right_display}\n"));
|
|
|
|
let is_add = !left_present && right_bytes.is_some();
|
|
let is_delete = left_present && right_bytes.is_none();
|
|
|
|
if is_add {
|
|
aggregated.push_str(&format!("new file mode {current_mode}\n"));
|
|
} else if is_delete {
|
|
aggregated.push_str(&format!("deleted file mode {baseline_mode}\n"));
|
|
} else if baseline_mode != current_mode {
|
|
aggregated.push_str(&format!("old mode {baseline_mode}\n"));
|
|
aggregated.push_str(&format!("new mode {current_mode}\n"));
|
|
}
|
|
|
|
let left_text = left_bytes.and_then(|b| std::str::from_utf8(b).ok());
|
|
let right_text = right_bytes
|
|
.as_deref()
|
|
.and_then(|b| std::str::from_utf8(b).ok());
|
|
|
|
let can_text_diff = matches!(
|
|
(left_text, right_text, is_add, is_delete),
|
|
(Some(_), Some(_), _, _) | (_, Some(_), true, _) | (Some(_), _, _, true)
|
|
);
|
|
|
|
if can_text_diff {
|
|
let l = left_text.unwrap_or("");
|
|
let r = right_text.unwrap_or("");
|
|
|
|
aggregated.push_str(&format!("index {left_oid}..{right_oid}\n"));
|
|
|
|
let old_header = if left_present {
|
|
format!("a/{left_display}")
|
|
} else {
|
|
DEV_NULL.to_string()
|
|
};
|
|
let new_header = if right_bytes.is_some() {
|
|
format!("b/{right_display}")
|
|
} else {
|
|
DEV_NULL.to_string()
|
|
};
|
|
|
|
let diff = similar::TextDiff::from_lines(l, r);
|
|
let unified = diff
|
|
.unified_diff()
|
|
.context_radius(3)
|
|
.header(&old_header, &new_header)
|
|
.to_string();
|
|
|
|
aggregated.push_str(&unified);
|
|
} else {
|
|
aggregated.push_str(&format!("index {left_oid}..{right_oid}\n"));
|
|
let old_header = if left_present {
|
|
format!("a/{left_display}")
|
|
} else {
|
|
DEV_NULL.to_string()
|
|
};
|
|
let new_header = if right_bytes.is_some() {
|
|
format!("b/{right_display}")
|
|
} else {
|
|
DEV_NULL.to_string()
|
|
};
|
|
aggregated.push_str(&format!("--- {old_header}\n"));
|
|
aggregated.push_str(&format!("+++ {new_header}\n"));
|
|
aggregated.push_str("Binary files differ\n");
|
|
}
|
|
aggregated
|
|
}
|
|
}
|
|
|
|
/// Compute the Git SHA-1 blob object ID for the given content (bytes).
|
|
fn git_blob_sha1_hex_bytes(data: &[u8]) -> Output<sha1::Sha1> {
|
|
// Git blob hash is sha1 of: "blob <len>\0<data>"
|
|
let header = format!("blob {}\0", data.len());
|
|
use sha1::Digest;
|
|
let mut hasher = sha1::Sha1::new();
|
|
hasher.update(header.as_bytes());
|
|
hasher.update(data);
|
|
hasher.finalize()
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
enum FileMode {
|
|
Regular,
|
|
#[cfg(unix)]
|
|
Executable,
|
|
Symlink,
|
|
}
|
|
|
|
impl FileMode {
|
|
fn as_str(self) -> &'static str {
|
|
match self {
|
|
FileMode::Regular => "100644",
|
|
#[cfg(unix)]
|
|
FileMode::Executable => "100755",
|
|
FileMode::Symlink => "120000",
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for FileMode {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(self.as_str())
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn file_mode_for_path(path: &Path) -> Option<FileMode> {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let meta = fs::symlink_metadata(path).ok()?;
|
|
let ft = meta.file_type();
|
|
if ft.is_symlink() {
|
|
return Some(FileMode::Symlink);
|
|
}
|
|
let mode = meta.permissions().mode();
|
|
let is_exec = (mode & 0o111) != 0;
|
|
Some(if is_exec {
|
|
FileMode::Executable
|
|
} else {
|
|
FileMode::Regular
|
|
})
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn file_mode_for_path(_path: &Path) -> Option<FileMode> {
|
|
// Default to non-executable on non-unix.
|
|
Some(FileMode::Regular)
|
|
}
|
|
|
|
fn blob_bytes(path: &Path, mode: FileMode) -> Option<Vec<u8>> {
|
|
if path.exists() {
|
|
let contents = if mode == FileMode::Symlink {
|
|
symlink_blob_bytes(path)
|
|
.ok_or_else(|| anyhow!("failed to read symlink target for {}", path.display()))
|
|
} else {
|
|
fs::read(path)
|
|
.with_context(|| format!("failed to read current file for diff {}", path.display()))
|
|
};
|
|
contents.ok()
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn symlink_blob_bytes(path: &Path) -> Option<Vec<u8>> {
|
|
use std::os::unix::ffi::OsStrExt;
|
|
let target = std::fs::read_link(path).ok()?;
|
|
Some(target.as_os_str().as_bytes().to_vec())
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn symlink_blob_bytes(_path: &Path) -> Option<Vec<u8>> {
|
|
None
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn is_windows_drive_or_unc_root(p: &std::path::Path) -> bool {
|
|
use std::path::Component;
|
|
let mut comps = p.components();
|
|
matches!(
|
|
(comps.next(), comps.next(), comps.next()),
|
|
(Some(Component::Prefix(_)), Some(Component::RootDir), None)
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
use tempfile::tempdir;
|
|
|
|
/// Compute the Git SHA-1 blob object ID for the given content (string).
|
|
/// This delegates to the bytes version to avoid UTF-8 lossy conversions here.
|
|
fn git_blob_sha1_hex(data: &str) -> String {
|
|
format!("{:x}", git_blob_sha1_hex_bytes(data.as_bytes()))
|
|
}
|
|
|
|
fn normalize_diff_for_test(input: &str, root: &Path) -> String {
|
|
let root_str = root.display().to_string().replace('\\', "/");
|
|
let replaced = input.replace(&root_str, "<TMP>");
|
|
// Split into blocks on lines starting with "diff --git ", sort blocks for determinism, and rejoin
|
|
let mut blocks: Vec<String> = Vec::new();
|
|
let mut current = String::new();
|
|
for line in replaced.lines() {
|
|
if line.starts_with("diff --git ") && !current.is_empty() {
|
|
blocks.push(current);
|
|
current = String::new();
|
|
}
|
|
if !current.is_empty() {
|
|
current.push('\n');
|
|
}
|
|
current.push_str(line);
|
|
}
|
|
if !current.is_empty() {
|
|
blocks.push(current);
|
|
}
|
|
blocks.sort();
|
|
let mut out = blocks.join("\n");
|
|
if !out.ends_with('\n') {
|
|
out.push('\n');
|
|
}
|
|
out
|
|
}
|
|
|
|
#[test]
|
|
fn accumulates_add_and_update() {
|
|
let mut acc = TurnDiffTracker::new();
|
|
|
|
let dir = tempdir().unwrap();
|
|
let file = dir.path().join("a.txt");
|
|
|
|
// First patch: add file (baseline should be /dev/null).
|
|
let add_changes = HashMap::from([(
|
|
file.clone(),
|
|
FileChange::Add {
|
|
content: "foo\n".to_string(),
|
|
},
|
|
)]);
|
|
acc.on_patch_begin(&add_changes);
|
|
|
|
// Simulate apply: create the file on disk.
|
|
fs::write(&file, "foo\n").unwrap();
|
|
let first = acc.get_unified_diff().unwrap().unwrap();
|
|
let first = normalize_diff_for_test(&first, dir.path());
|
|
let expected_first = {
|
|
let mode = file_mode_for_path(&file).unwrap_or(FileMode::Regular);
|
|
let right_oid = git_blob_sha1_hex("foo\n");
|
|
format!(
|
|
r#"diff --git a/<TMP>/a.txt b/<TMP>/a.txt
|
|
new file mode {mode}
|
|
index {ZERO_OID}..{right_oid}
|
|
--- {DEV_NULL}
|
|
+++ b/<TMP>/a.txt
|
|
@@ -0,0 +1 @@
|
|
+foo
|
|
"#,
|
|
)
|
|
};
|
|
assert_eq!(first, expected_first);
|
|
|
|
// Second patch: update the file on disk.
|
|
let update_changes = HashMap::from([(
|
|
file.clone(),
|
|
FileChange::Update {
|
|
unified_diff: "".to_owned(),
|
|
move_path: None,
|
|
},
|
|
)]);
|
|
acc.on_patch_begin(&update_changes);
|
|
|
|
// Simulate apply: append a new line.
|
|
fs::write(&file, "foo\nbar\n").unwrap();
|
|
let combined = acc.get_unified_diff().unwrap().unwrap();
|
|
let combined = normalize_diff_for_test(&combined, dir.path());
|
|
let expected_combined = {
|
|
let mode = file_mode_for_path(&file).unwrap_or(FileMode::Regular);
|
|
let right_oid = git_blob_sha1_hex("foo\nbar\n");
|
|
format!(
|
|
r#"diff --git a/<TMP>/a.txt b/<TMP>/a.txt
|
|
new file mode {mode}
|
|
index {ZERO_OID}..{right_oid}
|
|
--- {DEV_NULL}
|
|
+++ b/<TMP>/a.txt
|
|
@@ -0,0 +1,2 @@
|
|
+foo
|
|
+bar
|
|
"#,
|
|
)
|
|
};
|
|
assert_eq!(combined, expected_combined);
|
|
}
|
|
|
|
#[test]
|
|
fn accumulates_delete() {
|
|
let dir = tempdir().unwrap();
|
|
let file = dir.path().join("b.txt");
|
|
fs::write(&file, "x\n").unwrap();
|
|
|
|
let mut acc = TurnDiffTracker::new();
|
|
let del_changes = HashMap::from([(
|
|
file.clone(),
|
|
FileChange::Delete {
|
|
content: "x\n".to_string(),
|
|
},
|
|
)]);
|
|
acc.on_patch_begin(&del_changes);
|
|
|
|
// Simulate apply: delete the file from disk.
|
|
let baseline_mode = file_mode_for_path(&file).unwrap_or(FileMode::Regular);
|
|
fs::remove_file(&file).unwrap();
|
|
let diff = acc.get_unified_diff().unwrap().unwrap();
|
|
let diff = normalize_diff_for_test(&diff, dir.path());
|
|
let expected = {
|
|
let left_oid = git_blob_sha1_hex("x\n");
|
|
format!(
|
|
r#"diff --git a/<TMP>/b.txt b/<TMP>/b.txt
|
|
deleted file mode {baseline_mode}
|
|
index {left_oid}..{ZERO_OID}
|
|
--- a/<TMP>/b.txt
|
|
+++ {DEV_NULL}
|
|
@@ -1 +0,0 @@
|
|
-x
|
|
"#,
|
|
)
|
|
};
|
|
assert_eq!(diff, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn accumulates_move_and_update() {
|
|
let dir = tempdir().unwrap();
|
|
let src = dir.path().join("src.txt");
|
|
let dest = dir.path().join("dst.txt");
|
|
fs::write(&src, "line\n").unwrap();
|
|
|
|
let mut acc = TurnDiffTracker::new();
|
|
let mv_changes = HashMap::from([(
|
|
src.clone(),
|
|
FileChange::Update {
|
|
unified_diff: "".to_owned(),
|
|
move_path: Some(dest.clone()),
|
|
},
|
|
)]);
|
|
acc.on_patch_begin(&mv_changes);
|
|
|
|
// Simulate apply: move and update content.
|
|
fs::rename(&src, &dest).unwrap();
|
|
fs::write(&dest, "line2\n").unwrap();
|
|
|
|
let out = acc.get_unified_diff().unwrap().unwrap();
|
|
let out = normalize_diff_for_test(&out, dir.path());
|
|
let expected = {
|
|
let left_oid = git_blob_sha1_hex("line\n");
|
|
let right_oid = git_blob_sha1_hex("line2\n");
|
|
format!(
|
|
r#"diff --git a/<TMP>/src.txt b/<TMP>/dst.txt
|
|
index {left_oid}..{right_oid}
|
|
--- a/<TMP>/src.txt
|
|
+++ b/<TMP>/dst.txt
|
|
@@ -1 +1 @@
|
|
-line
|
|
+line2
|
|
"#
|
|
)
|
|
};
|
|
assert_eq!(out, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn move_without_1change_yields_no_diff() {
|
|
let dir = tempdir().unwrap();
|
|
let src = dir.path().join("moved.txt");
|
|
let dest = dir.path().join("renamed.txt");
|
|
fs::write(&src, "same\n").unwrap();
|
|
|
|
let mut acc = TurnDiffTracker::new();
|
|
let mv_changes = HashMap::from([(
|
|
src.clone(),
|
|
FileChange::Update {
|
|
unified_diff: "".to_owned(),
|
|
move_path: Some(dest.clone()),
|
|
},
|
|
)]);
|
|
acc.on_patch_begin(&mv_changes);
|
|
|
|
// Simulate apply: move only, no content change.
|
|
fs::rename(&src, &dest).unwrap();
|
|
|
|
let diff = acc.get_unified_diff().unwrap();
|
|
assert_eq!(diff, None);
|
|
}
|
|
|
|
#[test]
|
|
fn move_declared_but_file_only_appears_at_dest_is_add() {
|
|
let dir = tempdir().unwrap();
|
|
let src = dir.path().join("src.txt");
|
|
let dest = dir.path().join("dest.txt");
|
|
let mut acc = TurnDiffTracker::new();
|
|
let mv = HashMap::from([(
|
|
src,
|
|
FileChange::Update {
|
|
unified_diff: "".into(),
|
|
move_path: Some(dest.clone()),
|
|
},
|
|
)]);
|
|
acc.on_patch_begin(&mv);
|
|
// No file existed initially; create only dest
|
|
fs::write(&dest, "hello\n").unwrap();
|
|
let diff = acc.get_unified_diff().unwrap().unwrap();
|
|
let diff = normalize_diff_for_test(&diff, dir.path());
|
|
let expected = {
|
|
let mode = file_mode_for_path(&dest).unwrap_or(FileMode::Regular);
|
|
let right_oid = git_blob_sha1_hex("hello\n");
|
|
format!(
|
|
r#"diff --git a/<TMP>/src.txt b/<TMP>/dest.txt
|
|
new file mode {mode}
|
|
index {ZERO_OID}..{right_oid}
|
|
--- {DEV_NULL}
|
|
+++ b/<TMP>/dest.txt
|
|
@@ -0,0 +1 @@
|
|
+hello
|
|
"#,
|
|
)
|
|
};
|
|
assert_eq!(diff, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn update_persists_across_new_baseline_for_new_file() {
|
|
let dir = tempdir().unwrap();
|
|
let a = dir.path().join("a.txt");
|
|
let b = dir.path().join("b.txt");
|
|
fs::write(&a, "foo\n").unwrap();
|
|
fs::write(&b, "z\n").unwrap();
|
|
|
|
let mut acc = TurnDiffTracker::new();
|
|
|
|
// First: update existing a.txt (baseline snapshot is created for a).
|
|
let update_a = HashMap::from([(
|
|
a.clone(),
|
|
FileChange::Update {
|
|
unified_diff: "".to_owned(),
|
|
move_path: None,
|
|
},
|
|
)]);
|
|
acc.on_patch_begin(&update_a);
|
|
// Simulate apply: modify a.txt on disk.
|
|
fs::write(&a, "foo\nbar\n").unwrap();
|
|
let first = acc.get_unified_diff().unwrap().unwrap();
|
|
let first = normalize_diff_for_test(&first, dir.path());
|
|
let expected_first = {
|
|
let left_oid = git_blob_sha1_hex("foo\n");
|
|
let right_oid = git_blob_sha1_hex("foo\nbar\n");
|
|
format!(
|
|
r#"diff --git a/<TMP>/a.txt b/<TMP>/a.txt
|
|
index {left_oid}..{right_oid}
|
|
--- a/<TMP>/a.txt
|
|
+++ b/<TMP>/a.txt
|
|
@@ -1 +1,2 @@
|
|
foo
|
|
+bar
|
|
"#
|
|
)
|
|
};
|
|
assert_eq!(first, expected_first);
|
|
|
|
// Next: introduce a brand-new path b.txt into baseline snapshots via a delete change.
|
|
let del_b = HashMap::from([(
|
|
b.clone(),
|
|
FileChange::Delete {
|
|
content: "z\n".to_string(),
|
|
},
|
|
)]);
|
|
acc.on_patch_begin(&del_b);
|
|
// Simulate apply: delete b.txt.
|
|
let baseline_mode = file_mode_for_path(&b).unwrap_or(FileMode::Regular);
|
|
fs::remove_file(&b).unwrap();
|
|
|
|
let combined = acc.get_unified_diff().unwrap().unwrap();
|
|
let combined = normalize_diff_for_test(&combined, dir.path());
|
|
let expected = {
|
|
let left_oid_a = git_blob_sha1_hex("foo\n");
|
|
let right_oid_a = git_blob_sha1_hex("foo\nbar\n");
|
|
let left_oid_b = git_blob_sha1_hex("z\n");
|
|
format!(
|
|
r#"diff --git a/<TMP>/a.txt b/<TMP>/a.txt
|
|
index {left_oid_a}..{right_oid_a}
|
|
--- a/<TMP>/a.txt
|
|
+++ b/<TMP>/a.txt
|
|
@@ -1 +1,2 @@
|
|
foo
|
|
+bar
|
|
diff --git a/<TMP>/b.txt b/<TMP>/b.txt
|
|
deleted file mode {baseline_mode}
|
|
index {left_oid_b}..{ZERO_OID}
|
|
--- a/<TMP>/b.txt
|
|
+++ {DEV_NULL}
|
|
@@ -1 +0,0 @@
|
|
-z
|
|
"#,
|
|
)
|
|
};
|
|
assert_eq!(combined, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn binary_files_differ_update() {
|
|
let dir = tempdir().unwrap();
|
|
let file = dir.path().join("bin.dat");
|
|
|
|
// Initial non-UTF8 bytes
|
|
let left_bytes: Vec<u8> = vec![0xff, 0xfe, 0xfd, 0x00];
|
|
// Updated non-UTF8 bytes
|
|
let right_bytes: Vec<u8> = vec![0x01, 0x02, 0x03, 0x00];
|
|
|
|
fs::write(&file, &left_bytes).unwrap();
|
|
|
|
let mut acc = TurnDiffTracker::new();
|
|
let update_changes = HashMap::from([(
|
|
file.clone(),
|
|
FileChange::Update {
|
|
unified_diff: "".to_owned(),
|
|
move_path: None,
|
|
},
|
|
)]);
|
|
acc.on_patch_begin(&update_changes);
|
|
|
|
// Apply update on disk
|
|
fs::write(&file, &right_bytes).unwrap();
|
|
|
|
let diff = acc.get_unified_diff().unwrap().unwrap();
|
|
let diff = normalize_diff_for_test(&diff, dir.path());
|
|
let expected = {
|
|
let left_oid = format!("{:x}", git_blob_sha1_hex_bytes(&left_bytes));
|
|
let right_oid = format!("{:x}", git_blob_sha1_hex_bytes(&right_bytes));
|
|
format!(
|
|
r#"diff --git a/<TMP>/bin.dat b/<TMP>/bin.dat
|
|
index {left_oid}..{right_oid}
|
|
--- a/<TMP>/bin.dat
|
|
+++ b/<TMP>/bin.dat
|
|
Binary files differ
|
|
"#
|
|
)
|
|
};
|
|
assert_eq!(diff, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn filenames_with_spaces_add_and_update() {
|
|
let mut acc = TurnDiffTracker::new();
|
|
|
|
let dir = tempdir().unwrap();
|
|
let file = dir.path().join("name with spaces.txt");
|
|
|
|
// First patch: add file (baseline should be /dev/null).
|
|
let add_changes = HashMap::from([(
|
|
file.clone(),
|
|
FileChange::Add {
|
|
content: "foo\n".to_string(),
|
|
},
|
|
)]);
|
|
acc.on_patch_begin(&add_changes);
|
|
|
|
// Simulate apply: create the file on disk.
|
|
fs::write(&file, "foo\n").unwrap();
|
|
let first = acc.get_unified_diff().unwrap().unwrap();
|
|
let first = normalize_diff_for_test(&first, dir.path());
|
|
let expected_first = {
|
|
let mode = file_mode_for_path(&file).unwrap_or(FileMode::Regular);
|
|
let right_oid = git_blob_sha1_hex("foo\n");
|
|
format!(
|
|
r#"diff --git a/<TMP>/name with spaces.txt b/<TMP>/name with spaces.txt
|
|
new file mode {mode}
|
|
index {ZERO_OID}..{right_oid}
|
|
--- {DEV_NULL}
|
|
+++ b/<TMP>/name with spaces.txt
|
|
@@ -0,0 +1 @@
|
|
+foo
|
|
"#,
|
|
)
|
|
};
|
|
assert_eq!(first, expected_first);
|
|
|
|
// Second patch: update the file on disk.
|
|
let update_changes = HashMap::from([(
|
|
file.clone(),
|
|
FileChange::Update {
|
|
unified_diff: "".to_owned(),
|
|
move_path: None,
|
|
},
|
|
)]);
|
|
acc.on_patch_begin(&update_changes);
|
|
|
|
// Simulate apply: append a new line with a space.
|
|
fs::write(&file, "foo\nbar baz\n").unwrap();
|
|
let combined = acc.get_unified_diff().unwrap().unwrap();
|
|
let combined = normalize_diff_for_test(&combined, dir.path());
|
|
let expected_combined = {
|
|
let mode = file_mode_for_path(&file).unwrap_or(FileMode::Regular);
|
|
let right_oid = git_blob_sha1_hex("foo\nbar baz\n");
|
|
format!(
|
|
r#"diff --git a/<TMP>/name with spaces.txt b/<TMP>/name with spaces.txt
|
|
new file mode {mode}
|
|
index {ZERO_OID}..{right_oid}
|
|
--- {DEV_NULL}
|
|
+++ b/<TMP>/name with spaces.txt
|
|
@@ -0,0 +1,2 @@
|
|
+foo
|
|
+bar baz
|
|
"#,
|
|
)
|
|
};
|
|
assert_eq!(combined, expected_combined);
|
|
}
|
|
}
|