mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Make turn diff tracking operation backed (#21180)
## Summary - replace filesystem-based turn diff tracking with an operation-backed accumulator - preserve enough verified apply_patch state to render move-overwrite cases correctly - keep the turn/diff/updated contract intact while removing remote-only turn-diff test skips This takes the assumption that no 3P services rely on the output format of `apply_patch` ## Why For the CCA file system isolation push --------- Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
b2268999fe
commit
f7e8ff8e50
@@ -1,45 +1,38 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::collections::HashSet;
|
||||
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 codex_protocol::protocol::FileChange;
|
||||
use codex_apply_patch::AppliedPatchChange;
|
||||
use codex_apply_patch::AppliedPatchDelta;
|
||||
use codex_apply_patch::AppliedPatchFileChange;
|
||||
|
||||
const ZERO_OID: &str = "0000000000000000000000000000000000000000";
|
||||
const DEV_NULL: &str = "/dev/null";
|
||||
const REGULAR_FILE_MODE: &str = "100644";
|
||||
|
||||
struct BaselineFileInfo {
|
||||
path: PathBuf,
|
||||
content: Vec<u8>,
|
||||
mode: FileMode,
|
||||
oid: String,
|
||||
/// Tracks the net text diff for the current turn from successful apply_patch
|
||||
/// operations, without rereading the workspace filesystem.
|
||||
pub struct TurnDiffTracker {
|
||||
valid: bool,
|
||||
display_root: Option<PathBuf>,
|
||||
baseline_by_path: HashMap<PathBuf, String>,
|
||||
current_by_path: HashMap<PathBuf, String>,
|
||||
origin_by_current_path: HashMap<PathBuf, PathBuf>,
|
||||
}
|
||||
|
||||
/// 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 Default for TurnDiffTracker {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
valid: true,
|
||||
display_root: None,
|
||||
baseline_by_path: HashMap::new(),
|
||||
current_by_path: HashMap::new(),
|
||||
origin_by_current_path: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TurnDiffTracker {
|
||||
@@ -47,330 +40,268 @@ impl TurnDiffTracker {
|
||||
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());
|
||||
pub fn with_display_root(display_root: PathBuf) -> Self {
|
||||
let mut tracker = Self::new();
|
||||
tracker.display_root = Some(display_root);
|
||||
tracker
|
||||
}
|
||||
|
||||
// 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(),
|
||||
})
|
||||
};
|
||||
pub fn track_successful_patch(&mut self, delta: &AppliedPatchDelta) {
|
||||
if !delta.is_exact() {
|
||||
self.invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
for change in delta.changes() {
|
||||
self.apply_change(change);
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
})
|
||||
pub fn invalidate(&mut self) {
|
||||
self.valid = false;
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
pub fn get_unified_diff(&self) -> Option<String> {
|
||||
if !self.valid {
|
||||
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 rename_pairs = self.rename_pairs();
|
||||
let paired_destinations = rename_pairs.values().cloned().collect::<HashSet<_>>();
|
||||
let mut handled = HashSet::new();
|
||||
let mut paths = self
|
||||
.baseline_by_path
|
||||
.keys()
|
||||
.chain(self.current_by_path.keys())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
paths.sort_by_key(|path| self.display_path(path));
|
||||
paths.dedup();
|
||||
|
||||
let mut aggregated = String::new();
|
||||
for path in paths {
|
||||
if !handled.insert(path.clone()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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()
|
||||
});
|
||||
if paired_destinations.contains(&path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for internal in baseline_file_names {
|
||||
aggregated.push_str(self.get_file_diff(&internal).as_str());
|
||||
if !aggregated.ends_with('\n') {
|
||||
aggregated.push('\n');
|
||||
let diff = if let Some(dest) = rename_pairs.get(&path) {
|
||||
handled.insert(dest.clone());
|
||||
self.render_rename_diff(&path, dest)
|
||||
} else {
|
||||
self.render_path_diff(&path)
|
||||
};
|
||||
|
||||
if let Some(diff) = diff {
|
||||
aggregated.push_str(&diff);
|
||||
if !aggregated.ends_with('\n') {
|
||||
aggregated.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if aggregated.trim().is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(aggregated))
|
||||
(!aggregated.is_empty()).then_some(aggregated)
|
||||
}
|
||||
|
||||
fn apply_change(&mut self, change: &AppliedPatchChange) {
|
||||
let source_path = change.path.as_path();
|
||||
match &change.change {
|
||||
AppliedPatchFileChange::Add {
|
||||
content,
|
||||
overwritten_content,
|
||||
} => self.apply_add(source_path, content, overwritten_content.as_deref()),
|
||||
AppliedPatchFileChange::Delete { content } => self.apply_delete(source_path, content),
|
||||
AppliedPatchFileChange::Update {
|
||||
move_path,
|
||||
old_content,
|
||||
overwritten_move_content,
|
||||
new_content,
|
||||
} => self.apply_update(
|
||||
source_path,
|
||||
move_path.as_deref(),
|
||||
old_content,
|
||||
overwritten_move_content.as_deref(),
|
||||
new_content,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_file_diff(&mut self, internal_file_name: &str) -> String {
|
||||
let mut aggregated = String::new();
|
||||
fn apply_add(&mut self, path: &Path, content: &str, overwritten_content: Option<&str>) {
|
||||
self.origin_by_current_path.remove(path);
|
||||
if !self.current_by_path.contains_key(path)
|
||||
&& !self.baseline_by_path.contains_key(path)
|
||||
&& let Some(overwritten_content) = overwritten_content
|
||||
{
|
||||
self.baseline_by_path
|
||||
.insert(path.to_path_buf(), overwritten_content.to_string());
|
||||
}
|
||||
self.current_by_path
|
||||
.insert(path.to_path_buf(), content.to_string());
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
fn apply_delete(&mut self, path: &Path, content: &str) {
|
||||
if self.current_by_path.remove(path).is_none() && !self.baseline_by_path.contains_key(path)
|
||||
{
|
||||
self.baseline_by_path
|
||||
.insert(path.to_path_buf(), content.to_string());
|
||||
}
|
||||
self.origin_by_current_path.remove(path);
|
||||
}
|
||||
|
||||
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;
|
||||
fn apply_update(
|
||||
&mut self,
|
||||
source_path: &Path,
|
||||
move_path: Option<&Path>,
|
||||
old_content: &str,
|
||||
overwritten_move_content: Option<&str>,
|
||||
new_content: &str,
|
||||
) {
|
||||
if !self.current_by_path.contains_key(source_path)
|
||||
&& !self.baseline_by_path.contains_key(source_path)
|
||||
{
|
||||
self.baseline_by_path
|
||||
.insert(source_path.to_path_buf(), old_content.to_string());
|
||||
}
|
||||
|
||||
aggregated.push_str(&format!("diff --git a/{left_display} b/{right_display}\n"));
|
||||
match move_path {
|
||||
Some(dest_path) => {
|
||||
if !self.current_by_path.contains_key(dest_path)
|
||||
&& !self.baseline_by_path.contains_key(dest_path)
|
||||
&& let Some(overwritten_move_content) = overwritten_move_content
|
||||
{
|
||||
self.baseline_by_path.insert(
|
||||
dest_path.to_path_buf(),
|
||||
overwritten_move_content.to_string(),
|
||||
);
|
||||
}
|
||||
let origin = self
|
||||
.origin_by_current_path
|
||||
.remove(source_path)
|
||||
.unwrap_or_else(|| source_path.to_path_buf());
|
||||
self.current_by_path.remove(source_path);
|
||||
self.current_by_path
|
||||
.insert(dest_path.to_path_buf(), new_content.to_string());
|
||||
self.origin_by_current_path.remove(dest_path);
|
||||
if dest_path != origin.as_path() {
|
||||
self.origin_by_current_path
|
||||
.insert(dest_path.to_path_buf(), origin);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.current_by_path
|
||||
.insert(source_path.to_path_buf(), new_content.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let is_add = !left_present && right_bytes.is_some();
|
||||
let is_delete = left_present && right_bytes.is_none();
|
||||
fn rename_pairs(&self) -> HashMap<PathBuf, PathBuf> {
|
||||
self.origin_by_current_path
|
||||
.iter()
|
||||
.filter_map(|(dest_path, origin_path)| {
|
||||
if dest_path == origin_path
|
||||
|| self.current_by_path.contains_key(origin_path)
|
||||
|| !self.current_by_path.contains_key(dest_path)
|
||||
|| !self.baseline_by_path.contains_key(origin_path)
|
||||
|| self.baseline_by_path.contains_key(dest_path)
|
||||
{
|
||||
return 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"));
|
||||
Some((origin_path.clone(), dest_path.clone()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_path_diff(&self, path: &Path) -> Option<String> {
|
||||
self.render_diff(
|
||||
path,
|
||||
self.baseline_by_path.get(path).map(String::as_str),
|
||||
path,
|
||||
self.current_by_path.get(path).map(String::as_str),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_rename_diff(&self, source_path: &Path, dest_path: &Path) -> Option<String> {
|
||||
self.render_diff(
|
||||
source_path,
|
||||
self.baseline_by_path.get(source_path).map(String::as_str),
|
||||
dest_path,
|
||||
self.current_by_path.get(dest_path).map(String::as_str),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_diff(
|
||||
&self,
|
||||
left_path: &Path,
|
||||
left_content: Option<&str>,
|
||||
right_path: &Path,
|
||||
right_content: Option<&str>,
|
||||
) -> Option<String> {
|
||||
if left_content == right_content {
|
||||
return None;
|
||||
}
|
||||
|
||||
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)
|
||||
let left_display = self.display_path(left_path);
|
||||
let right_display = self.display_path(right_path);
|
||||
let left_oid = left_content.map_or_else(
|
||||
|| ZERO_OID.to_string(),
|
||||
|content| git_blob_oid(content.as_bytes()),
|
||||
);
|
||||
let right_oid = right_content.map_or_else(
|
||||
|| ZERO_OID.to_string(),
|
||||
|content| git_blob_oid(content.as_bytes()),
|
||||
);
|
||||
|
||||
if can_text_diff {
|
||||
let l = left_text.unwrap_or("");
|
||||
let r = right_text.unwrap_or("");
|
||||
let mut diff = format!("diff --git a/{left_display} b/{right_display}\n");
|
||||
match (left_content, right_content) {
|
||||
(None, Some(_)) => diff.push_str(&format!("new file mode {REGULAR_FILE_MODE}\n")),
|
||||
(Some(_), None) => diff.push_str(&format!("deleted file mode {REGULAR_FILE_MODE}\n")),
|
||||
(Some(_), Some(_)) => {}
|
||||
(None, None) => return None,
|
||||
}
|
||||
|
||||
aggregated.push_str(&format!("index {left_oid}..{right_oid}\n"));
|
||||
diff.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 old_header = if left_content.is_some() {
|
||||
format!("a/{left_display}")
|
||||
} else {
|
||||
DEV_NULL.to_string()
|
||||
};
|
||||
let new_header = if right_content.is_some() {
|
||||
format!("b/{right_display}")
|
||||
} else {
|
||||
DEV_NULL.to_string()
|
||||
};
|
||||
|
||||
let diff = similar::TextDiff::from_lines(l, r);
|
||||
let unified = diff
|
||||
let unified =
|
||||
similar::TextDiff::from_lines(left_content.unwrap_or(""), right_content.unwrap_or(""))
|
||||
.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
|
||||
diff.push_str(&unified);
|
||||
Some(diff)
|
||||
}
|
||||
|
||||
fn display_path(&self, path: &Path) -> String {
|
||||
let display = self
|
||||
.display_root
|
||||
.as_deref()
|
||||
.and_then(|root| path.strip_prefix(root).ok())
|
||||
.unwrap_or(path);
|
||||
display.display().to_string().replace('\\', "/")
|
||||
}
|
||||
}
|
||||
|
||||
fn git_blob_oid(data: &[u8]) -> String {
|
||||
format!("{:x}", git_blob_sha1_hex_bytes(data))
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -379,91 +310,6 @@ fn git_blob_sha1_hex_bytes(data: &[u8]) -> Output<sha1::Sha1> {
|
||||
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)]
|
||||
#[path = "turn_diff_tracker_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
Reference in New Issue
Block a user