Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View file

@ -0,0 +1,26 @@
[package]
license = "Apache-2.0"
name = "xai-hunk-tracker"
version = "0.1.0"
edition.workspace = true
description = "Track file hunks (diffs) with agent/external attribution"
[dependencies]
# External
chrono = { workspace = true, features = ["serde"] }
dunce = { workspace = true }
rustc-hash = { workspace = true }
gix = { workspace = true, features = ["status", "index", "parallel"] }
serde = { workspace = true, features = ["derive", "rc"] }
xai-gix-status = { workspace = true }
serde_json = { workspace = true }
similar = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["full"] }
tokio-util = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true, features = ["v4"] }
[dev-dependencies]
tempfile = { workspace = true }
xai-test-utils = { workspace = true }

View file

@ -0,0 +1,635 @@
//! Action commands for the HunkTrackerActor.
//!
//! These methods handle accept/reject actions on hunks.
//!
//! # Invariants
//!
//! Hunks only exist for files where both baseline and current content are
//! patchable (i.e., `FileContentState::Full` or `FileContentState::Missing`
//! for creation/deletion). Files with `Binary` or `TooLarge` content states
//! have their hunks cleared by `recompute_hunks()`.
//!
//! This means action handlers should never encounter a non-patchable state
//! when processing a hunk. The guards in these methods are defensive fallbacks
//! that silently skip non-patchable states rather than panic.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::diff::patch_lines;
use crate::events::{HunkEvent, HunkRemovalReason};
use crate::types::{Hunk, HunkAction, HunkActionError, HunkId, HunkLineInfo, HunkSource};
use super::HunkTrackerActor;
use super::state::FileContentState;
impl HunkTrackerActor {
/// Update session stats when a hunk is accepted or rejected.
fn update_session_stats(&mut self, line_info: &HunkLineInfo, accepted: bool) {
let lines_added = line_info.new_count;
let lines_removed = line_info.old_count;
if accepted {
self.session_stats.accepted_hunks = self.session_stats.accepted_hunks.saturating_add(1);
self.session_stats.accepted_lines_added = self
.session_stats
.accepted_lines_added
.saturating_add(lines_added);
self.session_stats.accepted_lines_removed = self
.session_stats
.accepted_lines_removed
.saturating_add(lines_removed);
} else {
self.session_stats.rejected_hunks = self.session_stats.rejected_hunks.saturating_add(1);
self.session_stats.rejected_lines_added = self
.session_stats
.rejected_lines_added
.saturating_add(lines_added);
self.session_stats.rejected_lines_removed = self
.session_stats
.rejected_lines_removed
.saturating_add(lines_removed);
}
}
/// Remove a hunk from turn_index based on its source.
fn remove_from_turn_index(&mut self, hunk_id: &HunkId, source: &HunkSource) {
if let Some(prompt_index) = source.prompt_index()
&& let Some(set) = self.turn_index.get_mut(&prompt_index)
{
set.remove(hunk_id);
}
}
/// Apply action (accept/reject) to a specific hunk.
pub(super) async fn apply_hunk_action(
&mut self,
hunk_id: &HunkId,
action: HunkAction,
) -> Result<(), HunkActionError> {
// Find which file contains this hunk and capture the full hunk data
let hunk_info = self.file_states.iter().find_map(|(path, state)| {
state
.hunks
.iter()
.find(|h| &h.id == hunk_id)
.map(|h| (path.clone(), h.clone()))
});
let Some((path, hunk)) = hunk_info else {
return Err(HunkActionError::HunkNotFound(hunk_id.clone()));
};
// Update session stats before removing the hunk
let accepted = matches!(action, HunkAction::Accept);
self.update_session_stats(&hunk.line_info, accepted);
// Remove from turn_index
self.remove_from_turn_index(hunk_id, &hunk.source);
match action {
HunkAction::Accept => {
self.accept_hunk(&path, &hunk).await?;
}
HunkAction::Reject => {
self.reject_hunk(&path, &hunk).await?;
}
}
Ok(())
}
/// Accept a single hunk: patch baseline to include this hunk's changes.
async fn accept_hunk(&mut self, path: &Path, hunk: &Arc<Hunk>) -> Result<(), HunkActionError> {
// Collect data and perform mutations, then send events afterward
let (should_recompute, current, _source) = {
let state = self.file_states.get_mut(path);
let Some(state) = state else {
return Ok(());
};
// Handle file creation case (no baseline)
if matches!(state.baseline, FileContentState::Missing) {
// File was created - accepting makes current the baseline for this hunk's lines
// For a newly created file, accept just sets baseline = current
state.baseline = state.current_content.clone();
state.baseline_accepted = true;
state.hunks.retain(|h| h.id != hunk.id);
// Don't recompute for file creation (no other hunks exist)
(false, None, hunk.source)
} else {
// Patch baseline to include ONLY this hunk's changes
if let FileContentState::Full(baseline) = &state.baseline {
let patched = patch_lines(
baseline,
hunk.line_info.old_start,
hunk.line_info.old_count,
&hunk.new_text,
);
state.baseline = FileContentState::Full(patched);
}
state.baseline_accepted = true;
// Remove this hunk from the list
state.hunks.retain(|h| h.id != hunk.id);
// Pass FileContentState directly (R3/MF-4: preserve Binary/TooLarge, no String extraction)
let current = Some(state.current_content.clone());
let source = hunk.source;
(true, current, source)
}
};
// Send events after mutable borrow is released
self.send_event(HunkEvent::HunkRemoved {
path: path.to_path_buf(),
hunk_id: hunk.id.clone(),
reason: HunkRemovalReason::Accepted,
});
self.send_event(HunkEvent::BaselineUpdated {
path: path.to_path_buf(),
});
// Recompute remaining hunks (positions may have shifted)
if should_recompute {
// Use the source of the first remaining hunk if any, else External
let remaining_source = self
.file_states
.get(path)
.and_then(|state| state.hunks.first())
.map(|h| h.source)
.unwrap_or(HunkSource::External);
self.recompute_hunks(path, current, remaining_source);
}
Ok(())
}
/// Reject a single hunk: patch current content to revert this hunk's changes.
async fn reject_hunk(&mut self, path: &Path, hunk: &Arc<Hunk>) -> Result<(), HunkActionError> {
// First, determine what action to take and collect data
enum RejectAction {
RestoreDeleted { baseline: String },
DeleteCreated,
RevertChange { patched: String },
NoOp,
}
let action = {
let state = self.file_states.get(path);
let Some(state) = state else {
return Ok(());
};
if matches!(state.current_content, FileContentState::Missing)
&& matches!(state.baseline, FileContentState::Full(_))
{
// File was deleted, rejecting restores it
if let FileContentState::Full(baseline) = &state.baseline {
RejectAction::RestoreDeleted {
baseline: baseline.clone(),
}
} else {
RejectAction::NoOp
}
} else if matches!(state.baseline, FileContentState::Missing)
&& matches!(state.current_content, FileContentState::Full(_))
{
// File was created, rejecting deletes it
RejectAction::DeleteCreated
} else if let FileContentState::Full(current) = &state.current_content {
// Normal case: patch current content to revert this hunk's changes
let old_text = hunk.old_text.as_deref().unwrap_or("");
let patched = patch_lines(
current,
hunk.line_info.new_start,
hunk.line_info.new_count,
old_text,
);
RejectAction::RevertChange { patched }
} else {
RejectAction::NoOp
}
};
// Perform file I/O based on the action
match &action {
RejectAction::RestoreDeleted { baseline } => {
tokio::fs::write(path, baseline).await.map_err(|e| {
HunkActionError::WriteError {
path: path.to_path_buf(),
source: e,
}
})?;
}
RejectAction::DeleteCreated => {
tokio::fs::remove_file(path)
.await
.map_err(|e| HunkActionError::DeleteError {
path: path.to_path_buf(),
source: e,
})?;
}
RejectAction::RevertChange { patched } => {
tokio::fs::write(path, patched)
.await
.map_err(|e| HunkActionError::WriteError {
path: path.to_path_buf(),
source: e,
})?;
}
RejectAction::NoOp => return Ok(()),
}
// Update state and collect data for recompute
let (should_recompute, current, _source) = {
let state = self.file_states.get_mut(path);
let Some(state) = state else {
return Ok(());
};
match action {
RejectAction::RestoreDeleted { baseline } => {
state.current_content = FileContentState::Full(baseline);
state.hunks.retain(|h| h.id != hunk.id);
(false, None, hunk.source)
}
RejectAction::DeleteCreated => {
state.current_content = FileContentState::Missing;
state.hunks.retain(|h| h.id != hunk.id);
(false, None, hunk.source)
}
RejectAction::RevertChange { patched } => {
let patched_state = FileContentState::Full(patched);
state.current_content = patched_state.clone();
state.hunks.retain(|h| h.id != hunk.id);
(true, Some(patched_state), hunk.source)
}
RejectAction::NoOp => return Ok(()),
}
};
// Send event after mutable borrow is released
self.send_event(HunkEvent::HunkRemoved {
path: path.to_path_buf(),
hunk_id: hunk.id.clone(),
reason: HunkRemovalReason::Rejected,
});
// Recompute remaining hunks
if should_recompute {
// Use the source of the first remaining hunk if any, else External
let remaining_source = self
.file_states
.get(path)
.and_then(|state| state.hunks.first())
.map(|h| h.source)
.unwrap_or(HunkSource::External);
self.recompute_hunks(path, current, remaining_source);
}
Ok(())
}
/// Apply action (accept/reject) to all hunks for a file.
/// Uses batched processing to avoid stale hunk IDs during recomputation.
pub(super) async fn apply_file_action(
&mut self,
path: &Path,
action: HunkAction,
) -> Result<Vec<HunkId>, HunkActionError> {
let Some(state) = self.file_states.get(path) else {
return Ok(vec![]);
};
let hunks_to_process: Vec<Arc<Hunk>> = state.hunks.clone();
if hunks_to_process.is_empty() {
return Ok(vec![]);
}
self.apply_action_batch(&[(path.to_path_buf(), hunks_to_process)], action)
.await
}
/// Apply action (accept/reject) to all hunks.
/// Uses batched processing to avoid stale hunk IDs during recomputation.
pub(super) async fn apply_all_action(
&mut self,
action: HunkAction,
) -> Result<Vec<HunkId>, HunkActionError> {
// Collect all hunks grouped by file
let files_with_hunks: Vec<(PathBuf, Vec<Arc<Hunk>>)> = self
.file_states
.iter()
.filter(|(_, state)| !state.hunks.is_empty())
.map(|(path, state)| (path.clone(), state.hunks.clone()))
.collect();
if files_with_hunks.is_empty() {
return Ok(vec![]);
}
self.apply_action_batch(&files_with_hunks, action).await
}
/// Apply action (accept/reject) to all hunks for a specific turn.
/// Uses batched processing to avoid stale hunk IDs during recomputation.
pub(super) async fn apply_turn_action(
&mut self,
prompt_index: usize,
action: HunkAction,
) -> Result<Vec<HunkId>, HunkActionError> {
// Collect all hunks for this turn, grouped by file
let mut files_with_hunks: std::collections::HashMap<PathBuf, Vec<Arc<Hunk>>> =
std::collections::HashMap::new();
for (path, state) in &self.file_states {
let turn_hunks: Vec<Arc<Hunk>> = state
.hunks
.iter()
.filter(|h| h.source.prompt_index() == Some(prompt_index))
.cloned()
.collect();
if !turn_hunks.is_empty() {
files_with_hunks.insert(path.clone(), turn_hunks);
}
}
if files_with_hunks.is_empty() {
return Ok(vec![]);
}
let files_vec: Vec<_> = files_with_hunks.into_iter().collect();
self.apply_action_batch(&files_vec, action).await
}
/// Internal batched action processor.
/// Processes all hunks for each file atomically, then recomputes once per file.
async fn apply_action_batch(
&mut self,
files_with_hunks: &[(PathBuf, Vec<Arc<Hunk>>)],
action: HunkAction,
) -> Result<Vec<HunkId>, HunkActionError> {
let mut affected_hunk_ids = Vec::new();
for (path, hunks) in files_with_hunks {
// Process all hunks for this file in one go
for hunk in hunks {
// Update session stats
let accepted = matches!(action, HunkAction::Accept);
self.update_session_stats(&hunk.line_info, accepted);
// Remove from turn_index
self.remove_from_turn_index(&hunk.id, &hunk.source);
affected_hunk_ids.push(hunk.id.clone());
}
// Apply all patches for this file at once
match action {
HunkAction::Accept => {
self.accept_hunks_batch(path, hunks).await?;
}
HunkAction::Reject => {
self.reject_hunks_batch(path, hunks).await?;
}
}
}
Ok(affected_hunk_ids)
}
/// Accept multiple hunks for a file atomically.
/// Patches baseline incrementally, then recomputes once.
async fn accept_hunks_batch(
&mut self,
path: &Path,
hunks: &[Arc<Hunk>],
) -> Result<(), HunkActionError> {
if hunks.is_empty() {
return Ok(());
}
let (should_recompute, current) = {
let Some(state) = self.file_states.get_mut(path) else {
return Ok(());
};
// Special case: file creation (no baseline)
if matches!(state.baseline, FileContentState::Missing) {
// Accepting all hunks for a new file just sets baseline = current
state.baseline = state.current_content.clone();
state.baseline_accepted = true;
let hunk_ids: Vec<HunkId> = hunks.iter().map(|h| h.id.clone()).collect();
state.hunks.retain(|h| !hunk_ids.contains(&h.id));
// Send events
for hunk in hunks {
self.send_event(HunkEvent::HunkRemoved {
path: path.to_path_buf(),
hunk_id: hunk.id.clone(),
reason: HunkRemovalReason::Accepted,
});
}
self.send_event(HunkEvent::BaselineUpdated {
path: path.to_path_buf(),
});
return Ok(());
}
// Patch baseline for each hunk (process end-to-start to avoid shifts)
let mut sorted_hunks = hunks.to_vec();
sorted_hunks.sort_by_key(|h| std::cmp::Reverse(h.line_info.old_start));
for hunk in &sorted_hunks {
if let FileContentState::Full(baseline) = &state.baseline {
let patched = patch_lines(
baseline,
hunk.line_info.old_start,
hunk.line_info.old_count,
&hunk.new_text,
);
state.baseline = FileContentState::Full(patched);
}
}
state.baseline_accepted = true;
// Remove all accepted hunks
let hunk_ids: Vec<HunkId> = hunks.iter().map(|h| h.id.clone()).collect();
state.hunks.retain(|h| !hunk_ids.contains(&h.id));
// Pass FileContentState directly (R3/MF-4: preserve Binary/TooLarge)
let current = Some(state.current_content.clone());
(true, current)
};
// Send events
for hunk in hunks {
self.send_event(HunkEvent::HunkRemoved {
path: path.to_path_buf(),
hunk_id: hunk.id.clone(),
reason: HunkRemovalReason::Accepted,
});
}
self.send_event(HunkEvent::BaselineUpdated {
path: path.to_path_buf(),
});
// Recompute remaining hunks once
if should_recompute {
// Use the source of the first remaining hunk if any, else External
let remaining_source = self
.file_states
.get(path)
.and_then(|state| state.hunks.first())
.map(|h| h.source)
.unwrap_or(HunkSource::External);
self.recompute_hunks(path, current, remaining_source);
}
Ok(())
}
/// Reject multiple hunks for a file atomically.
/// Patches current content incrementally, then recomputes once.
async fn reject_hunks_batch(
&mut self,
path: &Path,
hunks: &[Arc<Hunk>],
) -> Result<(), HunkActionError> {
if hunks.is_empty() {
return Ok(());
}
let Some(state) = self.file_states.get(path) else {
return Ok(());
};
if matches!(state.current_content, FileContentState::Missing)
&& matches!(state.baseline, FileContentState::Full(_))
{
// File was deleted - restore it
if let FileContentState::Full(baseline) = &state.baseline {
tokio::fs::write(path, baseline).await.map_err(|e| {
HunkActionError::WriteError {
path: path.to_path_buf(),
source: e,
}
})?;
}
let Some(state) = self.file_states.get_mut(path) else {
return Ok(());
};
state.current_content = state.baseline.clone();
let hunk_ids: Vec<HunkId> = hunks.iter().map(|h| h.id.clone()).collect();
state.hunks.retain(|h| !hunk_ids.contains(&h.id));
for hunk in hunks {
self.send_event(HunkEvent::HunkRemoved {
path: path.to_path_buf(),
hunk_id: hunk.id.clone(),
reason: HunkRemovalReason::Rejected,
});
}
return Ok(());
} else if matches!(state.baseline, FileContentState::Missing)
&& matches!(state.current_content, FileContentState::Full(_))
{
// File was created - delete it
tokio::fs::remove_file(path)
.await
.map_err(|e| HunkActionError::DeleteError {
path: path.to_path_buf(),
source: e,
})?;
let Some(state) = self.file_states.get_mut(path) else {
return Ok(());
};
state.current_content = FileContentState::Missing;
let hunk_ids: Vec<HunkId> = hunks.iter().map(|h| h.id.clone()).collect();
state.hunks.retain(|h| !hunk_ids.contains(&h.id));
for hunk in hunks {
self.send_event(HunkEvent::HunkRemoved {
path: path.to_path_buf(),
hunk_id: hunk.id.clone(),
reason: HunkRemovalReason::Rejected,
});
}
return Ok(());
}
// Normal case: patch current content to revert all hunks.
// The early returns above handle (None, Some) and (Some, None).
// If both are non-Full, there's nothing to patch — bail out.
let Some(state) = self.file_states.get_mut(path) else {
return Ok(());
};
let current_content = match &state.current_content {
FileContentState::Full(s) => s.clone(),
_ => return Ok(()),
};
let mut current = current_content;
// Apply patches in reverse order (from end of file to beginning)
// to avoid line number shifts
let mut sorted_hunks = hunks.to_vec();
sorted_hunks.sort_by_key(|h| std::cmp::Reverse(h.line_info.new_start));
for hunk in &sorted_hunks {
let old_text = hunk.old_text.as_deref().unwrap_or("");
current = patch_lines(
&current,
hunk.line_info.new_start,
hunk.line_info.new_count,
old_text,
);
}
// Write patched content
tokio::fs::write(path, &current)
.await
.map_err(|e| HunkActionError::WriteError {
path: path.to_path_buf(),
source: e,
})?;
let Some(state) = self.file_states.get_mut(path) else {
return Ok(());
};
let current_state = FileContentState::Full(current);
state.current_content = current_state.clone();
let hunk_ids: Vec<HunkId> = hunks.iter().map(|h| h.id.clone()).collect();
state.hunks.retain(|h| !hunk_ids.contains(&h.id));
for hunk in hunks {
self.send_event(HunkEvent::HunkRemoved {
path: path.to_path_buf(),
hunk_id: hunk.id.clone(),
reason: HunkRemovalReason::Rejected,
});
}
// Recompute remaining hunks
let remaining_source = self
.file_states
.get(path)
.and_then(|state| state.hunks.first())
.map(|h| h.source)
.unwrap_or(HunkSource::External);
self.recompute_hunks(path, Some(current_state), remaining_source);
Ok(())
}
}

View file

@ -0,0 +1,330 @@
//! Utilities for safe file reading with binary/UTF-8 detection.
use super::state::{FileContentState, MAX_TRACKED_TEXT_BYTES};
/// Git LFS pointer files start with this exact prefix.
/// See https://github.com/git-lfs/git-lfs/blob/main/docs/spec.md
const LFS_POINTER_PREFIX: &[u8] = b"version https://git-lfs.github.com/spec/v1\n";
/// True when `bytes` is a Git LFS pointer stub.
///
/// LFS pointers are small text files (typically ~130 bytes) with the format:
/// ```text
/// version https://git-lfs.github.com/spec/v1
/// oid sha256:<hex>
/// size <digits>
/// ```
///
/// When the hunk tracker reads the raw git blob for an LFS-tracked file,
/// it gets this pointer text. The working copy, however, holds the real
/// (smudged) content. Detecting and marking LFS pointers prevents phantom
/// diffs that can never be resolved.
pub fn is_lfs_pointer(bytes: &[u8]) -> bool {
// LFS pointers are always small (< 200 bytes typically).
// Quick length check avoids scanning large buffers.
bytes.len() < 1024 && bytes.starts_with(LFS_POINTER_PREFIX)
}
/// Check if content appears to be binary by looking for null bytes.
/// This is the same heuristic git uses.
pub fn is_binary(content: &[u8]) -> bool {
// Check first 8000 bytes for null bytes (git's heuristic)
let check_len = content.len().min(8000);
content[..check_len].contains(&0)
}
/// Classify raw bytes into a FileContentState.
/// - Checks size BEFORE any allocation (bounded read guarantee)
/// - Checks for binary (null bytes in first 8KB) - no allocation needed
/// - Checks for valid UTF-8 only after size check passes
/// - Returns Full(String) only if all checks pass and size is within limit
pub fn classify_bytes(bytes: &[u8]) -> FileContentState {
let byte_len = bytes.len();
// Check size FIRST - before any allocation (MF-1: bounded read guarantee)
if byte_len > MAX_TRACKED_TEXT_BYTES {
return FileContentState::TooLarge { byte_len };
}
// LFS pointer check — operates on slice, no allocation.
// Must come before the Full classification because LFS pointers are
// valid UTF-8 text and would otherwise be returned as Full.
if is_lfs_pointer(bytes) {
return FileContentState::LfsPointer { byte_len };
}
// Binary check - operates on slice, no allocation
if is_binary(bytes) {
return FileContentState::Binary {
byte_len: Some(byte_len),
};
}
// Only now allocate the String (size is within limit)
match String::from_utf8(bytes.to_vec()) {
Ok(s) => FileContentState::Full(s),
Err(_) => FileContentState::Binary {
byte_len: Some(byte_len),
},
}
}
/// Classify a String into FileContentState based on size and binary content.
/// - Checks for NUL bytes (binary content) using the same heuristic as is_binary()
/// - Checks against MAX_TRACKED_TEXT_BYTES size limit
/// - Returns Full(String) only if size is within limit and content is text
pub fn classify_string(s: String) -> FileContentState {
let byte_len = s.len();
// Check size FIRST (matches classify_bytes order)
if byte_len > MAX_TRACKED_TEXT_BYTES {
return FileContentState::TooLarge { byte_len };
}
// LFS pointer check (same prefix test as classify_bytes)
if is_lfs_pointer(s.as_bytes()) {
return FileContentState::LfsPointer { byte_len };
}
// Check for binary content (NUL bytes in first 8KB)
if is_binary(s.as_bytes()) {
return FileContentState::Binary {
byte_len: Some(byte_len),
};
}
FileContentState::Full(s)
}
/// Create a FileContentState for a file that doesn't exist.
pub fn missing_content() -> FileContentState {
FileContentState::Missing
}
/// Read a file and return FileContentState directly, with bounded allocation.
/// - Checks file metadata size BEFORE reading (MF-1: bounded read guarantee)
/// - Detects binary from a small prefix (8KB) without full read
/// - Returns TooLarge/Binary without allocating full content
/// - Only reads full content if within limit and text
pub async fn read_file_bounded(path: &std::path::Path) -> FileContentState {
use tokio::io::AsyncReadExt;
// Use symlink_metadata (lstat) to detect symlinks without following them.
// Symlinks produce phantom diffs: git stores the target path string while
// read() follows the link and returns the target file's content.
let metadata = match tokio::fs::symlink_metadata(path).await {
Ok(m) => m,
Err(_) => return missing_content(),
};
if metadata.is_symlink() {
return FileContentState::Symlink;
}
let byte_len = metadata.len() as usize;
// Check size BEFORE any read (MF-1)
if byte_len > MAX_TRACKED_TEXT_BYTES {
return FileContentState::TooLarge { byte_len };
}
let mut file = match tokio::fs::File::open(path).await {
Ok(f) => f,
Err(_) => return missing_content(),
};
// Read small prefix for binary detection (no full allocation)
let prefix_size = 8000.min(byte_len);
let mut prefix_buf = vec![0u8; prefix_size];
let n = match file.read(&mut prefix_buf).await {
Ok(n) => n,
Err(_) => return missing_content(),
};
prefix_buf.truncate(n);
// LFS pointer check on prefix (no full read needed — pointers are tiny)
if is_lfs_pointer(&prefix_buf) {
return FileContentState::LfsPointer { byte_len };
}
// Binary check on prefix only (no full read needed)
if is_binary(&prefix_buf) {
return FileContentState::Binary {
byte_len: Some(byte_len),
};
}
// Read remainder (total still within limit since we checked size upfront)
let mut full_buf = prefix_buf;
if byte_len > prefix_size && file.read_to_end(&mut full_buf).await.is_err() {
return missing_content();
}
// Convert to String (size already checked)
match String::from_utf8(full_buf) {
Ok(s) => FileContentState::Full(s),
Err(_) => FileContentState::Binary {
byte_len: Some(byte_len),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_binary_with_null_byte() {
let binary = b"hello\x00world";
assert!(is_binary(binary));
}
#[test]
fn test_is_binary_text_file() {
let text = b"hello world\nthis is text\n";
assert!(!is_binary(text));
}
#[test]
fn test_is_binary_empty() {
let empty: &[u8] = b"";
assert!(!is_binary(empty));
}
// === TooLarge / bounded read tests (SF-2) ===
#[test]
fn test_classify_bytes_too_large() {
// Create content larger than MAX_TRACKED_TEXT_BYTES
let large = vec![b'a'; MAX_TRACKED_TEXT_BYTES + 1];
let state = classify_bytes(&large);
assert!(
matches!(state, FileContentState::TooLarge { byte_len } if byte_len == MAX_TRACKED_TEXT_BYTES + 1)
);
}
#[test]
fn test_classify_bytes_too_large_with_null() {
// Large content with null byte - should be TooLarge, not Binary
// (size check happens first, so we short-circuit before binary check)
let large: Vec<u8> = std::iter::repeat_n(b'a', MAX_TRACKED_TEXT_BYTES + 100).collect();
let state = classify_bytes(&large);
assert!(matches!(state, FileContentState::TooLarge { .. }));
}
#[test]
fn test_classify_string_too_large() {
let large = "a".repeat(MAX_TRACKED_TEXT_BYTES + 1);
let state = classify_string(large);
assert!(matches!(state, FileContentState::TooLarge { .. }));
}
#[test]
fn test_classify_string_binary_with_null() {
let binary = "hello\0world".to_string();
let state = classify_string(binary);
assert!(matches!(state, FileContentState::Binary { .. }));
}
#[tokio::test]
async fn test_read_file_bounded_too_large() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("large.txt");
let large = vec![b'a'; MAX_TRACKED_TEXT_BYTES + 1];
std::fs::write(&path, &large).unwrap();
let state = read_file_bounded(&path).await;
assert!(
matches!(state, FileContentState::TooLarge { byte_len } if byte_len == MAX_TRACKED_TEXT_BYTES + 1)
);
}
// === LFS pointer tests ===
#[test]
fn test_is_lfs_pointer_valid() {
let pointer =
b"version https://git-lfs.github.com/spec/v1\noid sha256:abc123\nsize 12345\n";
assert!(is_lfs_pointer(pointer));
}
#[test]
fn test_is_lfs_pointer_prefix_only() {
let pointer = b"version https://git-lfs.github.com/spec/v1\n";
assert!(is_lfs_pointer(pointer));
}
#[test]
fn test_is_lfs_pointer_not_lfs() {
let text = b"hello world\nthis is not an LFS pointer\n";
assert!(!is_lfs_pointer(text));
}
#[test]
fn test_is_lfs_pointer_empty() {
assert!(!is_lfs_pointer(b""));
}
#[test]
fn test_is_lfs_pointer_too_large_rejected() {
// Even if content starts with LFS prefix, files >= 1024 bytes aren't pointers
let mut large = b"version https://git-lfs.github.com/spec/v1\n".to_vec();
large.resize(1024, b'x');
assert!(!is_lfs_pointer(&large));
}
#[test]
fn test_classify_bytes_lfs_pointer() {
let pointer =
b"version https://git-lfs.github.com/spec/v1\noid sha256:abc123\nsize 12345\n";
let state = classify_bytes(pointer);
assert!(
matches!(state, FileContentState::LfsPointer { byte_len } if byte_len == pointer.len())
);
}
#[test]
fn test_classify_string_lfs_pointer() {
let pointer = "version https://git-lfs.github.com/spec/v1\noid sha256:abc123\nsize 12345\n"
.to_string();
let len = pointer.len();
let state = classify_string(pointer);
assert!(matches!(state, FileContentState::LfsPointer { byte_len } if byte_len == len));
}
#[tokio::test]
async fn test_read_file_bounded_lfs_pointer() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("lfs_file.bin");
let pointer =
b"version https://git-lfs.github.com/spec/v1\noid sha256:abc123\nsize 12345\n";
std::fs::write(&path, pointer).unwrap();
let state = read_file_bounded(&path).await;
assert!(
matches!(state, FileContentState::LfsPointer { byte_len } if byte_len == pointer.len())
);
}
#[tokio::test]
async fn test_read_file_bounded_binary_no_full_read() {
// Binary file larger than limit - size check short-circuits first (TooLarge).
// This is correct: no content retained either way, size is primary concern.
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("huge_binary.bin");
let mut data = vec![0xFFu8; MAX_TRACKED_TEXT_BYTES * 10];
data[50] = 0; // null byte in prefix
std::fs::write(&path, &data).unwrap();
let state = read_file_bounded(&path).await;
// Size > limit means TooLarge (bounded read guarantee - no full allocation)
assert!(matches!(state, FileContentState::TooLarge { .. }));
}
#[tokio::test]
async fn test_read_file_bounded_symlink() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("real.txt");
std::fs::write(&target, "hello").unwrap();
let link = dir.path().join("link.txt");
std::os::unix::fs::symlink(&target, &link).unwrap();
let state = read_file_bounded(&link).await;
assert!(matches!(state, FileContentState::Symlink));
}
}

View file

@ -0,0 +1,513 @@
//! Git operations for the HunkTrackerActor.
//!
//! Uses `gix` (pure-Rust) instead of `git2` (libgit2 C bindings) to avoid
//! global lock contention in libiconv on macOS when multiple sessions run
//! parallel git operations.
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use gix::bstr::BString;
use crate::types::TrackingMode;
use super::HunkTrackerActor;
use super::file_utils::{classify_bytes, missing_content};
use super::state::{FileContentState, GitRepoState, RepoSyncState};
/// Open or discover a gix repository depending on cached state.
///
/// When a `ThreadSafeRepository` is already cached (`Discovered`), this calls
/// `.to_thread_local()` which is a cheap `Arc` clone — no config parsing, no
/// HEAD resolution, no filesystem discovery. On first call (`Unknown`), it
/// runs `gix::discover()` and converts the result to a `ThreadSafeRepository`
/// for caching.
///
/// Returns `(repo, prefix, discovered)` where `discovered` is `Some` only when
/// this was the first discovery attempt (so the caller can cache the result).
#[allow(clippy::type_complexity)]
fn open_or_discover(
cached_state: &GitRepoState,
working_dir: &Path,
) -> Option<(
gix::Repository,
PathBuf,
Option<Result<(Arc<gix::ThreadSafeRepository>, PathBuf), ()>>,
)> {
match cached_state {
GitRepoState::Discovered { repo, prefix } => {
let thread_local = repo.to_thread_local();
Some((thread_local, prefix.clone(), None))
}
GitRepoState::Unknown => {
let repo = gix::discover(working_dir).ok()?;
let repo_root = repo.workdir()?.to_path_buf();
// Canonicalize both paths to handle symlinks (e.g., /var -> /private/var on macOS)
let canonical_working_dir =
dunce::canonicalize(working_dir).unwrap_or_else(|_| working_dir.to_path_buf());
let canonical_repo_root = dunce::canonicalize(&repo_root).unwrap_or(repo_root);
let prefix = canonical_working_dir
.strip_prefix(&canonical_repo_root)
.ok()?
.to_path_buf();
// Convert to ThreadSafeRepository for caching, then get a
// thread-local handle for this call.
let sync_repo = Arc::new(repo.into_sync());
let thread_local = sync_repo.to_thread_local();
let discovered = Some(Ok((sync_repo, prefix.clone())));
Some((thread_local, prefix, discovered))
}
GitRepoState::NotARepo => None,
}
}
/// Canonicalize a path, falling back to canonicalizing the parent directory
/// when the file itself doesn't exist (e.g., deleted files on macOS where
/// `/var` -> `/private/var`).
fn canonicalize_or_parent(path: &Path) -> PathBuf {
dunce::canonicalize(path).unwrap_or_else(|_| {
path.parent()
.and_then(|p| dunce::canonicalize(p).ok())
.and_then(|cp| path.file_name().map(|f| cp.join(f)))
.unwrap_or_else(|| path.to_path_buf())
})
}
impl HunkTrackerActor {
/// Refresh git dirty cache and staged cache by querying git status.
/// Uses the combined status iterator to get both index→worktree (dirty)
/// and HEAD→index (staged) changes in a single pass.
/// In AllDirty mode, this also starts tracking all dirty files.
///
/// `scope` limits the scan to the given working-dir-relative paths:
/// pathspecs prune the untracked dirwalk and the index-entry walk and
/// filter the tree-index diff — but that diff still materializes the
/// full HEAD-tree index per call (gix limitation), an O(repo) floor.
/// `None` — and, by gix semantics, an empty list — scans the full
/// worktree, so callers wanting "scan nothing" must skip the call.
pub(super) async fn refresh_git_dirty_cache(&mut self, scope: Option<Vec<PathBuf>>) {
// Early return if we already know this isn't a git repo
if matches!(self.git_repo_state, GitRepoState::NotARepo) {
return;
}
// Clone state needed by the blocking task
let working_dir = self.working_dir.clone();
let cached_state = self.git_repo_state.clone();
// Result includes dirty files, staged files, and optionally newly-discovered repo info
struct GitResult {
dirty_files: HashSet<PathBuf>,
staged_files: HashSet<PathBuf>,
/// If we did discovery, include the result so actor can cache it
discovered: Option<Result<(Arc<gix::ThreadSafeRepository>, PathBuf), ()>>,
}
let task_result = tokio::task::spawn_blocking(move || {
let Some((repo, prefix, discovered)) = open_or_discover(&cached_state, &working_dir)
else {
return GitResult {
dirty_files: HashSet::new(),
staged_files: HashSet::new(),
discovered: if matches!(cached_state, GitRepoState::Unknown) {
Some(Err(()))
} else {
None
},
};
};
// Guard against empty index files — gix-index panics when the file
// is 0 bytes because it tries to slice the trailing hash from an
// empty mmap (integer underflow in the slice range).
let index_path = repo.git_dir().join("index");
if index_path.metadata().map_or(true, |m| m.len() == 0) {
tracing::debug!("index file is empty or missing, skipping git status");
return GitResult {
dirty_files: HashSet::new(),
staged_files: HashSet::new(),
discovered,
};
}
let mut dirty_files = HashSet::new();
let mut staged_files = HashSet::new();
// Cap produce workers: gix-features spawn-EAGAIN aborts under panic=abort.
let status = match repo.status(gix::progress::Discard) {
Ok(s) => xai_gix_status::with_budgeted_thread_limit(s)
.untracked_files(gix::status::UntrackedFiles::Files),
Err(_) => {
// Git status failed - return empty but keep cache
return GitResult {
dirty_files: HashSet::new(),
staged_files: HashSet::new(),
discovered,
};
}
};
// `:(top)` anchors each pathspec to the repo root — without it gix
// prepends a process-cwd-derived prefix (`Repository::prefix`),
// which is unrelated to this actor's working_dir. `literal` stops
// path bytes from being interpreted as globs. `into_bstr` is
// byte-preserving on unix; on Windows it requires UTF-8 (a panic
// there aborts under panic=abort; with unwind it is a JoinError and
// we keep previous caches). Separators must be `/` for gix paths.
let pathspecs: Vec<BString> = scope
.map(|rels| {
rels.iter()
.map(|rel| {
let repo_rel = gix::path::to_unix_separators_on_windows(
gix::path::into_bstr(prefix.join(rel)),
);
let mut spec = BString::from(":(top,literal)");
spec.extend_from_slice(&repo_rel);
spec
})
.collect()
})
.unwrap_or_default();
// Use the combined iterator which yields both:
// - Item::IndexWorktree: index vs worktree changes (dirty/untracked)
// - Item::TreeIndex: HEAD vs index changes (staged)
let iter = match status.into_iter(pathspecs) {
Ok(it) => it,
Err(_) => {
return GitResult {
dirty_files: HashSet::new(),
staged_files: HashSet::new(),
discovered,
};
}
};
for item_result in iter {
let Ok(item) = item_result else {
continue;
};
let path_str = item.location().to_string();
let path = PathBuf::from(&path_str);
// Only include files under our working_dir, and make them relative to it
if let Ok(relative_path) = path.strip_prefix(&prefix) {
let rel = relative_path.to_path_buf();
dirty_files.insert(rel.clone());
// TreeIndex items represent HEAD→index changes (staged)
if matches!(&item, gix::status::Item::TreeIndex(_)) {
staged_files.insert(rel);
}
}
}
GitResult {
dirty_files,
staged_files,
discovered,
}
})
.await;
let Ok(git_result) = task_result else {
// spawn_blocking was cancelled or panicked
return;
};
// Update cached repo state if we did discovery
if let Some(discovery_result) = git_result.discovered {
self.git_repo_state = match discovery_result {
Ok((repo, prefix)) => GitRepoState::Discovered { repo, prefix },
Err(()) => GitRepoState::NotARepo,
};
}
// Collect paths to track (files not yet being tracked)
// dirty_files contains relative paths, but file_states uses absolute paths
let paths_to_track: Vec<PathBuf> = git_result
.dirty_files
.iter()
.filter(|relative_path| {
let abs_path = self.working_dir.join(relative_path);
!self.file_states.contains_key(&abs_path)
})
.cloned()
.collect();
self.git_dirty_cache = git_result.dirty_files;
self.git_staged_cache = git_result.staged_files;
// In AllDirty mode, start tracking all dirty files that aren't already tracked
if self.mode == TrackingMode::AllDirty {
for relative_path in paths_to_track {
// Convert relative path to absolute for handle_file_change
let abs_path = self.working_dir.join(&relative_path);
self.handle_file_change(abs_path).await;
}
}
}
/// Read the current git HEAD OID and index mtime.
pub(super) async fn read_repo_sync_state(&mut self) -> Option<RepoSyncState> {
// Early return if we already know this isn't a git repo
if matches!(self.git_repo_state, GitRepoState::NotARepo) {
return None;
}
let working_dir = self.working_dir.clone();
let cached_state = self.git_repo_state.clone();
struct SyncResult {
sync_state: Option<RepoSyncState>,
discovered: Option<Result<(Arc<gix::ThreadSafeRepository>, PathBuf), ()>>,
}
let task_result = tokio::task::spawn_blocking(move || {
let Some((repo, _prefix, discovered)) = open_or_discover(&cached_state, &working_dir)
else {
return SyncResult {
sync_state: None,
discovered: if matches!(cached_state, GitRepoState::Unknown) {
Some(Err(()))
} else {
None
},
};
};
let head_oid = repo
.head()
.ok()
.and_then(|mut head| head.peel_to_commit().ok())
.map(|commit| commit.id().to_string());
let index_mtime = repo
.git_dir()
.join("index")
.metadata()
.ok()
.and_then(|metadata| metadata.modified().ok());
SyncResult {
sync_state: Some(RepoSyncState {
head_oid,
index_mtime,
}),
discovered,
}
})
.await;
match task_result {
Ok(result) => {
if let Some(discovery_result) = result.discovered {
self.git_repo_state = match discovery_result {
Ok((repo, prefix)) => GitRepoState::Discovered { repo, prefix },
Err(()) => GitRepoState::NotARepo,
};
}
result.sync_state
}
Err(_) => None,
}
}
/// Read baseline content from git HEAD.
///
/// # Arguments
/// * `path` - Absolute path to the file
///
/// Returns FileContentState::Missing if file doesn't exist in HEAD,
/// FileContentState::Binary/TooLarge for non-text or large files,
/// FileContentState::Full for text content within size limits.
pub(super) async fn read_baseline(&mut self, path: &Path) -> FileContentState {
// Early return if we already know this isn't a git repo
if matches!(self.git_repo_state, GitRepoState::NotARepo) {
return missing_content();
}
let working_dir = self.working_dir.clone();
let abs_path = path.to_path_buf();
let cached_state = self.git_repo_state.clone();
// Result includes content and optionally newly-discovered repo info
struct BaselineResult {
content: FileContentState,
/// If we did discovery, include the result so actor can cache it
discovered: Option<Result<(Arc<gix::ThreadSafeRepository>, PathBuf), ()>>,
}
let task_result = tokio::task::spawn_blocking(move || {
// Clone working_dir upfront for later use in path conversion
let working_dir_for_strip = working_dir.clone();
let Some((repo, prefix, discovered)) = open_or_discover(&cached_state, &working_dir)
else {
return BaselineResult {
content: missing_content(),
discovered: if matches!(cached_state, GitRepoState::Unknown) {
Some(Err(()))
} else {
None
},
};
};
// Convert absolute path to working_dir-relative, then to repo-root-relative
// Canonicalize to handle symlinks (e.g., /var -> /private/var on macOS).
let canonical_abs_path = canonicalize_or_parent(&abs_path);
let canonical_working_dir =
dunce::canonicalize(&working_dir_for_strip).unwrap_or(working_dir_for_strip);
let working_dir_relative = match canonical_abs_path.strip_prefix(&canonical_working_dir)
{
Ok(rel) => rel.to_path_buf(),
Err(_) => {
// Path is not under working_dir - shouldn't happen but handle gracefully
return BaselineResult {
content: missing_content(),
discovered,
};
}
};
let repo_relative_path = prefix.join(&working_dir_relative);
let content = (|| {
let head = repo.head().ok()?.peel_to_commit().ok()?;
let tree = head.tree().ok()?;
let entry = tree
.lookup_entry_by_path(repo_relative_path.to_string_lossy().as_ref())
.ok()??;
// Symlinks in git have mode 120000; return Symlink before reading blob.
if entry.mode().is_link() {
return Some(FileContentState::Symlink);
}
let object = entry.object().ok()?;
let blob = object.try_into_blob().ok()?;
// Classify bytes into FileContentState (handles binary, size limits)
Some(classify_bytes(&blob.data))
})();
BaselineResult {
content: content.unwrap_or_else(missing_content),
discovered,
}
})
.await;
match task_result {
Ok(result) => {
// Update cached repo state if we did discovery
if let Some(discovery_result) = result.discovered {
self.git_repo_state = match discovery_result {
Ok((repo, prefix)) => GitRepoState::Discovered { repo, prefix },
Err(()) => GitRepoState::NotARepo,
};
}
result.content
}
Err(_) => missing_content(), // spawn_blocking was cancelled or panicked
}
}
/// Read baseline content from git HEAD for multiple files in a single
/// `spawn_blocking` call. Opens the repo and resolves HEAD once, then
/// looks up every path in the same tree, avoiding the per-file overhead
/// of `read_baseline`.
///
/// Returns a map from absolute path to its baseline content state.
/// FileContentState::Missing for files not in HEAD,
/// FileContentState::Binary/TooLarge for non-text or large files,
/// FileContentState::Full for text content within size limits.
pub(super) async fn read_baselines_batch(
&mut self,
paths: &[PathBuf],
) -> HashMap<PathBuf, FileContentState> {
if paths.is_empty() || matches!(self.git_repo_state, GitRepoState::NotARepo) {
return HashMap::new();
}
let working_dir = self.working_dir.clone();
let cached_state = self.git_repo_state.clone();
let paths_owned: Vec<PathBuf> = paths.to_vec();
struct BatchResult {
baselines: HashMap<PathBuf, FileContentState>,
discovered: Option<Result<(Arc<gix::ThreadSafeRepository>, PathBuf), ()>>,
}
let task_result = tokio::task::spawn_blocking(move || {
let Some((repo, prefix, discovered)) = open_or_discover(&cached_state, &working_dir)
else {
return BatchResult {
baselines: paths_owned
.iter()
.map(|p| (p.clone(), missing_content()))
.collect(),
discovered: if matches!(cached_state, GitRepoState::Unknown) {
Some(Err(()))
} else {
None
},
};
};
let canonical_working_dir = dunce::canonicalize(&working_dir).unwrap_or(working_dir);
// Resolve HEAD tree once for all lookups
let tree = (|| {
let head = repo.head().ok()?.peel_to_commit().ok()?;
head.tree().ok()
})();
let baselines = paths_owned
.iter()
.map(|abs_path| {
let content = tree.as_ref().and_then(|tree| {
let canonical = canonicalize_or_parent(abs_path);
let wd_relative = canonical.strip_prefix(&canonical_working_dir).ok()?;
let repo_relative = prefix.join(wd_relative);
let entry = tree
.lookup_entry_by_path(repo_relative.to_string_lossy().as_ref())
.ok()??;
if entry.mode().is_link() {
return Some(FileContentState::Symlink);
}
let object = entry.object().ok()?;
let blob = object.try_into_blob().ok()?;
Some(classify_bytes(&blob.data))
});
(abs_path.clone(), content.unwrap_or_else(missing_content))
})
.collect();
BatchResult {
baselines,
discovered,
}
})
.await;
match task_result {
Ok(result) => {
if let Some(discovery_result) = result.discovered {
self.git_repo_state = match discovery_result {
Ok((repo, prefix)) => GitRepoState::Discovered { repo, prefix },
Err(()) => GitRepoState::NotARepo,
};
}
result.baselines
}
Err(_) => HashMap::new(),
}
}
}

View file

@ -0,0 +1,222 @@
//! Hunk recomputation and diff event emission for the HunkTrackerActor.
use std::collections::HashSet;
use std::path::Path;
use std::sync::Arc;
use crate::diff::{
compute_hunks, find_matching_old_hunk, hunk_moved, hunks_match_content, hunks_overlap,
};
use crate::events::{HunkEvent, HunkRemovalReason};
use crate::types::{Hunk, HunkId, HunkSource};
use super::HunkTrackerActor;
use super::file_utils::missing_content;
use super::state::FileContentState;
impl HunkTrackerActor {
/// Recompute hunks for a file and emit events.
/// Also updates the turn_index for O(1) prompt_index lookup.
///
/// **Invariant:** `source` must reflect a single edit origin per call.
/// The actor processes commands sequentially (one at a time via
/// `cmd_rx.recv()`), so agent writes (`RecordAgentWrite`) and external
/// edits (`HandleFileChange`) never share the same invocation. All
/// `HunkContentChanged` events emitted from one call therefore share
/// the same `trigger_source`, which is correct.
///
/// Takes `Option<FileContentState>` to preserve explicit Binary/TooLarge states
/// through recomputation.
pub(super) fn recompute_hunks(
&mut self,
path: &Path,
new_current: Option<FileContentState>,
source: HunkSource,
) {
let Some(state) = self.file_states.get_mut(path) else {
return;
};
let old_hunks = std::mem::take(&mut state.hunks);
// Update current_content with the new state (preserves Binary/TooLarge)
state.current_content = new_current.unwrap_or_else(missing_content);
// Compute new hunks from baseline vs current
// Only diff Full vs Full states; clear hunks for non-diffable states
let mut new_hunks = match (&state.baseline, &state.current_content) {
// Both Full - normal diff
(FileContentState::Full(baseline), FileContentState::Full(current)) => {
compute_hunks(path, baseline, current, source)
}
// Baseline Full, current deleted - whole file deleted
(FileContentState::Full(baseline), FileContentState::Missing) => {
if baseline.is_empty() {
vec![]
} else {
vec![Hunk::file_deleted(
path.to_path_buf(),
baseline.clone(),
source,
)]
}
}
// Baseline Full, current TooLarge/Binary/LfsPointer - can't diff, clear hunks
(FileContentState::Full(_), FileContentState::TooLarge { .. })
| (FileContentState::Full(_), FileContentState::Binary { .. })
| (FileContentState::Full(_), FileContentState::LfsPointer { .. }) => {
vec![]
}
// No baseline (Missing), current Full - whole file added
(FileContentState::Missing, FileContentState::Full(current)) => {
if current.is_empty() {
vec![]
} else {
vec![Hunk::file_created(
path.to_path_buf(),
current.clone(),
source,
)]
}
}
// Baseline TooLarge/Binary/LfsPointer, current Full - can't diff baseline, clear hunks
(FileContentState::TooLarge { .. }, FileContentState::Full(_))
| (FileContentState::Binary { .. }, FileContentState::Full(_))
| (FileContentState::LfsPointer { .. }, FileContentState::Full(_)) => {
vec![]
}
// All other combinations - no hunks (both non-Full, or both Missing, etc.)
_ => vec![],
};
// Preserve hunk IDs and sources from matching old hunks
// Track claimed old hunk IDs to prevent duplicates when one old hunk splits into multiple
let mut claimed_old_ids: HashSet<HunkId> = HashSet::new();
for new_hunk in &mut new_hunks {
if let Some(best_match) = find_matching_old_hunk(new_hunk, &old_hunks) {
// Skip if this old hunk was already claimed by another new hunk
if claimed_old_ids.contains(&best_match.id) {
continue; // new_hunk keeps its new ID
}
claimed_old_ids.insert(best_match.id.clone());
// Always preserve hunk ID for continuity
new_hunk.id = best_match.id.clone();
// Source preservation logic:
// - If new edit is from agent: keep new source (latest prompt_index wins)
// - If new edit is external but old was agent: preserve agent attribution
// - Otherwise: keep new source
if new_hunk.source.is_external() && best_match.source.is_agent_edit() {
new_hunk.source = best_match.source;
}
// else: keep new_hunk.source as-is (agent edits always update attribution)
}
}
// Wrap hunks in Arc for cheap cloning
let arc_hunks: Vec<Arc<Hunk>> = new_hunks.iter().cloned().map(Arc::new).collect();
state.hunks = arc_hunks.clone();
// Update turn_index: remove old hunk IDs, add new ones
for old_hunk in &old_hunks {
if let Some(prompt_index) = old_hunk.source.prompt_index()
&& let Some(set) = self.turn_index.get_mut(&prompt_index)
{
set.remove(&old_hunk.id);
}
}
for new_hunk in &arc_hunks {
if let Some(prompt_index) = new_hunk.source.prompt_index() {
self.turn_index
.entry(prompt_index)
.or_default()
.insert(new_hunk.id.clone());
}
}
// Diff old hunks vs new hunks → emit Added/Removed/Moved/ContentChanged events
self.emit_hunk_diff_events(path, &old_hunks, &arc_hunks, source);
}
/// Emit events for the difference between old and new hunks.
///
/// `trigger_source` is the source of the edit that triggered this recomputation
/// (before any source-preservation logic). It is forwarded to `HunkContentChanged`
/// so that LOC tracking can attribute in-place changes to the correct author.
fn emit_hunk_diff_events(
&self,
path: &Path,
old_hunks: &[Arc<Hunk>],
new_hunks: &[Arc<Hunk>],
trigger_source: HunkSource,
) {
// Find removed hunks (in old but no overlap with any new hunk)
// A hunk is removed only if it doesn't overlap with any new hunk
for old_hunk in old_hunks {
let has_overlap = new_hunks
.iter()
.any(|n| hunks_overlap(old_hunk, n) || hunks_match_content(old_hunk, n));
if !has_overlap {
self.send_event(HunkEvent::HunkRemoved {
path: path.to_path_buf(),
hunk_id: old_hunk.id.clone(),
reason: HunkRemovalReason::Superseded,
});
}
}
// Find added and moved hunks
for new_hunk in new_hunks {
// Check for exact content match first
let exact_match = old_hunks.iter().find(|o| hunks_match_content(o, new_hunk));
match exact_match {
Some(old_hunk) if hunk_moved(old_hunk, new_hunk) => {
self.send_event(HunkEvent::HunkMoved {
path: path.to_path_buf(),
hunk_id: old_hunk.id.clone(),
new_line_info: new_hunk.line_info.clone(),
});
}
Some(_) => {
// Same content, same position - no event needed
}
None => {
// No exact match - check if there's any overlap
let has_overlap = old_hunks.iter().any(|o| hunks_overlap(o, new_hunk));
if !has_overlap {
// Truly new hunk with no relation to old hunks
self.send_event(HunkEvent::HunkAdded {
path: path.to_path_buf(),
hunk: new_hunk.clone(),
});
} else {
// Hunk grew/merged/changed in place — ID was already
// preserved in recompute_hunks. Find the matching old
// hunk by ID to get previous line counts for delta
// computation. Fall back to the overlapping hunk if
// no ID match (e.g., hunk split/merge scenarios).
let prev = old_hunks
.iter()
.find(|o| o.id == new_hunk.id)
.or_else(|| old_hunks.iter().find(|o| hunks_overlap(o, new_hunk)));
let (prev_lines_added, prev_lines_removed) = prev
.map(|h| (h.line_info.new_count, h.line_info.old_count))
.unwrap_or((0, 0));
self.send_event(HunkEvent::HunkContentChanged {
path: path.to_path_buf(),
hunk: new_hunk.clone(),
trigger_source,
prev_lines_added,
prev_lines_removed,
});
}
}
}
}
}
}

View file

@ -0,0 +1,604 @@
//! HunkTrackerActor - runs in a dedicated tokio task and owns all state.
//!
//! This module is organized into submodules by responsibility:
//! - `state`: Internal state types (GitRepoState, FileHunkState)
//! - `git`: Git operations (refresh_git_dirty_cache, read_baseline)
//! - `mutations`: File change handlers (record_agent_write, handle_file_change, etc.)
//! - `actions`: Hunk actions (apply_hunk_action, apply_file_action, etc.)
//! - `queries`: Read-only queries (get_all_hunks, get_hunks_for_path, etc.)
//! - `hunks`: Hunk recomputation and diff events
//! - `file_utils`: Safe file reading with binary/UTF-8 detection
mod actions;
mod file_utils;
mod git;
mod hunks;
mod mutations;
mod queries;
pub(crate) mod state;
pub use mutations::{REFRESH_SCAN_LOG_PREFIX, REFRESH_SKIP_LOG_PREFIX};
#[cfg(test)]
mod tests;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use tokio::sync::mpsc;
use tracing::debug;
use crate::commands::HunkTrackerCommand;
use crate::events::HunkEvent;
use crate::handle::HunkTrackerHandle;
use crate::types::{
FileContentEntry, FileContentView, FileHunkStateSnapshot, HunkId, HunkTrackerSnapshot,
HunkTurnDelta, SessionStats, TrackingMode,
};
use state::{FileHunkState, GitRepoState, RepoSyncState};
/// Coalesced action for a single path within a batch.
#[derive(Debug, PartialEq)]
pub(crate) enum CoalescedPathAction {
Changed,
Deleted,
DeletedThenChanged,
}
/// A batch of coalesced commands drained from the channel.
#[derive(Debug)]
pub(crate) struct CoalescedBatch {
pub(crate) file_actions: HashMap<PathBuf, CoalescedPathAction>,
pub(crate) refresh_all: bool,
pub(crate) refresh_dirty: bool,
pub(crate) other_commands: Vec<HunkTrackerCommand>,
pub(crate) command_count: usize,
}
impl CoalescedBatch {
pub(crate) fn new() -> Self {
Self {
file_actions: HashMap::new(),
refresh_all: false,
refresh_dirty: false,
other_commands: Vec::new(),
command_count: 0,
}
}
pub(crate) fn add(&mut self, cmd: HunkTrackerCommand) {
self.command_count += 1;
match cmd {
HunkTrackerCommand::HandleFileChange { path } => {
self.file_actions
.entry(path)
.and_modify(|action| {
if let CoalescedPathAction::Deleted = action {
*action = CoalescedPathAction::DeletedThenChanged;
}
})
.or_insert(CoalescedPathAction::Changed);
}
HunkTrackerCommand::HandleFileDeleted { path } => {
self.file_actions
.entry(path)
.and_modify(|action| *action = CoalescedPathAction::Deleted)
.or_insert(CoalescedPathAction::Deleted);
}
HunkTrackerCommand::RefreshAllBaselines => {
self.refresh_all = true;
}
HunkTrackerCommand::RefreshGitDirtyCache => {
self.refresh_dirty = true;
}
_ => {
self.other_commands.push(cmd);
}
}
}
}
/// The actor that owns all hunk tracking state.
/// Runs in a dedicated tokio task and processes commands sequentially.
pub struct HunkTrackerActor {
/// Session ID for this hunk tracker instance
#[allow(dead_code)]
session_id: String,
/// Working directory (repo root)
working_dir: PathBuf,
/// Unified map for all tracked files.
/// Key: absolute path
/// Value: file state including is_agent_file flag
file_states: HashMap<PathBuf, FileHunkState>,
/// Secondary index: prompt_index -> set of hunk IDs for that turn.
/// Enables O(1) lookup for `get_hunks_for_turn`.
turn_index: HashMap<usize, HashSet<HunkId>>,
/// Cached set of git dirty file paths (refreshed periodically).
/// Repo-wide in AllDirty; in AgentOnly the refresh scan is scoped to
/// tracked paths, so only their state is cached.
git_dirty_cache: HashSet<PathBuf>,
/// Cached set of git staged file paths (HEAD→index changes, refreshed
/// with — and scoped like — the dirty cache).
git_staged_cache: HashSet<PathBuf>,
/// Cached git repository discovery state
git_repo_state: GitRepoState,
/// Cached git HEAD/index state for baseline refreshes
repo_sync_state: RepoSyncState,
/// Channel to receive commands
cmd_rx: mpsc::UnboundedReceiver<HunkTrackerCommand>,
/// Channel to send hunk events to clients
event_tx: mpsc::UnboundedSender<HunkEvent>,
/// Current tracking mode
mode: TrackingMode,
/// Session-level stats for accepted/rejected hunks.
/// Reset when all baselines are reset (e.g., after commit).
session_stats: SessionStats,
// Cancellation token which can cancel the ongoing loop
cancellation_token: tokio_util::sync::CancellationToken,
}
impl HunkTrackerActor {
/// Send an event to subscribers, logging if the channel is closed.
fn send_event(&self, event: HunkEvent) {
if self.event_tx.send(event).is_err() {
debug!("Event channel closed, event dropped");
}
}
/// Construct an actor with its initial state. The single construction
/// site shared by [`spawn`](Self::spawn) and tests, so tests exercise
/// exactly the state production starts from.
fn new(
session_id: String,
working_dir: PathBuf,
cmd_rx: mpsc::UnboundedReceiver<HunkTrackerCommand>,
event_tx: mpsc::UnboundedSender<HunkEvent>,
mode: TrackingMode,
cancellation_token: tokio_util::sync::CancellationToken,
) -> Self {
HunkTrackerActor {
session_id,
working_dir,
file_states: HashMap::new(),
turn_index: HashMap::new(),
git_dirty_cache: HashSet::new(),
git_staged_cache: HashSet::new(),
git_repo_state: GitRepoState::Unknown,
repo_sync_state: RepoSyncState::default(),
cmd_rx,
event_tx,
mode,
session_stats: SessionStats::default(),
cancellation_token,
}
}
/// Spawn the actor and return a handle to communicate with it.
///
/// If `mode` is `AllDirty`, the actor automatically loads all uncommitted
/// git changes at startup.
pub fn spawn(
session_id: String,
working_dir: PathBuf,
event_tx: mpsc::UnboundedSender<HunkEvent>,
mode: TrackingMode,
cancellation_token: tokio_util::sync::CancellationToken,
) -> HunkTrackerHandle {
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
let actor = Self::new(
session_id,
working_dir,
cmd_rx,
event_tx,
mode,
cancellation_token,
);
// Spawn the actor task
tokio::spawn(actor.run());
HunkTrackerHandle::new(cmd_tx)
}
/// Returns true if a command can be coalesced into a batch.
pub(crate) fn is_coalescable(cmd: &HunkTrackerCommand) -> bool {
matches!(
cmd,
HunkTrackerCommand::HandleFileChange { .. }
| HunkTrackerCommand::HandleFileDeleted { .. }
| HunkTrackerCommand::RefreshAllBaselines
| HunkTrackerCommand::RefreshGitDirtyCache
)
}
/// Drain all queued commands from the channel and coalesce them with `first`.
fn drain_and_coalesce(&mut self, first: HunkTrackerCommand) -> CoalescedBatch {
let mut batch = CoalescedBatch::new();
batch.add(first);
while let Ok(cmd) = self.cmd_rx.try_recv() {
batch.add(cmd);
}
batch
}
/// Process a coalesced batch of commands. Returns false if actor should shut down.
async fn handle_coalesced_batch(&mut self, batch: CoalescedBatch) -> bool {
let file_count = batch.file_actions.len();
let other_count = batch.other_commands.len();
let action_count =
file_count + batch.refresh_all as usize + batch.refresh_dirty as usize + other_count;
if batch.command_count > 1 {
debug!(
commands = batch.command_count,
actions = action_count,
files = file_count,
refresh_all = batch.refresh_all,
refresh_dirty = batch.refresh_dirty,
other = other_count,
"coalesced batch",
);
}
if batch.refresh_dirty && !batch.refresh_all {
self.refresh_git_dirty_cache(None).await;
}
if batch.refresh_all {
let mut already_processed = std::collections::HashSet::new();
for (path, action) in batch.file_actions {
match action {
CoalescedPathAction::Changed | CoalescedPathAction::DeletedThenChanged => {
if !self.file_states.contains_key(&path) {
self.handle_file_change(path.clone()).await;
already_processed.insert(path);
}
}
CoalescedPathAction::Deleted => {
if self.file_states.contains_key(&path) {
self.handle_file_deleted(path).await;
}
}
}
}
self.refresh_all_baselines_except(&already_processed).await;
} else {
let mut changed_paths = Vec::new();
for (path, action) in batch.file_actions {
match action {
CoalescedPathAction::Changed | CoalescedPathAction::DeletedThenChanged => {
changed_paths.push(path);
}
CoalescedPathAction::Deleted => {
self.handle_file_deleted(path).await;
}
}
}
self.handle_file_changes_batch(changed_paths).await;
}
for cmd in batch.other_commands {
if !self.handle_command(cmd).await {
return false;
}
}
true
}
/// Main actor loop - processes commands until shutdown or cancellation
async fn run(mut self) {
// If AllDirty mode, load all uncommitted git changes at startup
if self.mode == TrackingMode::AllDirty {
self.refresh_git_dirty_cache(None).await;
}
loop {
tokio::select! {
biased;
_ = self.cancellation_token.cancelled() => {
break;
}
cmd = self.cmd_rx.recv() => {
let Some(cmd) = cmd else {
break;
};
if Self::is_coalescable(&cmd) {
let batch = self.drain_and_coalesce(cmd);
if !self.handle_coalesced_batch(batch).await {
break;
}
} else if !self.handle_command(cmd).await {
break;
}
}
}
}
}
/// Handle a command. Returns false if actor should shut down.
async fn handle_command(&mut self, cmd: HunkTrackerCommand) -> bool {
debug!("HunkTrackerAction:: {:?}", cmd);
match cmd {
HunkTrackerCommand::RecordAgentWrite {
path,
content,
prompt_index,
previous_content,
} => {
self.record_agent_write(path, content, prompt_index, previous_content)
.await;
}
HunkTrackerCommand::HandleFileChange { path } => {
self.handle_file_change(path).await;
}
HunkTrackerCommand::HandleFileDeleted { path } => {
self.handle_file_deleted(path).await;
}
HunkTrackerCommand::RefreshGitDirtyCache => {
// Public command contract: always a full-worktree scan.
self.refresh_git_dirty_cache(None).await;
}
HunkTrackerCommand::ResetBaseline { path } => {
self.reset_baseline(&path);
}
HunkTrackerCommand::SetMode { mode } => {
self.set_mode(mode).await;
}
HunkTrackerCommand::HunkAction {
hunk_id,
action,
reply,
} => {
let result = self.apply_hunk_action(&hunk_id, action).await;
if reply.send(result).is_err() {
debug!("HunkAction reply channel dropped");
}
}
HunkTrackerCommand::FileAction {
path,
action,
reply,
} => {
let affected = self.apply_file_action(&path, action).await;
if reply.send(affected).is_err() {
debug!("FileAction reply channel dropped");
}
}
HunkTrackerCommand::AllAction { action, reply } => {
let affected = self.apply_all_action(action).await;
if reply.send(affected).is_err() {
debug!("AllAction reply channel dropped");
}
}
HunkTrackerCommand::TurnAction {
prompt_index,
action,
reply,
} => {
let affected = self.apply_turn_action(prompt_index, action).await;
if reply.send(affected).is_err() {
debug!("TurnAction reply channel dropped");
}
}
HunkTrackerCommand::GetAllHunks { reply } => {
let hunks = self.get_all_hunks();
if reply.send(hunks).is_err() {
debug!("GetAllHunks reply channel dropped");
}
}
HunkTrackerCommand::GetHunksForPath { path, reply } => {
let hunks = self.get_hunks_for_path(&path);
if reply.send(hunks).is_err() {
debug!("GetHunksForPath reply channel dropped");
}
}
HunkTrackerCommand::GetFileHunkData { path, reply } => {
let data = self.get_file_hunk_data(&path);
if reply.send(data).is_err() {
debug!("GetFileHunkData reply channel dropped");
}
}
HunkTrackerCommand::GetHunksBySource { source, reply } => {
let hunks = self.get_hunks_by_source(source);
if reply.send(hunks).is_err() {
debug!("GetHunksBySource reply channel dropped");
}
}
HunkTrackerCommand::GetHunk { hunk_id, reply } => {
let hunk = self.get_hunk(&hunk_id);
if reply.send(hunk).is_err() {
debug!("GetHunk reply channel dropped");
}
}
HunkTrackerCommand::IsAgentFile { path, reply } => {
let is_agent = self
.file_states
.get(&path)
.map(|s| s.is_agent_file)
.unwrap_or(false);
if reply.send(is_agent).is_err() {
debug!("IsAgentFile reply channel dropped");
}
}
HunkTrackerCommand::GetAllTrackedPaths { reply } => {
let paths = self.get_all_tracked_paths();
if reply.send(paths).is_err() {
debug!("GetAllTrackedPaths reply channel dropped");
}
}
HunkTrackerCommand::GetStagedFiles { reply } => {
let staged: HashSet<PathBuf> = self
.git_staged_cache
.iter()
.map(|rel| self.working_dir.join(rel))
.collect();
if reply.send(staged).is_err() {
debug!("GetStagedFiles reply channel dropped");
}
}
HunkTrackerCommand::GetAllFileContents { reply } => {
let entries: Vec<FileContentEntry> = self
.file_states
.iter()
.map(|(abs_path, state)| {
let rel = abs_path.strip_prefix(&self.working_dir).unwrap_or(abs_path);
let staged = self.git_staged_cache.contains(rel);
FileContentEntry {
path: abs_path.clone(),
baseline: FileContentView::from_content_state(&state.baseline),
current: FileContentView::from_content_state(&state.current_content),
is_agent_file: state.is_agent_file,
staged,
}
})
.collect();
if reply.send(entries).is_err() {
debug!("GetAllFileContents reply channel dropped");
}
}
HunkTrackerCommand::GetSessionSummary { reply } => {
let summary = self.compute_session_summary();
if reply.send(summary).is_err() {
debug!("GetSessionSummary reply channel dropped");
}
}
HunkTrackerCommand::GetTurnHunks {
prompt_index,
reply,
} => {
let hunks = self.get_hunks_for_turn(prompt_index);
if reply.send(hunks).is_err() {
debug!("GetTurnHunks reply channel dropped");
}
}
HunkTrackerCommand::ResetStats => {
self.session_stats = SessionStats::default();
}
HunkTrackerCommand::RefreshAllBaselines => {
self.refresh_all_baselines().await;
}
HunkTrackerCommand::SnapshotState { reply } => {
let snapshot = self.take_snapshot();
if reply.send(snapshot).is_err() {
debug!("SnapshotState reply channel dropped");
}
}
HunkTrackerCommand::SnapshotTurnDelta {
prompt_index,
reply,
} => {
let delta = self.take_turn_delta(prompt_index);
if reply.send(delta).is_err() {
debug!("SnapshotTurnDelta reply channel dropped");
}
}
HunkTrackerCommand::RestoreState(snapshot) => {
self.restore_snapshot(snapshot);
}
}
true
}
/// Snapshot one file's state (full FileContentState incl. Binary/TooLarge).
/// Shared by [`take_snapshot`](Self::take_snapshot) and
/// [`take_turn_delta`](Self::take_turn_delta) so they can't diverge.
fn snapshot_file_state(state: &FileHunkState) -> FileHunkStateSnapshot {
FileHunkStateSnapshot {
baseline: state.baseline.clone(),
current_content: state.current_content.clone(),
hunks: state.hunks.iter().map(|h| (**h).clone()).collect(),
is_agent_file: state.is_agent_file,
baseline_accepted: state.baseline_accepted,
}
}
/// Take a snapshot of all hunk tracker state for preservation across
/// session kill/reload cycles.
/// Preserves the full FileContentState (including Binary/TooLarge) for correctness
/// in fork and cross-session sync flows.
fn take_snapshot(&self) -> HunkTrackerSnapshot {
let file_states = self
.file_states
.iter()
.map(|(path, state)| (path.clone(), Self::snapshot_file_state(state)))
.collect();
HunkTrackerSnapshot {
file_states,
turn_index: self.turn_index.clone(),
session_stats: self.session_stats.clone(),
}
}
/// Incremental single-turn delta for `prompt_index`: snapshots of the files
/// owning this turn's hunks plus the hunk-id set. Unlike
/// [`take_snapshot`](Self::take_snapshot), never copies the whole tracker.
fn take_turn_delta(&self, prompt_index: usize) -> HunkTurnDelta {
let hunk_ids = self
.turn_index
.get(&prompt_index)
.cloned()
.unwrap_or_default();
let file_states = self
.file_states
.iter()
.filter(|(_, state)| state.hunks.iter().any(|h| hunk_ids.contains(&h.id)))
.map(|(path, state)| (path.clone(), Self::snapshot_file_state(state)))
.collect();
HunkTurnDelta {
prompt_index,
file_states,
hunk_ids,
}
}
/// Restore a previously snapshotted state, replacing all current file
/// states, turn index, and session stats.
/// Preserves the full FileContentState (including Binary/TooLarge).
fn restore_snapshot(&mut self, snapshot: HunkTrackerSnapshot) {
self.file_states = snapshot
.file_states
.into_iter()
.map(|(path, snap)| {
// Preserve full FileContentState as-is
let state = FileHunkState {
baseline: snap.baseline,
current_content: snap.current_content,
hunks: snap.hunks.into_iter().map(std::sync::Arc::new).collect(),
is_agent_file: snap.is_agent_file,
baseline_accepted: snap.baseline_accepted,
};
(path, state)
})
.collect();
self.turn_index = snapshot.turn_index;
self.session_stats = snapshot.session_stats;
// TODO: Re-emit HunkEvent::FileAdded / HunkEvent::HunkAdded for
// all restored files and hunks so that connected clients (TUI, VSCode
// extension) see the restored state without requiring a manual refresh.
// Alternative: emit a single HunkEvent::StateRestored { file_count }
// that clients use as a signal to do a full refresh.
debug!(
files = self.file_states.len(),
turns = self.turn_index.len(),
"Hunk tracker state restored from snapshot"
);
}
}

View file

@ -0,0 +1,719 @@
//! Mutation commands for the HunkTrackerActor.
//!
//! These methods handle file changes and state mutations.
//!
//! ## Path Convention
//!
//! All paths in the hunk tracker are stored as **absolute paths**. This provides:
//! - Unambiguous file identification
//! - No need for working_dir context when processing paths
//! - Simpler path handling across the codebase
//!
//! Callers should pass absolute paths to `record_agent_write`, `handle_file_change`,
//! and `handle_file_deleted`.
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use tracing::debug;
use crate::events::{HunkEvent, HunkRemovalReason};
use crate::types::{HunkSource, TrackingMode};
use super::HunkTrackerActor;
use super::file_utils::{classify_string, missing_content, read_file_bounded};
use super::state::{FileContentState, FileHunkState};
/// Log-line prefix emitted only when [`HunkTrackerActor::refresh_all_baselines`]
/// runs a real scan. Test scan counters match on it; keep it the single source
/// of truth for the string.
pub const REFRESH_SCAN_LOG_PREFIX: &str = "refresh_all_baselines: completed in";
/// Log-line prefix for the unchanged-git-state skip path of
/// [`HunkTrackerActor::refresh_all_baselines`] (no scan ran).
pub const REFRESH_SKIP_LOG_PREFIX: &str = "refresh_all_baselines: git state unchanged";
/// Strip a single trailing newline (`\r\n` or `\n`) for equality comparison.
///
/// Git-stored content typically has exactly one trailing newline appended.
/// We strip only one to avoid falsely treating files with meaningful trailing
/// whitespace as clean. Bare `\r` (classic Mac) is intentionally out of scope.
fn strip_single_trailing_newline(content: &str) -> &str {
content
.strip_suffix("\r\n")
.or_else(|| content.strip_suffix('\n'))
.unwrap_or(content)
}
impl HunkTrackerActor {
/// Record that an agent tool wrote to a file.
///
/// # Arguments
/// * `path` - Absolute path to the file
/// * `content` - New file content
/// * `prompt_index` - The prompt/turn index when this write occurred
/// * `previous_content` - Content of the file before this write (if known).
/// Used as a fallback baseline when the file doesn't exist in git HEAD
/// (e.g., in worktrees created from dirty state where uncommitted files
/// were copied but aren't tracked by git).
pub(super) async fn record_agent_write(
&mut self,
path: PathBuf,
content: String,
prompt_index: usize,
previous_content: Option<String>,
) {
let source = HunkSource::AgentEdit { prompt_index };
// Classify current content into FileContentState (single classification, cloned for file_states)
let current_state = classify_string(content.clone());
let current_state_for_hunks = current_state.clone(); // Used by recompute_hunks below
// Binary or TooLarge content: still track as an agent file (so
// `get_all_tracked_paths` reports it for worktree replication)
// but skip hunk computation — we don't diff these.
if !current_state.is_diffable() {
if !self.file_states.contains_key(&path) {
// For new files, establish baseline from git or previous_content
// Preserve previous_content when supplied (don't throw away available baseline)
let baseline = if let Some(prev) = previous_content {
classify_string(prev)
} else {
// Try git baseline, but don't block on it for large/binary writes
self.read_baseline(&path).await
};
self.file_states.insert(
path.clone(),
FileHunkState {
baseline,
current_content: current_state,
hunks: vec![],
is_agent_file: true,
baseline_accepted: false,
},
);
self.send_event(HunkEvent::FileAdded {
path,
is_agent_file: true,
});
} else if let Some(state) = self.file_states.get_mut(&path) {
state.is_agent_file = true;
state.current_content = current_state;
// Clear hunks since content is not diffable
state.hunks.clear();
}
return;
}
// Ensure file is tracked as an agent file
let is_new_file = !self.file_states.contains_key(&path);
if is_new_file {
// First time seeing this file - establish baseline
// For agent writes, path is already absolute
let baseline = match self.read_baseline(&path).await {
FileContentState::Missing => {
// File doesn't exist in git HEAD
// Use previous_content as fallback if available
previous_content
.map(classify_string)
.unwrap_or(missing_content())
}
other => other,
};
self.file_states.insert(
path.clone(),
FileHunkState {
baseline,
current_content: current_state,
hunks: vec![],
is_agent_file: true,
baseline_accepted: false,
},
);
// Emit FileAdded event
self.send_event(HunkEvent::FileAdded {
path: path.clone(),
is_agent_file: true,
});
} else {
// Mark as agent file if not already
if let Some(state) = self.file_states.get_mut(&path) {
state.is_agent_file = true;
}
}
// Recompute hunks with pre-classified content (no re-classification in recompute_hunks)
self.recompute_hunks(&path, Some(current_state_for_hunks), source);
}
/// Handle a file change notification from fs_notify.
pub(super) async fn handle_file_change(&mut self, path: PathBuf) {
self.process_file_change(path, None).await;
}
/// Handle multiple file change notifications as a batch.
///
/// Reads all needed baselines in a single call instead of per-file.
pub(super) async fn handle_file_changes_batch(&mut self, paths: Vec<PathBuf>) {
if paths.is_empty() {
return;
}
// Collect paths that need baseline reads:
// - Untracked paths (need initial baseline)
// - Tracked with baseline_accepted (need fresh baseline to detect restoration)
let baseline_paths: Vec<PathBuf> = paths
.iter()
.filter(|p| {
let tracked = self.file_states.contains_key(*p);
if self.mode == TrackingMode::AgentOnly && !tracked {
return false;
}
!tracked
|| self
.file_states
.get(*p)
.is_some_and(|s| s.baseline_accepted)
})
.cloned()
.collect();
let mut baselines = if baseline_paths.is_empty() {
HashMap::new()
} else {
self.read_baselines_batch(&baseline_paths).await
};
for path in paths {
let baseline = baselines.remove(&path);
self.process_file_change(path, baseline).await;
}
}
/// Shared implementation for processing a single file change.
///
/// When `preloaded_baseline` is `Some`, uses the provided baseline.
/// When `None`, reads the baseline from git on demand.
async fn process_file_change(
&mut self,
path: PathBuf,
mut preloaded_baseline: Option<FileContentState>,
) {
let is_tracked = self.file_states.contains_key(&path);
if self.mode == TrackingMode::AgentOnly && !is_tracked {
return;
}
let current_state = read_file_bounded(&path).await;
if !current_state.is_diffable() && !is_tracked {
let baseline = if self.mode == TrackingMode::AllDirty {
match preloaded_baseline.take() {
Some(b) => b,
None => self.read_baseline(&path).await,
}
} else {
missing_content()
};
// No git baseline + not in dirty cache, gitignored; skip.
// But allow directory paths through; they are legitimate fsnotify
// entries used to discover files inside new directories
// (inotify recursive-watch race recovery).
if self.mode == TrackingMode::AllDirty
&& matches!(baseline, FileContentState::Missing)
&& !path.is_dir()
{
let rel = path.strip_prefix(&self.working_dir).unwrap_or(&path);
if !self.git_dirty_cache.contains(rel) {
return;
}
}
let has_diffable_baseline = baseline.is_diffable();
self.file_states.insert(
path.clone(),
FileHunkState {
baseline,
current_content: current_state,
hunks: vec![],
is_agent_file: false,
baseline_accepted: false,
},
);
self.send_event(HunkEvent::FileAdded {
path: path.clone(),
is_agent_file: false,
});
// If the baseline exists in HEAD but the file is missing/non-diffable
// on disk, compute a deletion hunk (e.g., staged deletion after soft reset).
if has_diffable_baseline {
self.recompute_hunks(&path, None, HunkSource::External);
}
return;
}
if !is_tracked {
let baseline = match preloaded_baseline.take() {
Some(b) => b,
None => self.read_baseline(&path).await,
};
// No git baseline + not in dirty cache → gitignored; skip.
// But allow directory paths through (see comment above).
if self.mode == TrackingMode::AllDirty
&& matches!(baseline, FileContentState::Missing)
&& !path.is_dir()
{
let rel = path.strip_prefix(&self.working_dir).unwrap_or(&path);
if !self.git_dirty_cache.contains(rel) {
return;
}
}
self.file_states.insert(
path.clone(),
FileHunkState {
baseline,
current_content: missing_content(), // Will be set by recompute_hunks
hunks: vec![],
is_agent_file: false,
baseline_accepted: false,
},
);
self.send_event(HunkEvent::FileAdded {
path: path.clone(),
is_agent_file: false,
});
} else if self
.file_states
.get(&path)
.is_some_and(|s| s.baseline_accepted)
{
// Existing file with accepted baseline — check if content was
// restored to git HEAD (e.g., `git restore .`). If so, reset
// baseline so the file appears clean.
let git_head_state = match preloaded_baseline.take() {
Some(b) => b,
None => self.read_baseline(&path).await,
};
let content_matches_head = match (&current_state, &git_head_state) {
(FileContentState::Full(current), FileContentState::Full(head)) => {
strip_single_trailing_newline(current) == strip_single_trailing_newline(head)
}
(FileContentState::Missing, FileContentState::Missing) => true,
(FileContentState::Binary { .. }, FileContentState::Binary { .. }) => true,
(FileContentState::TooLarge { .. }, FileContentState::TooLarge { .. }) => true,
(FileContentState::LfsPointer { .. }, FileContentState::LfsPointer { .. }) => true,
(FileContentState::Symlink, FileContentState::Symlink) => true,
// Symlink on disk vs Full(target) in HEAD (or vice versa):
// git stores symlinks as plain text blobs, so the types
// differ even when the file is unchanged. Consult dirty cache.
(FileContentState::Symlink, FileContentState::Full(_))
| (FileContentState::Full(_), FileContentState::Symlink) => {
let rel = path.strip_prefix(&self.working_dir).unwrap_or(&path);
!self.git_dirty_cache.contains(rel)
}
_ => false,
};
if content_matches_head {
if let Some(state) = self.file_states.get_mut(&path) {
state.baseline = git_head_state.clone();
state.current_content = git_head_state;
state.baseline_accepted = false;
state.hunks.clear();
}
return;
}
}
let source = if self.file_states.get(&path).is_some_and(|s| s.is_agent_file) {
HunkSource::ExternalEditOnAgentFile
} else {
HunkSource::External
};
self.recompute_hunks(&path, Some(current_state), source);
}
/// Handle a file deletion notification from fs_notify.
///
/// Some git operations (e.g., `git restore .`) emit Remove events for
/// files that are immediately re-created with different content. To
/// avoid treating these as true deletions, we check whether the file
/// still exists on disk before marking it as deleted.
///
/// # Arguments
/// * `path` - Absolute path to the deleted file
pub(super) async fn handle_file_deleted(&mut self, path: PathBuf) {
if !self.file_states.contains_key(&path) {
// File not tracked by hunk tracker.
// In AgentOnly mode, skip untracked files (same as handle_file_change).
if self.mode == TrackingMode::AgentOnly {
return;
}
// If the file still exists on disk, this is not a real deletion.
if path.exists() {
return;
}
// Check if the file exists in git HEAD. If so, this is a meaningful
// deletion that should produce a deletion hunk (e.g., user ran
// `rm foo.txt` on a committed file).
let baseline = self.read_baseline(&path).await;
if matches!(baseline, FileContentState::Missing) {
return; // Not in HEAD either, nothing to track
}
// Seed file_states with baseline and Missing current content.
self.file_states.insert(
path.clone(),
FileHunkState {
baseline,
current_content: missing_content(),
hunks: vec![],
is_agent_file: false,
baseline_accepted: false,
},
);
self.send_event(HunkEvent::FileAdded {
path: path.clone(),
is_agent_file: false,
});
// Recompute hunks: (Full baseline, Missing current) -> file_deleted hunk
self.recompute_hunks(&path, None, HunkSource::External);
return;
}
// File IS tracked — existing logic below.
// If the file still exists on disk, this was a replace (e.g.,
// `git restore`), not a true deletion. Delegate to
// handle_file_change which handles baseline refresh correctly.
if path.exists() {
self.handle_file_change(path).await;
return;
}
let source = if self
.file_states
.get(&path)
.map(|s| s.is_agent_file)
.unwrap_or(false)
{
// User deleted a file the agent has touched
HunkSource::ExternalEditOnAgentFile
} else {
HunkSource::External
};
// Set current_content to Missing (deleted) and recompute hunks.
// Don't remove from file_states — the baseline still exists in HEAD.
self.recompute_hunks(&path, None, source);
}
/// Reset baseline for a file (typically after commit).
pub(super) fn reset_baseline(&mut self, path: &Path) {
if let Some(state) = self.file_states.get_mut(path) {
// Update baseline to current content
state.baseline = state.current_content.clone();
state.baseline_accepted = false;
// Clear hunks since baseline == current
let old_hunks = std::mem::take(&mut state.hunks);
// Remove from turn_index and emit removed events for all hunks
for hunk in old_hunks {
if let Some(prompt_index) = hunk.source.prompt_index()
&& let Some(set) = self.turn_index.get_mut(&prompt_index)
{
set.remove(&hunk.id);
}
self.send_event(HunkEvent::HunkRemoved {
path: path.to_path_buf(),
hunk_id: hunk.id.clone(),
reason: HunkRemovalReason::Superseded,
});
}
self.send_event(HunkEvent::BaselineUpdated {
path: path.to_path_buf(),
});
}
}
/// Refresh all baselines from the current git HEAD and re-read current
/// content from disk for every tracked file.
///
/// This is called after a git HEAD/index change to reconcile stale
/// state. For each tracked file:
/// - Re-read baseline from the new HEAD
/// - Re-read current content from disk
/// - Recompute hunks
/// - Drop files that are now clean (baseline == current, not agent files)
pub(super) async fn refresh_all_baselines(&mut self) {
self.refresh_all_baselines_except(&HashSet::new()).await;
}
/// Same as `refresh_all_baselines` but skips paths in `skip`. Returns
/// immediately when AgentOnly has nothing tracked (no per-file work to do).
pub(super) async fn refresh_all_baselines_except(&mut self, skip: &HashSet<PathBuf>) {
// AgentOnly with nothing tracked has no work: it never auto-discovers,
// and the dirty/staged caches are read only for tracked files. Skipping
// avoids a full-worktree gix scan per git change. AllDirty must still
// scan — that is how it discovers newly-dirty files.
if self.mode == TrackingMode::AgentOnly && self.file_states.is_empty() {
return;
}
let start = std::time::Instant::now();
if let Some(repo_sync_state) = self.read_repo_sync_state().await {
if repo_sync_state == self.repo_sync_state {
debug!("{REFRESH_SKIP_LOG_PREFIX}, skipping refresh");
return;
}
self.repo_sync_state = repo_sync_state;
}
// Refresh git dirty/staged caches BEFORE the main loop so the
// is_clean check can consult them for non-diffable files (LFS,
// binary, tooLarge). In AllDirty mode this also picks up newly
// dirty files on the new branch, so it must scan the full worktree.
// AgentOnly never auto-discovers and only consults the caches for
// tracked paths, so the scan is scoped to them. Paths that can't be
// made working-dir-relative are dropped: the caches are keyed
// working-dir-relative, so such paths could never match a cache
// entry anyway.
let scope: Option<Vec<PathBuf>> = match self.mode {
TrackingMode::AllDirty => None,
TrackingMode::AgentOnly => Some(
self.file_states
.keys()
.filter_map(|p| p.strip_prefix(&self.working_dir).ok())
.map(Path::to_path_buf)
.collect(),
),
};
match scope {
// Every tracked path fell outside working_dir: nothing in scope
// can ever hit the caches, and an empty pathspec list would mean
// a FULL worktree scan in gix — the inversion of the intent — so
// skip the scan. Clear the caches rather than keep them: their
// entries predate the HEAD/index move that brought us here, and
// the repo_sync_state committed above would short-circuit every
// later refresh into serving those stale entries (get_staged_files,
// staged flags). Consistent-empty matches the scope: the caches
// describe nothing we track.
Some(rels) if rels.is_empty() => {
self.git_dirty_cache.clear();
self.git_staged_cache.clear();
}
scope => self.refresh_git_dirty_cache(scope).await,
}
let paths: Vec<PathBuf> = self
.file_states
.keys()
.filter(|p| !skip.contains(*p))
.cloned()
.collect();
// Batch all git baseline reads into a single spawn_blocking call
// to avoid per-file repo open / HEAD resolve overhead.
let baselines = if paths.is_empty() {
HashMap::new()
} else {
self.read_baselines_batch(&paths).await
};
const PARALLEL_READ_LIMIT: usize = 64;
let mut current_contents: HashMap<PathBuf, FileContentState> = {
let mut results = HashMap::with_capacity(paths.len());
for chunk in paths.chunks(PARALLEL_READ_LIMIT) {
let mut join_set = tokio::task::JoinSet::new();
for path in chunk {
let p = path.clone();
join_set.spawn(async move {
let content = read_file_bounded(&p).await;
(p, content)
});
}
while let Some(result) = join_set.join_next().await {
match result {
Ok((path, content)) => {
results.insert(path, content);
}
Err(e) => debug!("parallel read task failed: {e}"),
}
}
}
results
};
for path in paths {
let new_baseline = baselines.get(&path).cloned().unwrap_or(missing_content());
let new_current = current_contents.remove(&path).unwrap_or(missing_content());
let Some(state) = self.file_states.get_mut(&path) else {
continue;
};
let is_agent_file = state.is_agent_file;
// Update baseline and current content (move ownership, no clones)
state.baseline = new_baseline;
state.current_content = new_current;
state.baseline_accepted = false;
// Check if file is now clean (baseline == current).
// For Full states, compare text (ignoring trailing newline).
// For non-diffable states (Binary/TooLarge/LFS): consult the git
// dirty cache (refreshed above) — if git says the file is clean,
// drop it from tracking to avoid phantom entries.
// For Missing state: clean only if file doesn't exist.
let is_clean = match (&state.baseline, &state.current_content) {
(FileContentState::Full(b), FileContentState::Full(c)) => {
strip_single_trailing_newline(b) == strip_single_trailing_newline(c)
}
(FileContentState::Binary { .. }, FileContentState::Binary { .. })
| (FileContentState::TooLarge { .. }, FileContentState::TooLarge { .. })
| (FileContentState::LfsPointer { .. }, FileContentState::LfsPointer { .. })
| (FileContentState::Symlink, FileContentState::Symlink) => {
// Non-diffable states with matching types: consult git dirty cache.
// The dirty cache was refreshed above so it reflects current HEAD.
let rel = path.strip_prefix(&self.working_dir).unwrap_or(&path);
!self.git_dirty_cache.contains(rel)
}
// LFS pointer baseline with different current content type (e.g. the
// normal smudge case: baseline=pointer, current=binary). These are
// NOT diffable but may be clean per git status. Consult dirty cache.
(FileContentState::LfsPointer { .. }, _)
| (_, FileContentState::LfsPointer { .. }) => {
let rel = path.strip_prefix(&self.working_dir).unwrap_or(&path);
!self.git_dirty_cache.contains(rel)
}
// Symlink on disk vs Full(target) in HEAD (or vice versa):
// git stores symlinks as plain text blobs, so the types
// differ even when the file is unchanged. Consult dirty cache.
(FileContentState::Symlink, FileContentState::Full(_))
| (FileContentState::Full(_), FileContentState::Symlink) => {
let rel = path.strip_prefix(&self.working_dir).unwrap_or(&path);
!self.git_dirty_cache.contains(rel)
}
(FileContentState::Missing, FileContentState::Missing) => !path.exists(),
// Not in git HEAD + not in dirty cache → gitignored; clean.
(FileContentState::Missing, _) => {
let rel = path.strip_prefix(&self.working_dir).unwrap_or(&path);
!self.git_dirty_cache.contains(rel)
}
_ => false,
};
if is_clean && !is_agent_file {
// File is clean and not an agent file — stop tracking it
let old_state = self.file_states.remove(&path).unwrap();
// Clean up turn_index and emit removal events
for hunk in &old_state.hunks {
if let Some(prompt_index) = hunk.source.prompt_index()
&& let Some(set) = self.turn_index.get_mut(&prompt_index)
{
set.remove(&hunk.id);
}
self.send_event(HunkEvent::HunkRemoved {
path: path.clone(),
hunk_id: hunk.id.clone(),
reason: HunkRemovalReason::Superseded,
});
}
self.send_event(HunkEvent::FileRemoved { path: path.clone() });
debug!("refresh_all_baselines: dropped clean file {:?}", path);
} else if is_clean && is_agent_file {
// Agent file is clean — clear hunks but keep tracking
let old_hunks = std::mem::take(&mut state.hunks);
for hunk in &old_hunks {
if let Some(prompt_index) = hunk.source.prompt_index()
&& let Some(set) = self.turn_index.get_mut(&prompt_index)
{
set.remove(&hunk.id);
}
self.send_event(HunkEvent::HunkRemoved {
path: path.clone(),
hunk_id: hunk.id.clone(),
reason: HunkRemovalReason::Superseded,
});
}
self.send_event(HunkEvent::BaselineUpdated { path: path.clone() });
debug!("refresh_all_baselines: agent file {:?} is now clean", path);
} else {
// File still has diffs — recompute hunks
// Determine source: preserve agent attribution if it's an agent file
let source = if is_agent_file {
HunkSource::ExternalEditOnAgentFile
} else {
HunkSource::External
};
// Pass current_content directly (already FileContentState, no re-classification)
let current = self
.file_states
.get(&path)
.map(|s| s.current_content.clone());
self.recompute_hunks(&path, current, source);
self.send_event(HunkEvent::BaselineUpdated { path: path.clone() });
debug!("refresh_all_baselines: recomputed hunks for {:?}", path);
}
}
debug!("{REFRESH_SCAN_LOG_PREFIX} {:?}", start.elapsed());
}
/// Set tracking mode.
pub(super) async fn set_mode(&mut self, mode: TrackingMode) {
let old_mode = self.mode;
self.mode = mode;
if old_mode == TrackingMode::AgentOnly && mode == TrackingMode::AllDirty {
// Switching to AllDirty - refresh git cache and track dirty files
self.refresh_git_dirty_cache(None).await;
} else if old_mode == TrackingMode::AllDirty && mode == TrackingMode::AgentOnly {
// Switching to AgentOnly - remove non-agent files
let non_agent_paths: Vec<PathBuf> = self
.file_states
.iter()
.filter(|(_, state)| !state.is_agent_file)
.map(|(path, _)| path.clone())
.collect();
for path in non_agent_paths {
if let Some(state) = self.file_states.remove(&path) {
// Remove from turn_index and emit removed events for all hunks
for hunk in state.hunks {
if let Some(prompt_index) = hunk.source.prompt_index()
&& let Some(set) = self.turn_index.get_mut(&prompt_index)
{
set.remove(&hunk.id);
}
self.send_event(HunkEvent::HunkRemoved {
path: path.clone(),
hunk_id: hunk.id.clone(),
reason: HunkRemovalReason::Superseded,
});
}
self.send_event(HunkEvent::FileRemoved { path });
}
}
}
}
}

View file

@ -0,0 +1,204 @@
//! Query commands for the HunkTrackerActor.
//!
//! These methods provide read-only access to hunk state.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use rustc_hash::{FxHashMap, FxHashSet};
use crate::diff::generate_hunk_patch;
use crate::types::{
FileContentView, FileHunkData, Hunk, HunkId, HunkSourceFilter, SessionSummary, TurnSummary,
};
use super::HunkTrackerActor;
impl HunkTrackerActor {
/// Get all hunks.
pub(super) fn get_all_hunks(&self) -> Vec<Arc<Hunk>> {
self.file_states
.values()
.flat_map(|state| state.hunks.clone())
.collect()
}
/// Get hunks for a specific path.
pub(super) fn get_hunks_for_path(&self, path: &Path) -> Vec<Arc<Hunk>> {
self.file_states
.get(path)
.map(|state| state.hunks.clone())
.unwrap_or_default()
}
/// Get hunks + file content for a specific path (for diff rendering).
/// Each hunk includes its own patch fragment with context lines.
/// Returns explicit content status (Full/Binary/TooLarge/Missing) for both
/// baseline and current content, plus legacy Option<String> fields for
/// backward compatibility.
pub(super) fn get_file_hunk_data(&self, path: &Path) -> FileHunkData {
self.file_states
.get(path)
.map(|state| {
// Extract text content for patching (only Full states)
let baseline_text = state.baseline.as_str();
let current_text = state.current_content.as_str();
// Generate patch for each hunk
let hunks_with_patches: Vec<Arc<Hunk>> = state
.hunks
.iter()
.map(|hunk| {
// Generate patch if we have both baseline and current content
let patch = match (baseline_text, current_text) {
(Some(baseline), Some(current)) => {
Some(generate_hunk_patch(baseline, current, hunk))
}
// New file: diff from empty
(None, Some(current)) => Some(generate_hunk_patch("", current, hunk)),
// Deleted file: diff to empty
(Some(baseline), None) => Some(generate_hunk_patch(baseline, "", hunk)),
(None, None) => None,
};
// Clone the hunk and add the patch
let mut hunk_with_patch = (**hunk).clone();
hunk_with_patch.patch = patch;
Arc::new(hunk_with_patch)
})
.collect();
// Convert FileContentState to FileContentView (explicit status)
let baseline = FileContentView::from_content_state(&state.baseline);
let current = FileContentView::from_content_state(&state.current_content);
// Legacy fields for backward compatibility (populated from views)
let baseline_content = baseline.content.clone();
let current_content = current.content.clone();
FileHunkData {
hunks: hunks_with_patches,
baseline,
current,
baseline_content,
current_content,
}
})
.unwrap_or_default()
}
/// Get all tracked file paths, regardless of source or remaining hunks.
///
/// Returns every key in `file_states` — agent files, external edits, and
/// fs_notify-detected changes alike. Entries persist after the user
/// accepts/rejects every hunk, making this suitable for file-discovery
/// when replicating a worktree's changes back to the root repo.
pub(super) fn get_all_tracked_paths(&self) -> Vec<PathBuf> {
self.file_states.keys().cloned().collect()
}
/// Get hunks filtered by source.
pub(super) fn get_hunks_by_source(&self, source: HunkSourceFilter) -> Vec<Arc<Hunk>> {
self.file_states
.values()
.flat_map(|state| &state.hunks)
.filter(|hunk| match source {
HunkSourceFilter::Agent => hunk.source.is_agent_edit(),
HunkSourceFilter::External => hunk.source.is_external(),
})
.cloned()
.collect()
}
/// Get a specific hunk by ID.
pub(super) fn get_hunk(&self, hunk_id: &HunkId) -> Option<Arc<Hunk>> {
self.file_states
.values()
.flat_map(|state| &state.hunks)
.find(|h| h.id == *hunk_id)
.cloned()
}
/// Get hunks for a specific turn/prompt_index using the turn_index for O(1) lookup.
pub(super) fn get_hunks_for_turn(&self, prompt_index: usize) -> Vec<Arc<Hunk>> {
let Some(hunk_ids) = self.turn_index.get(&prompt_index) else {
return vec![];
};
hunk_ids.iter().filter_map(|id| self.get_hunk(id)).collect()
}
/// Compute a complete session summary with stats and pending hunks grouped by turn.
pub(super) fn compute_session_summary(&self) -> SessionSummary {
let mut files_modified: FxHashSet<PathBuf> = FxHashSet::default();
let mut files_with_pending: FxHashSet<PathBuf> = FxHashSet::default();
// Temporary struct to accumulate per-turn data
#[derive(Default)]
struct TurnData {
files: FxHashSet<PathBuf>,
pending: Vec<Arc<Hunk>>,
lines_added: usize,
lines_removed: usize,
}
let mut by_prompt: FxHashMap<usize, TurnData> = FxHashMap::default();
let mut unattributed_pending = 0;
// Collect agent-attributed hunks for turn summaries and totals.
// Track unattributed hunks separately (external edits, missing prompt_index).
for (path, state) in &self.file_states {
let mut has_agent_hunks = false;
for hunk in &state.hunks {
if let Some(prompt_index) = hunk.source.prompt_index() {
has_agent_hunks = true;
let entry = by_prompt.entry(prompt_index).or_default();
entry.files.insert(path.clone());
entry.pending.push(hunk.clone());
entry.lines_added += hunk.line_info.new_count;
entry.lines_removed += hunk.line_info.old_count;
} else {
unattributed_pending += 1;
}
}
if has_agent_hunks {
files_modified.insert(path.clone());
files_with_pending.insert(path.clone());
}
}
// Build turn summaries (pending only)
let mut turns: Vec<TurnSummary> = Vec::new();
for (prompt_index, data) in by_prompt {
turns.push(TurnSummary {
prompt_index,
files: data.files.into_iter().collect(),
pending_hunks: data.pending,
lines_added: data.lines_added,
lines_removed: data.lines_removed,
});
}
// Sort turns by prompt_index
turns.sort_by_key(|t| t.prompt_index);
// Compute pending totals from agent-attributed hunks only.
let pending_hunks: usize = turns.iter().map(|t| t.pending_hunks.len()).sum();
let pending_lines_added: usize = turns.iter().map(|t| t.lines_added).sum();
let pending_lines_removed: usize = turns.iter().map(|t| t.lines_removed).sum();
SessionSummary {
stats: self.session_stats.clone(),
turns,
files_modified: files_modified.len(),
files_with_pending: files_with_pending.len(),
pending_hunks,
pending_lines_added,
pending_lines_removed,
unattributed_pending,
}
}
}

View file

@ -0,0 +1,145 @@
//! Internal state types for the HunkTrackerActor.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::SystemTime;
use crate::types::Hunk;
/// Maximum size (in bytes) of file text content to retain in memory.
/// Files larger than this are stored as TooLarge.
/// This is aligned with the diff limit to ensure consistent behavior.
pub(crate) const MAX_TRACKED_TEXT_BYTES: usize = 1024 * 1024; // 1 MB
/// Explicit state of file content storage.
/// Replaces Option<String> for baseline/current_content to avoid unbounded memory.
/// Files exceeding MAX_TRACKED_TEXT_BYTES or containing binary content are
/// stored with metadata only (no text retained).
///
/// `Serialize`/`Deserialize` back the disk-persisted rewind checkpoint store; the
/// default externally-tagged representation round-trips every variant.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum FileContentState {
/// File does not exist at this reference point.
Missing,
/// File content is not valid UTF-8 (binary).
/// byte_len is optional because we may not know the size.
Binary { byte_len: Option<usize> },
/// File exceeds MAX_TRACKED_TEXT_BYTES; content not retained.
TooLarge { byte_len: usize },
/// File content is a Git LFS pointer (small text stub that references
/// the real object in the LFS store). Not diffable because the working
/// copy holds the smudged (real) content while the git blob holds only
/// the pointer — comparing them produces a phantom diff.
LfsPointer { byte_len: usize },
/// Path is a symbolic link. Not diffable because the hunk tracker
/// follows symlinks when reading content, producing a phantom diff
/// against the git-stored symlink target string.
Symlink,
/// Full text content retained (within limit).
Full(String),
}
impl FileContentState {
/// Returns the text content if Full, None otherwise.
pub(crate) fn as_str(&self) -> Option<&str> {
match self {
FileContentState::Full(s) => Some(s),
_ => None,
}
}
/// Returns true if content can be used for diffing.
pub(crate) fn is_diffable(&self) -> bool {
matches!(self, FileContentState::Full(_))
}
/// Returns byte length if known.
#[allow(dead_code)]
pub(crate) fn byte_len(&self) -> Option<usize> {
match self {
FileContentState::Missing => Some(0),
FileContentState::Binary { byte_len } => *byte_len,
FileContentState::TooLarge { byte_len } => Some(*byte_len),
FileContentState::LfsPointer { byte_len } => Some(*byte_len),
FileContentState::Symlink => None,
FileContentState::Full(s) => Some(s.len()),
}
}
}
/// Cached state for git repository discovery.
/// Avoids repeated filesystem walks to find the repo root.
///
/// When a repo is discovered, we cache a `gix::ThreadSafeRepository` handle
/// so that subsequent operations can call `.to_thread_local()` (a cheap
/// `Arc` clone + thread-local wrapper) instead of re-opening the repo via
/// `gix::open()` on every `spawn_blocking` call.
#[derive(Clone)]
pub(crate) enum GitRepoState {
/// Haven't attempted discovery yet
Unknown,
/// Discovered that working_dir is not inside a git repository
NotARepo,
/// Successfully discovered the git repository
Discovered {
/// Cached thread-safe repo handle. `.to_thread_local()` is cheap.
repo: Arc<gix::ThreadSafeRepository>,
/// Prefix to convert working_dir-relative paths to repo-relative paths
/// (working_dir relative to repo_root, e.g., "subdir/nested" if working_dir is repo_root/subdir/nested)
prefix: PathBuf,
},
}
impl std::fmt::Debug for GitRepoState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unknown => write!(f, "Unknown"),
Self::NotARepo => write!(f, "NotARepo"),
Self::Discovered { prefix, .. } => f
.debug_struct("Discovered")
.field("prefix", prefix)
.finish_non_exhaustive(),
}
}
}
/// Git state used to decide when to refresh baselines.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct RepoSyncState {
/// Last observed HEAD commit id.
pub head_oid: Option<String>,
/// Last observed .git/index modification time.
pub index_mtime: Option<SystemTime>,
}
/// Internal state for a single tracked file.
pub(crate) struct FileHunkState {
/// Content at git HEAD or session start (baseline for diffing).
/// FileContentState::Missing means file didn't exist at baseline (new file).
/// FileContentState::TooLarge/Binary means content not retained (metadata only).
pub baseline: FileContentState,
/// Last known content (from agent write or disk read).
/// Used to detect external edits by comparing to disk content.
/// FileContentState::Missing means file doesn't exist currently.
pub current_content: FileContentState,
/// Active hunks for this file (computed from baseline vs current).
/// Wrapped in Arc for cheap cloning when returning from queries.
/// Hunks only exist for Full text states on both sides.
pub hunks: Vec<Arc<Hunk>>,
/// True if agent has written to this file.
/// Determines if file stays tracked in AgentOnly mode.
pub is_agent_file: bool,
/// True if the baseline has been patched by an accept action (diverged
/// from git HEAD). Used by `handle_file_change` to decide whether to
/// re-read the baseline from git HEAD: if this flag is set and the new
/// file content matches git HEAD, the baseline is refreshed and the flag
/// cleared. This handles `git restore .` without undoing accepts when
/// the user makes a normal (non-restore) edit.
pub baseline_accepted: bool,
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,159 @@
//! Commands sent to the HunkTrackerActor.
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::oneshot;
use crate::types::{
FileContentEntry, FileHunkData, Hunk, HunkAction, HunkActionError, HunkId, HunkSourceFilter,
HunkTrackerSnapshot, HunkTurnDelta, SessionSummary, TrackingMode,
};
/// Commands sent to the HunkTrackerActor via mpsc channel.
#[derive(Debug)]
pub enum HunkTrackerCommand {
// === Mutation Commands (fire-and-forget) ===
/// Agent tool wrote to a file - record it and compute hunks
RecordAgentWrite {
path: PathBuf,
content: String,
prompt_index: usize,
/// Content of the file before this write (if known).
/// Used as a fallback baseline when the file doesn't exist in git HEAD
/// (e.g., in worktrees created from dirty state).
previous_content: Option<String>,
},
/// fs_notify detected a file change - check if we should track/update
HandleFileChange { path: PathBuf },
/// fs_notify detected file deletion
HandleFileDeleted { path: PathBuf },
/// Refresh git dirty cache (called periodically)
RefreshGitDirtyCache,
/// Reset baseline after commit
ResetBaseline { path: PathBuf },
/// Set tracking mode
SetMode { mode: TrackingMode },
// === Action Commands (accept/reject hunks) ===
/// Apply action (accept/reject) to a specific hunk
HunkAction {
hunk_id: HunkId,
action: HunkAction,
reply: oneshot::Sender<Result<(), HunkActionError>>,
},
/// Apply action (accept/reject) to all hunks for a file
FileAction {
path: PathBuf,
action: HunkAction,
reply: oneshot::Sender<Result<Vec<HunkId>, HunkActionError>>,
},
/// Apply action (accept/reject) to all hunks
AllAction {
action: HunkAction,
reply: oneshot::Sender<Result<Vec<HunkId>, HunkActionError>>,
},
/// Apply action (accept/reject) to all hunks for a specific turn
TurnAction {
prompt_index: usize,
action: HunkAction,
reply: oneshot::Sender<Result<Vec<HunkId>, HunkActionError>>,
},
// === Query Commands (request-response via oneshot) ===
/// Get all current hunks
GetAllHunks {
reply: oneshot::Sender<Vec<Arc<Hunk>>>,
},
/// Get hunks for a specific path
GetHunksForPath {
path: PathBuf,
reply: oneshot::Sender<Vec<Arc<Hunk>>>,
},
/// Get hunks + file content for a specific path (for diff rendering)
GetFileHunkData {
path: PathBuf,
reply: oneshot::Sender<FileHunkData>,
},
/// Get hunks filtered by source
GetHunksBySource {
source: HunkSourceFilter,
reply: oneshot::Sender<Vec<Arc<Hunk>>>,
},
/// Get a specific hunk by ID
GetHunk {
hunk_id: HunkId,
reply: oneshot::Sender<Option<Arc<Hunk>>>,
},
/// Check if a path is being tracked as an agent file
IsAgentFile {
path: PathBuf,
reply: oneshot::Sender<bool>,
},
/// Get all tracked file paths (agent + external, regardless of hunk state)
GetAllTrackedPaths {
reply: oneshot::Sender<Vec<PathBuf>>,
},
/// Get staged file paths (HEAD→index changes from git). Repo-wide in
/// AllDirty; scoped to tracked paths in AgentOnly.
GetStagedFiles {
reply: oneshot::Sender<HashSet<PathBuf>>,
},
/// Get baseline, current content, agent flag, and staged flag for every
/// tracked file in a single in-memory iteration. No async I/O.
GetAllFileContents {
reply: oneshot::Sender<Vec<FileContentEntry>>,
},
// === Session Summary Commands ===
/// Get complete session summary (stats + pending turns)
GetSessionSummary {
reply: oneshot::Sender<SessionSummary>,
},
/// Get pending hunks for a specific turn
GetTurnHunks {
prompt_index: usize,
reply: oneshot::Sender<Vec<Arc<Hunk>>>,
},
/// Reset session stats (e.g., after commit)
ResetStats,
/// Refresh all baselines from the current git HEAD and re-read current
/// content from disk. Used after a git HEAD/index change to reconcile stale state.
RefreshAllBaselines,
// === Snapshot / Restore Commands (for cross-session sync-back) ===
/// Take a snapshot of all hunk tracker state for preservation across
/// session kill/reload cycles.
SnapshotState {
reply: oneshot::Sender<HunkTrackerSnapshot>,
},
/// Incremental single-turn delta for the rewind checkpoint store.
SnapshotTurnDelta {
prompt_index: usize,
reply: oneshot::Sender<HunkTurnDelta>,
},
/// Restore a previously snapshotted state. Replaces all current file
/// states, turn index, and session stats.
RestoreState(HunkTrackerSnapshot),
}

View file

@ -0,0 +1,876 @@
//! Diff computation using the `similar` crate.
use similar::{ChangeTag, TextDiff};
use std::fmt::Write;
use std::path::Path;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tracing::{debug, warn};
use crate::types::{Hunk, HunkId, HunkLineInfo, HunkSource};
/// Number of context lines to include around changes (like git diff).
const CONTEXT_LINES: usize = 3;
/// Maximum time allowed for a single diff computation.
const DIFF_TIMEOUT: Duration = Duration::from_secs(10);
/// Maximum file size (in bytes) to attempt diffing.
/// Files larger than this will be skipped to avoid pathological diff behavior.
const MAX_DIFF_FILE_SIZE: usize = 1024 * 1024; // 1 MB
/// Generate a unified diff patch string from baseline and current content.
/// This produces a patch that can be parsed by Pierre's `getSingularPatch`.
///
/// Returns None if:
/// - Content is identical
/// - Either file exceeds MAX_DIFF_FILE_SIZE
/// - Diff computation times out
pub fn generate_unified_patch(path: &Path, baseline: &str, current: &str) -> Option<String> {
// If content is identical, no patch needed
if baseline == current {
return None;
}
// Check file size limits
if baseline.len() > MAX_DIFF_FILE_SIZE || current.len() > MAX_DIFF_FILE_SIZE {
warn!(
path = %path.display(),
baseline_size = baseline.len(),
current_size = current.len(),
max_size = MAX_DIFF_FILE_SIZE,
"Skipping unified patch for file exceeding size limit"
);
return None;
}
let start_time = Instant::now();
let diff = TextDiff::configure()
.timeout(DIFF_TIMEOUT)
.diff_lines(baseline, current);
let elapsed = start_time.elapsed();
if elapsed >= DIFF_TIMEOUT {
warn!(
path = %path.display(),
elapsed_ms = elapsed.as_millis(),
"Unified patch diff timed out"
);
return None;
}
// Generate unified diff with file headers
let path_str = path.display().to_string();
let unified = diff
.unified_diff()
.context_radius(CONTEXT_LINES)
.header(&format!("a/{}", path_str), &format!("b/{}", path_str))
.to_string();
if unified.is_empty() {
None
} else {
Some(unified)
}
}
/// Generate a patch fragment for a single hunk with context lines.
/// Returns just the hunk portion (no file headers), e.g.:
/// "@@ -10,5 +10,7 @@\n context\n-old\n+new\n context\n"
pub fn generate_hunk_patch(baseline: &str, current: &str, hunk: &Hunk) -> String {
let old_lines: Vec<&str> = baseline.lines().collect();
let new_lines: Vec<&str> = current.lines().collect();
let mut output = String::new();
// Calculate context bounds (0-indexed)
let old_start_idx = hunk.line_info.old_start.saturating_sub(1);
let new_start_idx = hunk.line_info.new_start.saturating_sub(1);
// Context before the change
let context_before_start = old_start_idx.saturating_sub(CONTEXT_LINES);
let context_before_end = old_start_idx;
// Context after the change (in new file coordinates)
let changes_end_new = new_start_idx + hunk.line_info.new_count;
let context_after_start = changes_end_new;
let context_after_end = (changes_end_new + CONTEXT_LINES).min(new_lines.len());
// For old file, context after
let changes_end_old = old_start_idx + hunk.line_info.old_count;
let context_after_start_old = changes_end_old;
let context_after_end_old = (changes_end_old + CONTEXT_LINES).min(old_lines.len());
// Calculate total lines for header
let total_old_lines = (context_before_end - context_before_start)
+ hunk.line_info.old_count
+ (context_after_end_old - context_after_start_old);
let total_new_lines = (context_before_end - context_before_start)
+ hunk.line_info.new_count
+ (context_after_end - context_after_start);
// Hunk header (1-indexed)
let header_old_start = context_before_start + 1;
let header_new_start = context_before_start + 1; // Context is same in both
let _ = writeln!(
output,
"@@ -{},{} +{},{} @@",
header_old_start, total_old_lines, header_new_start, total_new_lines
);
// Context lines before
for i in context_before_start..context_before_end {
if let Some(line) = old_lines.get(i) {
let _ = writeln!(output, " {}", line);
}
}
// Deleted lines
if let Some(old_text) = &hunk.old_text {
for line in old_text.lines() {
let _ = writeln!(output, "-{}", line);
}
}
// Added lines
for line in hunk.new_text.lines() {
let _ = writeln!(output, "+{}", line);
}
// Context lines after (from new file since changes may have shifted things)
for i in context_after_start..context_after_end {
if let Some(line) = new_lines.get(i) {
let _ = writeln!(output, " {}", line);
}
}
output
}
/// Compute hunks by diffing baseline against current content.
/// Uses the `similar` crate for line-based diff.
///
/// Returns an empty vector if:
/// - Content is identical (no changes)
/// - Either file exceeds MAX_DIFF_FILE_SIZE
/// - Diff computation times out
pub fn compute_hunks(path: &Path, baseline: &str, current: &str, source: HunkSource) -> Vec<Hunk> {
// If content is identical, no hunks
if baseline == current {
return vec![];
}
// Check file size limits to avoid pathological diff behavior
let baseline_size = baseline.len();
let current_size = current.len();
if baseline_size > MAX_DIFF_FILE_SIZE || current_size > MAX_DIFF_FILE_SIZE {
warn!(
path = %path.display(),
baseline_size,
current_size,
max_size = MAX_DIFF_FILE_SIZE,
"Skipping diff for file exceeding size limit"
);
return vec![];
}
let start_time = Instant::now();
debug!(
path = %path.display(),
baseline_lines = baseline.lines().count(),
current_lines = current.lines().count(),
"Starting diff computation"
);
let diff = TextDiff::configure()
.timeout(DIFF_TIMEOUT)
.diff_lines(baseline, current);
let elapsed = start_time.elapsed();
// Check if we hit the timeout (similar crate returns partial results on timeout)
if elapsed >= DIFF_TIMEOUT {
warn!(
path = %path.display(),
elapsed_ms = elapsed.as_millis(),
timeout_ms = DIFF_TIMEOUT.as_millis(),
"Diff computation timed out, returning empty hunks"
);
return vec![];
}
debug!(
path = %path.display(),
elapsed_ms = elapsed.as_millis(),
"Diff computation completed"
);
let mut hunks = Vec::new();
// Track current position in old and new files (1-indexed for display)
let mut old_line = 1usize;
let mut new_line = 1usize;
// Accumulator for current hunk being built
let mut current_hunk: Option<HunkBuilder> = None;
for change in diff.iter_all_changes() {
match change.tag() {
ChangeTag::Equal => {
// Equal line - finalize any in-progress hunk
if let Some(builder) = current_hunk.take() {
hunks.push(builder.build(path, source));
}
old_line += 1;
new_line += 1;
}
ChangeTag::Delete => {
// Line exists in old, not in new
let hunk = current_hunk.get_or_insert_with(|| HunkBuilder::new(old_line, new_line));
hunk.add_old_line(change.value());
old_line += 1;
}
ChangeTag::Insert => {
// Line exists in new, not in old
let hunk = current_hunk.get_or_insert_with(|| HunkBuilder::new(old_line, new_line));
hunk.add_new_line(change.value());
new_line += 1;
}
}
}
// Finalize last hunk if any
if let Some(builder) = current_hunk.take() {
hunks.push(builder.build(path, source));
}
hunks
}
/// Helper to accumulate lines while building a hunk.
struct HunkBuilder {
old_start: usize,
new_start: usize,
old_lines: Vec<String>,
new_lines: Vec<String>,
}
impl HunkBuilder {
fn new(old_start: usize, new_start: usize) -> Self {
Self {
old_start,
new_start,
old_lines: Vec::new(),
new_lines: Vec::new(),
}
}
fn add_old_line(&mut self, line: &str) {
self.old_lines.push(line.to_string());
}
fn add_new_line(&mut self, line: &str) {
self.new_lines.push(line.to_string());
}
fn build(self, path: &Path, source: HunkSource) -> Hunk {
let old_text = if self.old_lines.is_empty() {
None
} else {
Some(self.old_lines.join(""))
};
let new_text = self.new_lines.join("");
Hunk {
id: HunkId::new(),
path: path.to_path_buf(),
line_info: HunkLineInfo {
old_start: self.old_start,
old_count: self.old_lines.len(),
new_start: self.new_start,
new_count: self.new_lines.len(),
},
source,
old_text,
new_text,
patch: None, // Patch is generated later when requested
created_at: chrono::Utc::now(),
selected: false,
}
}
}
/// Generate a unified diff string for display.
pub fn format_unified_diff(hunk: &Hunk) -> String {
let mut output = String::new();
// Header
let _ = writeln!(output, "--- a/{}", hunk.path.display());
let _ = writeln!(output, "+++ b/{}", hunk.path.display());
// Hunk header
let _ = writeln!(output, "{}", hunk.line_info);
// Content
if let Some(old_text) = &hunk.old_text {
for line in old_text.lines() {
let _ = writeln!(output, "-{}", line);
}
}
for line in hunk.new_text.lines() {
let _ = writeln!(output, "+{}", line);
}
output
}
/// Replace lines in content starting at `start_line` (1-indexed),
/// removing `remove_count` lines and inserting `insert_text`.
///
/// # Arguments
/// * `content` - The full file content to patch
/// * `start_line` - 1-indexed line number where patch begins
/// * `remove_count` - Number of lines to remove (can be 0 for pure insert)
/// * `insert_text` - Text to insert (can be empty for pure delete)
///
/// # Returns
/// The patched content
pub fn patch_lines(
content: &str,
start_line: usize,
remove_count: usize,
insert_text: &str,
) -> String {
let lines: Vec<&str> = content.lines().collect();
let start_idx = start_line.saturating_sub(1); // Convert to 0-indexed
let mut result = Vec::new();
// Lines before the patch point
result.extend(lines[..start_idx.min(lines.len())].iter().copied());
// Insert new lines (if any)
if !insert_text.is_empty() {
for line in insert_text.lines() {
result.push(line);
}
}
// Lines after the removed section
let end_idx = (start_idx + remove_count).min(lines.len());
result.extend(lines[end_idx..].iter().copied());
// Reconstruct with proper trailing newline handling
let mut output = result.join("\n");
if content.ends_with('\n') && !output.is_empty() {
output.push('\n');
}
output
}
/// Compare two hunks to see if they represent the same logical change
/// (content match, possibly at different positions).
pub fn hunks_match_content(a: &Hunk, b: &Hunk) -> bool {
a.path == b.path && a.old_text == b.old_text && a.new_text == b.new_text
}
/// Check if a hunk has moved (same content, different position).
pub fn hunk_moved(old: &Hunk, new: &Hunk) -> bool {
hunks_match_content(old, new) && old.line_info != new.line_info
}
/// Check if two hunks overlap by line range in the baseline (old) file.
/// Uses old_start/old_count for stable overlap detection even when file shifts.
/// Used for determining when hunks should be merged or matched.
pub fn hunks_overlap(a: &Hunk, b: &Hunk) -> bool {
if a.path != b.path {
return false;
}
// Use old_start/old_count (baseline-relative) for stable overlap detection
let a_start = a.line_info.old_start;
let a_end = a.line_info.old_start.saturating_add(a.line_info.old_count);
let b_start = b.line_info.old_start;
let b_end = b.line_info.old_start.saturating_add(b.line_info.old_count);
// For pure insertions (old_count=0), consider adjacent positions as overlapping
if a.line_info.old_count == 0 && b.line_info.old_count == 0 {
// Two insertions at the same baseline position overlap
return a_start == b_start;
}
// Handle insertions overlapping with regular hunks:
// An insertion at position X overlaps with a hunk spanning [start, end) if start <= X <= end
if a.line_info.old_count == 0 {
// a is an insertion at a_start
return a_start >= b_start && a_start <= b_end;
}
if b.line_info.old_count == 0 {
// b is an insertion at b_start
return b_start >= a_start && b_start <= a_end;
}
// Overlaps if NOT (a ends before b starts OR b ends before a starts)
// Include adjacent (touching) hunks as overlapping
!(a_end < b_start || b_end < a_start)
}
/// Find the best matching old hunk for a new hunk.
/// Priority: 1) exact content + position match, 2) content match closest by line, 3) maximum overlap size
pub fn find_matching_old_hunk<'a>(
new_hunk: &Hunk,
old_hunks: &'a [Arc<Hunk>],
) -> Option<&'a Arc<Hunk>> {
// Collect all content matches
let content_matches: Vec<_> = old_hunks
.iter()
.filter(|o| hunks_match_content(o, new_hunk))
.collect();
if !content_matches.is_empty() {
// If we have content matches, pick the one closest by line position
// This handles the case of identical changes at multiple locations (e.g., variable rename)
return content_matches
.into_iter()
.min_by_key(|o| o.line_info.new_start.abs_diff(new_hunk.line_info.new_start));
}
// Fall back to BEST overlapping hunk (max overlap size)
old_hunks
.iter()
.filter(|o| hunks_overlap(o, new_hunk))
.max_by_key(|o| calculate_overlap_size(&o.line_info, &new_hunk.line_info))
}
/// Calculate the overlap size between two hunks (in baseline lines)
fn calculate_overlap_size(a: &HunkLineInfo, b: &HunkLineInfo) -> usize {
let a_start = a.old_start;
let a_end = a.old_start + a.old_count;
let b_start = b.old_start;
let b_end = b.old_start + b.old_count;
let overlap_start = a_start.max(b_start);
let overlap_end = a_end.min(b_end);
overlap_end.saturating_sub(overlap_start)
}
/// Find all old hunks that overlap with a new hunk (for merging).
pub fn find_overlapping_hunks<'a>(
new_hunk: &Hunk,
old_hunks: &'a [Arc<Hunk>],
) -> Vec<&'a Arc<Hunk>> {
old_hunks
.iter()
.filter(|o| hunks_overlap(o, new_hunk))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn agent_source() -> HunkSource {
HunkSource::AgentEdit { prompt_index: 0 }
}
#[test]
fn test_no_changes() {
let content = "line 1\nline 2\nline 3\n";
let hunks = compute_hunks(Path::new("test.rs"), content, content, agent_source());
assert!(hunks.is_empty());
}
#[test]
fn test_single_line_modification() {
let baseline = "line 1\nline 2\nline 3\n";
let current = "line 1\nmodified\nline 3\n";
let hunks = compute_hunks(Path::new("test.rs"), baseline, current, agent_source());
assert_eq!(hunks.len(), 1);
assert_eq!(hunks[0].old_text, Some("line 2\n".to_string()));
assert_eq!(hunks[0].new_text, "modified\n");
assert_eq!(hunks[0].line_info.old_start, 2);
assert_eq!(hunks[0].line_info.new_start, 2);
}
#[test]
fn test_insertion() {
let baseline = "line 1\nline 2\n";
let current = "line 1\ninserted\nline 2\n";
let hunks = compute_hunks(Path::new("test.rs"), baseline, current, agent_source());
assert_eq!(hunks.len(), 1);
assert_eq!(hunks[0].old_text, None);
assert_eq!(hunks[0].new_text, "inserted\n");
assert_eq!(hunks[0].line_info.old_count, 0);
assert_eq!(hunks[0].line_info.new_count, 1);
}
#[test]
fn test_deletion() {
let baseline = "line 1\nline 2\nline 3\n";
let current = "line 1\nline 3\n";
let hunks = compute_hunks(Path::new("test.rs"), baseline, current, agent_source());
assert_eq!(hunks.len(), 1);
assert_eq!(hunks[0].old_text, Some("line 2\n".to_string()));
assert_eq!(hunks[0].new_text, "");
assert_eq!(hunks[0].line_info.old_count, 1);
assert_eq!(hunks[0].line_info.new_count, 0);
}
#[test]
fn test_multiple_hunks() {
let baseline = "line 1\nline 2\nline 3\nline 4\nline 5\n";
let current = "modified 1\nline 2\nline 3\nline 4\nmodified 5\n";
let hunks = compute_hunks(Path::new("test.rs"), baseline, current, agent_source());
assert_eq!(hunks.len(), 2);
assert_eq!(hunks[0].line_info.old_start, 1);
assert_eq!(hunks[1].line_info.old_start, 5);
}
#[test]
fn test_format_unified_diff() {
let hunk = Hunk {
id: HunkId::new(),
path: "test.rs".into(),
line_info: HunkLineInfo {
old_start: 2,
old_count: 1,
new_start: 2,
new_count: 1,
},
source: agent_source(),
old_text: Some("old line\n".to_string()),
new_text: "new line\n".to_string(),
patch: None,
created_at: chrono::Utc::now(),
selected: false,
};
let diff = format_unified_diff(&hunk);
assert!(diff.contains("--- a/test.rs"));
assert!(diff.contains("+++ b/test.rs"));
assert!(diff.contains("-old line"));
assert!(diff.contains("+new line"));
}
#[test]
fn test_find_matching_hunk_with_identical_content_at_different_positions() {
// Simulate a variable rename that appears at multiple locations
// Old hunks at lines 10 and 100 with identical content
let old_hunk_at_10 = Arc::new(Hunk {
id: HunkId::from_string("hunk-10".to_string()),
path: "test.rs".into(),
line_info: HunkLineInfo {
old_start: 10,
old_count: 1,
new_start: 10,
new_count: 1,
},
source: agent_source(),
old_text: Some("let a = b;\n".to_string()),
new_text: "let c = b;\n".to_string(),
patch: None,
created_at: chrono::Utc::now(),
selected: false,
});
let old_hunk_at_100 = Arc::new(Hunk {
id: HunkId::from_string("hunk-100".to_string()),
path: "test.rs".into(),
line_info: HunkLineInfo {
old_start: 100,
old_count: 1,
new_start: 100,
new_count: 1,
},
source: agent_source(),
old_text: Some("let a = b;\n".to_string()),
new_text: "let c = b;\n".to_string(),
patch: None,
created_at: chrono::Utc::now(),
selected: false,
});
let old_hunks = vec![old_hunk_at_10.clone(), old_hunk_at_100.clone()];
// New hunk at line 10 should match old hunk at line 10
let new_hunk_near_10 = Hunk {
id: HunkId::new(),
path: "test.rs".into(),
line_info: HunkLineInfo {
old_start: 10,
old_count: 1,
new_start: 12, // slightly shifted
new_count: 1,
},
source: agent_source(),
old_text: Some("let a = b;\n".to_string()),
new_text: "let c = b;\n".to_string(),
patch: None,
created_at: chrono::Utc::now(),
selected: false,
};
let matched = find_matching_old_hunk(&new_hunk_near_10, &old_hunks);
assert!(matched.is_some());
assert_eq!(matched.unwrap().id.as_str(), "hunk-10");
// New hunk at line 100 should match old hunk at line 100
let new_hunk_near_100 = Hunk {
id: HunkId::new(),
path: "test.rs".into(),
line_info: HunkLineInfo {
old_start: 100,
old_count: 1,
new_start: 102, // slightly shifted
new_count: 1,
},
source: agent_source(),
old_text: Some("let a = b;\n".to_string()),
new_text: "let c = b;\n".to_string(),
patch: None,
created_at: chrono::Utc::now(),
selected: false,
};
let matched = find_matching_old_hunk(&new_hunk_near_100, &old_hunks);
assert!(matched.is_some());
assert_eq!(matched.unwrap().id.as_str(), "hunk-100");
}
#[test]
fn test_find_matching_hunk_fallback_best_overlap() {
// This test figures out the edge case mentioned: when a new hunk overlaps
// *multiple* old hunks (and no content match, so fallback), the current
// .find() picks the *first* overlapping one -- order-dependent, can preserve
// wrong hunk ID/source.
//
// We use different overlap sizes so "best" (max overlap) is unambiguous.
// With current code, this test FAILS (picks "small" because it's first).
// After fix to use max overlap, it should PASS (picks "large").
// Old hunks with no content match to new_hunk, ordered small-first
let old_hunk_small = Arc::new(Hunk {
id: HunkId::from_string("hunk-small".to_string()),
path: "test.rs".into(),
line_info: HunkLineInfo {
old_start: 1,
old_count: 1,
new_start: 1,
new_count: 2, // covers new lines 1-2
},
source: agent_source(),
old_text: Some("old-small\n".to_string()),
new_text: "new-small\n".to_string(),
patch: None,
created_at: chrono::Utc::now(),
selected: false,
});
let old_hunk_large = Arc::new(Hunk {
id: HunkId::from_string("hunk-large".to_string()),
path: "test.rs".into(),
line_info: HunkLineInfo {
old_start: 3,
old_count: 1,
new_start: 3,
new_count: 4, // covers new lines 3-6
},
source: agent_source(),
old_text: Some("old-large\n".to_string()),
new_text: "new-large\n".to_string(),
patch: None,
created_at: chrono::Utc::now(),
selected: false,
});
let old_hunks = vec![old_hunk_small.clone(), old_hunk_large.clone()]; // small first!
// New hunk overlaps both, but more with large:
// new lines 2-5 (end=6)
// - small: overlap lines 2 (size=1)
// - large: overlap lines 3-5 (size=3)
// Content differs -> no content match -> fallback to overlap
let new_hunk = Hunk {
id: HunkId::new(),
path: "test.rs".into(),
line_info: HunkLineInfo {
old_start: 2,
old_count: 4,
new_start: 2,
new_count: 4, // lines 2-5
},
source: agent_source(),
old_text: Some("different-old\n".to_string()),
new_text: "different-new\n".to_string(),
patch: None,
created_at: chrono::Utc::now(),
selected: false,
};
let matched = find_matching_old_hunk(&new_hunk, &old_hunks);
assert!(matched.is_some(), "Should find an overlapping hunk");
// EXPECTS BEST MATCH: large overlap, NOT the first one
// (this currently FAILS with .find(), proving the bug)
assert_eq!(
matched.unwrap().id.as_str(),
"hunk-large",
"Should pick hunk with largest overlap size, not first in list"
);
}
#[test]
fn test_patch_lines_basic() {
let content = "line 1\nline 2\nline 3\nline 4\nline 5\n";
// Replace line 2 with "CHANGED"
let patched = super::patch_lines(content, 2, 1, "CHANGED\n");
assert_eq!(patched, "line 1\nCHANGED\nline 3\nline 4\nline 5\n");
}
#[test]
fn test_patch_lines_no_trailing_newline_in_insert() {
let content = "line 1\nline 2\nline 3\n";
// Replace line 2 with "CHANGED" (no trailing newline in insert text)
let patched = super::patch_lines(content, 2, 1, "CHANGED");
assert_eq!(patched, "line 1\nCHANGED\nline 3\n");
}
#[test]
fn test_patch_lines_pure_insert() {
let content = "line 1\nline 2\nline 3\n";
// Insert at line 2 without removing anything
let patched = super::patch_lines(content, 2, 0, "INSERTED\n");
assert_eq!(patched, "line 1\nINSERTED\nline 2\nline 3\n");
}
#[test]
fn test_patch_lines_pure_delete() {
let content = "line 1\nline 2\nline 3\n";
// Delete line 2 without inserting anything
let patched = super::patch_lines(content, 2, 1, "");
assert_eq!(patched, "line 1\nline 3\n");
}
#[test]
fn test_patch_lines_multiple_lines() {
let content = "line 1\nline 2\nline 3\nline 4\nline 5\n";
// Replace lines 2-3 with 2 new lines
let patched = super::patch_lines(content, 2, 2, "NEW A\nNEW B\n");
assert_eq!(patched, "line 1\nNEW A\nNEW B\nline 4\nline 5\n");
}
#[test]
fn test_generate_hunk_patch_includes_context_and_headers() {
let baseline = "line 1\nline 2\nline 3\nline 4\nline 5\n";
let current = "line 1\nline 2\nchanged line 3\nline 4\nline 5\n";
let hunk = Hunk {
id: HunkId::new(),
path: "test.rs".into(),
line_info: HunkLineInfo {
old_start: 3,
old_count: 1,
new_start: 3,
new_count: 1,
},
source: agent_source(),
old_text: Some("line 3\n".to_string()),
new_text: "changed line 3\n".to_string(),
patch: None,
created_at: chrono::Utc::now(),
selected: false,
};
let patch = generate_hunk_patch(baseline, current, &hunk);
assert!(patch.starts_with("@@ -1,5 +1,5 @@"));
assert!(patch.contains(" line 1"));
assert!(patch.contains(" line 2"));
assert!(patch.contains("-line 3"));
assert!(patch.contains("+changed line 3"));
assert!(patch.contains(" line 4"));
assert!(patch.contains(" line 5"));
}
#[test]
fn test_generate_hunk_patch_multiple_hunks_with_add_and_delete() {
let baseline = "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\n";
let current = "line 1\nline 2\nadded line 2a\nline 3\nline 5\nline 6\nline 7\n";
let hunks = compute_hunks(Path::new("test.rs"), baseline, current, agent_source());
assert_eq!(hunks.len(), 2, "Should have one add and one delete hunk");
let add_hunk = hunks
.iter()
.find(|h| h.old_text.is_none())
.expect("Add hunk should exist");
let delete_hunk = hunks
.iter()
.find(|h| h.new_text.is_empty())
.expect("Delete hunk should exist");
let add_patch = generate_hunk_patch(baseline, current, add_hunk);
assert!(add_patch.contains("+added line 2a"));
assert!(!add_patch.contains("-line 2"));
let delete_patch = generate_hunk_patch(baseline, current, delete_hunk);
assert!(delete_patch.contains("-line 4"));
assert!(!delete_patch.contains("+line 4"));
}
#[test]
fn test_compute_hunks_after_accept_simulation() {
// Simulate what happens after accepting one hunk and diffing
// This simulates the scenario in test_sequential_accepts_preserve_remaining_hunks
// Patched baseline (after accepting HUNK_A at line 2)
let patched_baseline = "line 1\nHUNK_A\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\n";
// Current content (has all 3 changes)
let current = "line 1\nHUNK_A\nline 3\nline 4\nline 5\nline 6\nHUNK_B\nline 8\nline 9\nline 10\nHUNK_C\nline 12\n";
let hunks = compute_hunks(
Path::new("test.rs"),
patched_baseline,
current,
agent_source(),
);
// Should produce 2 hunks: one at line 7, one at line 11
assert_eq!(
hunks.len(),
2,
"Should produce 2 hunks after accepting first one"
);
// Verify the hunks are at the expected positions
assert_eq!(
hunks[0].line_info.old_start, 7,
"First hunk should be at line 7"
);
assert_eq!(hunks[0].new_text, "HUNK_B\n", "First hunk should be HUNK_B");
assert_eq!(
hunks[1].line_info.old_start, 11,
"Second hunk should be at line 11"
);
assert_eq!(
hunks[1].new_text, "HUNK_C\n",
"Second hunk should be HUNK_C"
);
}
}

View file

@ -0,0 +1,74 @@
//! Events emitted by the HunkTrackerActor.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use crate::types::{Hunk, HunkId, HunkLineInfo, HunkSource};
/// Why a hunk was removed. Used by the LOC sink to decide whether to
/// negate the hunk's accumulated LOC contribution.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HunkRemovalReason {
/// User accepted the hunk — lines are kept in the file and should
/// still count toward the author's LOC total.
Accepted,
/// User rejected/reverted the hunk — changes are undone, LOC should
/// be zeroed out.
Rejected,
/// Hunk was replaced during recomputation (overlapping edit created
/// a new hunk), baseline reset, or file cleanup. LOC should be
/// zeroed out (the replacement hunk has its own records).
Superseded,
}
/// Events emitted by the HunkTrackerActor when hunks change.
/// Sent via the update_tx channel to subscribers.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum HunkEvent {
/// A new hunk was created
HunkAdded { path: PathBuf, hunk: Arc<Hunk> },
/// A hunk was removed.
HunkRemoved {
path: PathBuf,
hunk_id: HunkId,
reason: HunkRemovalReason,
},
/// A hunk's position changed but content is the same
HunkMoved {
path: PathBuf,
hunk_id: HunkId,
new_line_info: HunkLineInfo,
},
/// A hunk's content changed in place (overlapping region, same hunk ID).
/// Emitted when an edit modifies a hunk without fully removing/recreating it.
///
/// `trigger_source` is the source of the *edit that triggered* this change
/// (before source-preservation logic). This lets LOC tracking attribute
/// the change correctly even when the hunk's own `source` field was
/// preserved from a prior agent edit.
///
/// `prev_lines_added` / `prev_lines_removed` are the line counts from the
/// previous version of this hunk, so the LOC sink can compute the delta
/// (new - prev) and attribute only the incremental change.
HunkContentChanged {
path: PathBuf,
hunk: Arc<Hunk>,
trigger_source: HunkSource,
prev_lines_added: usize,
prev_lines_removed: usize,
},
/// A file started being tracked
FileAdded { path: PathBuf, is_agent_file: bool },
/// A file stopped being tracked (all hunks gone, not an agent file)
FileRemoved { path: PathBuf },
/// Baseline was updated for a file (after accept or commit)
BaselineUpdated { path: PathBuf },
}

View file

@ -0,0 +1,303 @@
//! Handle to communicate with HunkTrackerActor.
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::{mpsc, oneshot};
use crate::commands::HunkTrackerCommand;
use crate::types::{
FileContentEntry, FileHunkData, Hunk, HunkAction, HunkActionError, HunkId, HunkSourceFilter,
HunkTrackerSnapshot, HunkTurnDelta, SessionSummary, TrackingMode,
};
/// Handle to communicate with HunkTrackerActor.
/// This is cheap to clone and can be shared across tasks.
#[derive(Clone)]
pub struct HunkTrackerHandle {
cmd_tx: mpsc::UnboundedSender<HunkTrackerCommand>,
}
impl HunkTrackerHandle {
/// Create a new handle with the given command sender.
pub(crate) fn new(cmd_tx: mpsc::UnboundedSender<HunkTrackerCommand>) -> Self {
Self { cmd_tx }
}
/// Create a no-op handle that discards all commands.
/// Useful for tests and situations where hunk tracking is not needed.
pub fn noop() -> Self {
let (cmd_tx, _cmd_rx) = mpsc::unbounded_channel();
// The receiver is dropped immediately, so all sends will return Err
// but since we use `let _ = send(...)` everywhere, this is fine.
Self { cmd_tx }
}
/// Record that an agent tool wrote to a file.
/// This is fire-and-forget - doesn't wait for processing.
///
/// `previous_content` is the file content before this write (if known).
/// It is used as a fallback baseline when the file doesn't exist in git HEAD
/// (e.g., in worktrees created from dirty state).
pub fn record_agent_write(
&self,
path: PathBuf,
content: String,
prompt_index: usize,
previous_content: Option<String>,
) {
let _ = self.cmd_tx.send(HunkTrackerCommand::RecordAgentWrite {
path,
content,
prompt_index,
previous_content,
});
}
/// Notify of file change from fs_notify.
pub fn handle_file_change(&self, path: PathBuf) {
let _ = self
.cmd_tx
.send(HunkTrackerCommand::HandleFileChange { path });
}
/// Notify of file deletion from fs_notify.
pub fn handle_file_deleted(&self, path: PathBuf) {
let _ = self
.cmd_tx
.send(HunkTrackerCommand::HandleFileDeleted { path });
}
/// Refresh git dirty cache.
pub fn refresh_git_dirty_cache(&self) {
let _ = self.cmd_tx.send(HunkTrackerCommand::RefreshGitDirtyCache);
}
/// Reset baseline for a file (after commit).
pub fn reset_baseline(&self, path: PathBuf) {
let _ = self.cmd_tx.send(HunkTrackerCommand::ResetBaseline { path });
}
/// Set tracking mode.
pub fn set_mode(&self, mode: TrackingMode) {
let _ = self.cmd_tx.send(HunkTrackerCommand::SetMode { mode });
}
/// Apply action (accept/reject) to a specific hunk.
pub async fn hunk_action(
&self,
hunk_id: HunkId,
action: HunkAction,
) -> Result<(), HunkActionError> {
let (reply_tx, reply_rx) = oneshot::channel();
let _ = self.cmd_tx.send(HunkTrackerCommand::HunkAction {
hunk_id: hunk_id.clone(),
action,
reply: reply_tx,
});
reply_rx
.await
.unwrap_or(Err(HunkActionError::HunkNotFound(hunk_id)))
}
/// Apply action (accept/reject) to all hunks for a file.
pub async fn file_action(
&self,
path: PathBuf,
action: HunkAction,
) -> Result<Vec<HunkId>, HunkActionError> {
let (reply_tx, reply_rx) = oneshot::channel();
let _ = self.cmd_tx.send(HunkTrackerCommand::FileAction {
path,
action,
reply: reply_tx,
});
reply_rx.await.unwrap_or_else(|_| Ok(vec![]))
}
/// Apply action (accept/reject) to all hunks.
pub async fn all_action(&self, action: HunkAction) -> Result<Vec<HunkId>, HunkActionError> {
let (reply_tx, reply_rx) = oneshot::channel();
let _ = self.cmd_tx.send(HunkTrackerCommand::AllAction {
action,
reply: reply_tx,
});
reply_rx.await.unwrap_or_else(|_| Ok(vec![]))
}
/// Apply action (accept/reject) to all hunks for a specific turn.
pub async fn turn_action(
&self,
prompt_index: usize,
action: HunkAction,
) -> Result<Vec<HunkId>, HunkActionError> {
let (reply_tx, reply_rx) = oneshot::channel();
let _ = self.cmd_tx.send(HunkTrackerCommand::TurnAction {
prompt_index,
action,
reply: reply_tx,
});
reply_rx.await.unwrap_or_else(|_| Ok(vec![]))
}
/// Get all hunks.
pub async fn get_all_hunks(&self) -> Vec<Arc<Hunk>> {
let (reply_tx, reply_rx) = oneshot::channel();
let _ = self
.cmd_tx
.send(HunkTrackerCommand::GetAllHunks { reply: reply_tx });
reply_rx.await.unwrap_or_default()
}
/// Get hunks for a specific path.
pub async fn get_hunks_for_path(&self, path: PathBuf) -> Vec<Arc<Hunk>> {
let (reply_tx, reply_rx) = oneshot::channel();
let _ = self.cmd_tx.send(HunkTrackerCommand::GetHunksForPath {
path,
reply: reply_tx,
});
reply_rx.await.unwrap_or_default()
}
/// Get hunks + file content for a specific path (for diff rendering).
pub async fn get_file_hunk_data(&self, path: PathBuf) -> FileHunkData {
let (reply_tx, reply_rx) = oneshot::channel();
let _ = self.cmd_tx.send(HunkTrackerCommand::GetFileHunkData {
path,
reply: reply_tx,
});
reply_rx.await.unwrap_or_default()
}
/// Get hunks by source.
pub async fn get_hunks_by_source(&self, source: HunkSourceFilter) -> Vec<Arc<Hunk>> {
let (reply_tx, reply_rx) = oneshot::channel();
let _ = self.cmd_tx.send(HunkTrackerCommand::GetHunksBySource {
source,
reply: reply_tx,
});
reply_rx.await.unwrap_or_default()
}
/// Get a specific hunk by ID.
pub async fn get_hunk(&self, hunk_id: HunkId) -> Option<Arc<Hunk>> {
let (reply_tx, reply_rx) = oneshot::channel();
let _ = self.cmd_tx.send(HunkTrackerCommand::GetHunk {
hunk_id,
reply: reply_tx,
});
reply_rx.await.ok().flatten()
}
/// Get all tracked file paths (agent + external), regardless of hunk state.
///
/// Returns every path the hunk tracker knows about — agent writes,
/// fs_notify-detected external edits, and git-dirty files (in `AllDirty`
/// mode). Entries persist after the user accepts/rejects every hunk.
///
/// Use this for worktree replication where ALL changes matter, not just
/// agent-attributed ones (the agent may have created files via terminal
/// commands like `echo`, `cp`, `mv`, etc.).
pub async fn get_all_tracked_paths(&self) -> Vec<PathBuf> {
let (reply_tx, reply_rx) = oneshot::channel();
let _ = self
.cmd_tx
.send(HunkTrackerCommand::GetAllTrackedPaths { reply: reply_tx });
reply_rx.await.unwrap_or_default()
}
/// Get staged file paths (absolute) — files with HEAD→index changes in
/// git. In AllDirty mode this is repo-wide; in AgentOnly mode the
/// underlying scan is scoped to tracked paths, so only their staged
/// state is reported.
pub async fn get_staged_files(&self) -> HashSet<PathBuf> {
let (reply_tx, reply_rx) = oneshot::channel();
let _ = self
.cmd_tx
.send(HunkTrackerCommand::GetStagedFiles { reply: reply_tx });
reply_rx.await.unwrap_or_default()
}
/// Get baseline, current content, agent flag, and staged flag for every
/// tracked file in a single call. Pure in-memory — no git I/O.
pub async fn get_all_file_contents(&self) -> Vec<FileContentEntry> {
let (reply_tx, reply_rx) = oneshot::channel();
let _ = self
.cmd_tx
.send(HunkTrackerCommand::GetAllFileContents { reply: reply_tx });
reply_rx.await.unwrap_or_default()
}
/// Check if path is an agent file.
pub async fn is_agent_file(&self, path: PathBuf) -> bool {
let (reply_tx, reply_rx) = oneshot::channel();
let _ = self.cmd_tx.send(HunkTrackerCommand::IsAgentFile {
path,
reply: reply_tx,
});
reply_rx.await.unwrap_or(false)
}
/// Get complete session summary (stats + pending turns).
pub async fn get_session_summary(&self) -> SessionSummary {
let (reply_tx, reply_rx) = oneshot::channel();
let _ = self
.cmd_tx
.send(HunkTrackerCommand::GetSessionSummary { reply: reply_tx });
reply_rx.await.unwrap_or_default()
}
/// Get pending hunks for a specific turn.
pub async fn get_turn_hunks(&self, prompt_index: usize) -> Vec<Arc<Hunk>> {
let (reply_tx, reply_rx) = oneshot::channel();
let _ = self.cmd_tx.send(HunkTrackerCommand::GetTurnHunks {
prompt_index,
reply: reply_tx,
});
reply_rx.await.unwrap_or_default()
}
/// Reset session stats (e.g., after commit).
pub fn reset_stats(&self) {
let _ = self.cmd_tx.send(HunkTrackerCommand::ResetStats);
}
/// Refresh all baselines from the current git HEAD and re-read current
/// content from disk. Call this after a git HEAD/index change to
/// reconcile stale baselines and fix phantom "file deleted" hunks.
pub fn refresh_all_baselines(&self) {
let _ = self.cmd_tx.send(HunkTrackerCommand::RefreshAllBaselines);
}
/// Take a snapshot of all hunk tracker state for preservation across
/// session kill/reload cycles (e.g., fork sync-back).
///
/// Returns `None` if the actor has been shut down.
pub async fn snapshot_state(&self) -> Option<HunkTrackerSnapshot> {
let (reply_tx, reply_rx) = oneshot::channel();
let _ = self
.cmd_tx
.send(HunkTrackerCommand::SnapshotState { reply: reply_tx });
reply_rx.await.ok()
}
/// Incremental single-turn delta for `prompt_index`: snapshots of the files
/// touched that turn plus its hunk-id set. Per-prompt counterpart to
/// [`snapshot_state`](Self::snapshot_state). `None` if the actor is shut down.
pub async fn snapshot_turn_delta(&self, prompt_index: usize) -> Option<HunkTurnDelta> {
let (reply_tx, reply_rx) = oneshot::channel();
let _ = self.cmd_tx.send(HunkTrackerCommand::SnapshotTurnDelta {
prompt_index,
reply: reply_tx,
});
reply_rx.await.ok()
}
/// Restore a previously snapshotted state. Replaces all current file
/// states, turn index, and session stats in the actor.
///
/// This is fire-and-forget — doesn't wait for processing.
pub fn restore_state(&self, snapshot: HunkTrackerSnapshot) {
let _ = self.cmd_tx.send(HunkTrackerCommand::RestoreState(snapshot));
}
}

View file

@ -0,0 +1,82 @@
//! xai-hunk-tracker - Track file hunks (diffs) with agent/external attribution.
//!
//! This crate provides:
//! - Actor-based hunk tracking with source attribution (Agent vs External)
//! - Integration with grok-shell sessions
//!
//! ## Actor Pattern
//!
//! The HunkTracker uses an actor pattern with message-passing via channels:
//!
//! ```text
//! ┌────────────────┐ ┌──────────────────────────────────────┐
//! │ Agent Tool │ ─── Command ───▶ │ HunkTrackerActor │
//! │ (search_ │ │ (runs in dedicated tokio task) │
//! │ replace) │ │ │
//! └────────────────┘ │ State (no locks needed): │
//! │ - file_states: HashMap │
//! ┌────────────────┐ │ - git_dirty_cache: HashSet │
//! │ fs_notify │ ─── Command ───▶ │ - mode: TrackingMode │
//! │ event loop │ │ │
//! └────────────────┘ │ │ HunkEvent │
//! │ ▼ │
//! ┌────────────────┐ │ ┌──────────────────┐ │
//! │ Query (e.g. │ ── Cmd+Oneshot ─▶│ │ event_tx │───▶ Client │
//! │ get_hunks) │ ◀── Response ────│ └──────────────────┘ │
//! └────────────────┘ └──────────────────────────────────────┘
//! ```
//!
//! ## Usage
//!
//! ```rust,ignore
//! use xai_hunk_tracker::{HunkTrackerActor, HunkEvent, TrackingMode, HunkAction};
//! use tokio::sync::mpsc;
//!
//! // Create event channel
//! let (event_tx, mut event_rx) = mpsc::unbounded_channel();
//!
//! // Spawn actor and get handle
//! let handle = HunkTrackerActor::spawn(
//! session_id,
//! working_dir,
//! event_tx,
//! TrackingMode::AllDirty,
//! cancellation_token,
//! );
//!
//! // Record agent writes
//! handle.record_agent_write(path, content, prompt_index);
//!
//! // Query hunks
//! let hunks = handle.get_all_hunks().await;
//!
//! // Apply actions
//! handle.hunk_action(hunk_id, HunkAction::Accept).await;
//!
//! // Listen for events
//! while let Some(event) = event_rx.recv().await {
//! match event {
//! HunkEvent::HunkAdded { path, hunk } => { /* ... */ }
//! HunkEvent::HunkRemoved { path, hunk_id } => { /* ... */ }
//! _ => {}
//! }
//! }
//! ```
pub mod actor;
pub mod commands;
pub mod diff;
pub mod events;
pub mod handle;
pub mod loc;
pub mod types;
// Re-export main types for convenience
pub use actor::{HunkTrackerActor, REFRESH_SCAN_LOG_PREFIX, REFRESH_SKIP_LOG_PREFIX};
pub use events::{HunkEvent, HunkRemovalReason};
pub use handle::HunkTrackerHandle;
pub use loc::{
AuthorType, EventType, HunkRecord, HunkRecordWriter, JsonlHunkRecordWriter, LocAggregate,
LocSinkContext, SourceType, run_loc_sink,
};
pub use types::*;

View file

@ -0,0 +1,545 @@
//! LOC (Lines of Code) tracking — hunk-level attribution records.
//!
//! This module provides:
//! - [`HunkRecord`]: a serializable attribution record derived from a [`Hunk`].
//! - [`HunkRecordWriter`] / [`JsonlHunkRecordWriter`]: append-only JSONL persistence.
//! - [`run_loc_sink`]: an async task that consumes [`HunkEvent`]s and writes records.
#[cfg(test)]
mod tests;
use std::collections::HashMap;
use std::path::PathBuf;
use crate::events::{HunkEvent, HunkRemovalReason};
use crate::types::{Hunk, HunkId, HunkSource};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
// ---------------------------------------------------------------------------
// Enums
// ---------------------------------------------------------------------------
/// Who authored a change.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum AuthorType {
Agent,
Human,
}
impl std::fmt::Display for AuthorType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Agent => f.write_str("agent"),
Self::Human => f.write_str("human"),
}
}
}
/// Mirror of [`HunkSource`] for serialization.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SourceType {
AgentEdit,
ExternalEditOnAgentFile,
External,
}
impl std::fmt::Display for SourceType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AgentEdit => f.write_str("agent_edit"),
Self::ExternalEditOnAgentFile => f.write_str("external_edit_on_agent_file"),
Self::External => f.write_str("external"),
}
}
}
/// Whether a record represents a new hunk or an in-place update.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum EventType {
/// A new hunk was created.
Added,
/// An existing hunk's content changed in place.
Updated,
/// A hunk was removed. `lines_added` / `lines_removed` are negated
/// so that `SUM` zeroes out the hunk's accumulated contribution.
Removed,
}
impl std::fmt::Display for EventType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Added => f.write_str("added"),
Self::Updated => f.write_str("updated"),
Self::Removed => f.write_str("removed"),
}
}
}
// ---------------------------------------------------------------------------
// HunkRecord
// ---------------------------------------------------------------------------
/// A single LOC attribution record derived from a [`Hunk`].
///
/// Each record captures who authored a hunk (agent vs human), along with
/// enough context (session, file, line range) for downstream analytics.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct HunkRecord {
/// Stable hunk identifier (UUID).
pub hunk_id: HunkId,
/// Absolute file path.
pub file_path: PathBuf,
/// Start line of the hunk in the new file (1-indexed).
pub hunk_start: usize,
/// End line of the hunk in the new file (inclusive).
pub hunk_end: usize,
/// Lines added. For [`EventType::Added`] this is the full count (≥ 0).
/// For [`EventType::Updated`] this is the delta from the previous state
/// and may be negative (hunk shrank).
pub lines_added: i64,
/// Lines removed. For [`EventType::Added`] this is the full count (≥ 0).
/// For [`EventType::Updated`] this is the delta and may be negative.
pub lines_removed: i64,
/// Who authored this change. `None` for [`EventType::Removed`] records.
#[serde(skip_serializing_if = "Option::is_none")]
pub author_type: Option<AuthorType>,
/// For agent edits: the agent id. For human edits: the user id (if known).
#[serde(skip_serializing_if = "Option::is_none")]
pub author_id: Option<String>,
/// Machine-level agent identifier.
pub agent_id: String,
/// Session that produced this hunk.
pub session_id: String,
/// When the hunk was first detected.
pub timestamp: DateTime<Utc>,
/// Prompt index for agent edits, `None` for human edits.
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_index: Option<usize>,
/// Which [`HunkSource`] variant produced this change. `None` for [`EventType::Removed`] records.
#[serde(skip_serializing_if = "Option::is_none")]
pub source_type: Option<SourceType>,
/// Whether this is a new hunk or an in-place update.
pub event_type: EventType,
/// Why the hunk was removed. Only set for [`EventType::Removed`] records.
#[serde(skip_serializing_if = "Option::is_none")]
pub removal_reason: Option<HunkRemovalReason>,
}
impl HunkRecord {
/// Derive a [`HunkRecord`] from a [`Hunk`].
///
/// `agent_id` is the stable machine-level identifier.
/// `user_id` is the authenticated user id (used for human-attributed hunks).
/// `event_type` distinguishes new hunks from in-place updates.
///
/// `attribution_source` controls which [`HunkSource`] is used for author
/// attribution. For `HunkAdded` events this is `hunk.source`. For
/// `HunkContentChanged` events this should be the *trigger* source
/// (the source of the edit that caused the change), not the hunk's
/// preserved source, since source-preservation logic may have kept the
/// original agent attribution even though a human made the edit.
pub fn from_hunk(
hunk: &Hunk,
session_id: &str,
agent_id: &str,
user_id: Option<&str>,
event_type: EventType,
attribution_source: &HunkSource,
) -> Self {
let (author_type, author_id, prompt_index, source_type) = match *attribution_source {
HunkSource::AgentEdit { prompt_index } => (
AuthorType::Agent,
Some(agent_id.to_owned()),
Some(prompt_index),
SourceType::AgentEdit,
),
HunkSource::ExternalEditOnAgentFile => (
AuthorType::Human,
user_id.map(str::to_owned),
None,
SourceType::ExternalEditOnAgentFile,
),
HunkSource::External => (
AuthorType::Human,
user_id.map(str::to_owned),
None,
SourceType::External,
),
};
// For pure deletions (new_count == 0) use old_start/old_count.
let (start, count) = if hunk.line_info.new_count == 0 {
(hunk.line_info.old_start, hunk.line_info.old_count)
} else {
(hunk.line_info.new_start, hunk.line_info.new_count)
};
let end = if count == 0 { start } else { start + count - 1 };
Self {
hunk_id: hunk.id.clone(),
file_path: hunk.path.clone(),
hunk_start: start,
hunk_end: end,
lines_added: hunk.line_info.new_count as i64,
lines_removed: hunk.line_info.old_count as i64,
author_type: Some(author_type),
author_id,
agent_id: agent_id.to_owned(),
session_id: session_id.to_owned(),
timestamp: hunk.created_at,
prompt_index,
source_type: Some(source_type),
event_type,
removal_reason: None,
}
}
}
// ---------------------------------------------------------------------------
// HunkRecordWriter
// ---------------------------------------------------------------------------
/// Trait for persisting [`HunkRecord`]s.
///
/// Implementations may write to JSONL files, databases, etc.
///
/// All returned futures must be `Send` because `run_loc_sink` is spawned
/// via `tokio::spawn` (which may run the task on any thread in the pool).
pub trait HunkRecordWriter: Send {
/// Write a single record. Errors are non-fatal; callers log and continue.
fn write(
&mut self,
record: &HunkRecord,
) -> impl std::future::Future<Output = std::io::Result<()>> + Send;
/// Flush any buffered data. Called during shutdown.
fn flush(&mut self) -> impl std::future::Future<Output = std::io::Result<()>> + Send;
}
/// Append-only JSONL writer for [`HunkRecord`]s.
///
/// The file is opened lazily on the first write so that sessions that produce
/// no hunk events never create an empty file on disk.
pub struct JsonlHunkRecordWriter {
path: PathBuf,
file: Option<tokio::fs::File>,
}
impl JsonlHunkRecordWriter {
/// Create a writer that will append to the given path.
///
/// The parent directory is created on the first write if it does not exist.
pub fn new(path: PathBuf) -> Self {
Self { path, file: None }
}
/// Lazily open (or create) the file in append mode.
async fn ensure_open(&mut self) -> std::io::Result<&mut tokio::fs::File> {
if self.file.is_none() {
if let Some(parent) = self.path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let file = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
.await?;
self.file = Some(file);
}
// The `if` block above guarantees `self.file` is `Some` at this point.
Ok(self.file.as_mut().unwrap())
}
}
impl HunkRecordWriter for JsonlHunkRecordWriter {
async fn write(&mut self, record: &HunkRecord) -> std::io::Result<()> {
use tokio::io::AsyncWriteExt;
let file = self.ensure_open().await?;
let mut line = serde_json::to_string(record)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
line.push('\n');
file.write_all(line.as_bytes()).await?;
Ok(())
}
async fn flush(&mut self) -> std::io::Result<()> {
use tokio::io::AsyncWriteExt;
if let Some(file) = self.file.as_mut() {
file.flush().await?;
}
Ok(())
}
}
// ---------------------------------------------------------------------------
// LocAggregate (channel-based bridge to signals)
// ---------------------------------------------------------------------------
/// Lightweight aggregate update emitted by the LOC sink for consumption by
/// an external bridge (e.g., the signals system in `xai-grok-shell`).
///
/// The sink sends one of these per processed `HunkEvent` that affects LOC.
/// The bridge task translates them into `SignalEvent` variants.
#[derive(Debug, Clone)]
pub enum LocAggregate {
/// Lines were added or changed (from HunkAdded or HunkContentChanged).
LinesChanged {
author_type: AuthorType,
lines_added: i64,
lines_removed: i64,
file_path: PathBuf,
},
/// A hunk was reverted (rejected or superseded). The values are the
/// accumulated totals that were zeroed out — always non-negative.
LinesReverted {
lines_added_reverted: i64,
lines_removed_reverted: i64,
},
}
// ---------------------------------------------------------------------------
// Sink configuration
// ---------------------------------------------------------------------------
/// Context passed to the LOC sink at spawn time.
pub struct LocSinkContext {
/// Session identifier.
pub session_id: String,
/// Stable machine-level agent identifier.
pub agent_id: String,
/// Authenticated user id (if available). Used for human-attributed records.
pub user_id: Option<String>,
/// Optional channel for emitting LOC aggregates to an external consumer
/// (e.g., the session signals system). When `None`, only JSONL is written.
pub aggregate_tx: Option<mpsc::UnboundedSender<LocAggregate>>,
}
// ---------------------------------------------------------------------------
// run_loc_sink
// ---------------------------------------------------------------------------
/// Consume [`HunkEvent`]s and write LOC attribution records.
///
/// This is the main entry point for the LOC tracking pipeline. It runs as a
/// long-lived async task and should be spawned via `tokio::spawn`.
///
/// The sink maintains a `HashMap<HunkId, (i64, i64)>` tracking accumulated
/// `(lines_added, lines_removed)` per hunk. When a `HunkRemoved` event
/// arrives, the accumulated total is negated and written as a `Removed`
/// record, zeroing out the hunk's contribution in SUM-based totals.
///
/// On cancellation the task drains any remaining events from the channel so
/// that no in-flight records are lost.
pub async fn run_loc_sink(
mut event_rx: mpsc::UnboundedReceiver<HunkEvent>,
mut writer: impl HunkRecordWriter,
ctx: LocSinkContext,
cancellation_token: tokio_util::sync::CancellationToken,
) {
// Accumulated (lines_added, lines_removed) per hunk_id.
// Used to emit negating records when hunks are rejected/superseded.
let mut acc: HashMap<HunkId, (i64, i64)> = HashMap::new();
loop {
tokio::select! {
biased;
_ = cancellation_token.cancelled() => {
tracing::debug!("LOC sink: cancellation received, draining remaining events");
drain_remaining(&mut event_rx, &mut writer, &ctx, &mut acc).await;
break;
}
event = event_rx.recv() => {
let Some(event) = event else {
// Channel closed — sender dropped.
tracing::debug!("LOC sink: event channel closed");
break;
};
handle_event(event, &mut writer, &ctx, &mut acc).await;
}
}
}
if let Err(e) = writer.flush().await {
tracing::warn!(error = %e, "LOC sink: failed to flush writer on shutdown");
}
tracing::debug!("LOC sink: exiting");
}
/// Process a single [`HunkEvent`].
async fn handle_event(
event: HunkEvent,
writer: &mut impl HunkRecordWriter,
ctx: &LocSinkContext,
acc: &mut HashMap<HunkId, (i64, i64)>,
) {
match event {
HunkEvent::HunkAdded { path: _, ref hunk } => {
// For new hunks, the hunk's own source is the correct attribution.
// lines_added/lines_removed are the full counts (no prior state).
let record = HunkRecord::from_hunk(
hunk,
&ctx.session_id,
&ctx.agent_id,
ctx.user_id.as_deref(),
EventType::Added,
&hunk.source,
);
let entry = acc.entry(hunk.id.clone()).or_insert((0, 0));
entry.0 += record.lines_added;
entry.1 += record.lines_removed;
// Emit aggregate for signals bridge
if let Some(tx) = &ctx.aggregate_tx {
let _ = tx.send(LocAggregate::LinesChanged {
author_type: record.author_type.unwrap_or(AuthorType::Agent),
lines_added: record.lines_added,
lines_removed: record.lines_removed,
file_path: record.file_path.clone(),
});
}
write_record(&record, writer).await;
}
HunkEvent::HunkContentChanged {
path: _,
ref hunk,
trigger_source,
prev_lines_added,
prev_lines_removed,
} => {
// For in-place changes, use the trigger source for attribution
// and record only the delta (new - prev) so LOC totals can be
// computed with a simple SUM grouped by author_type.
let mut record = HunkRecord::from_hunk(
hunk,
&ctx.session_id,
&ctx.agent_id,
ctx.user_id.as_deref(),
EventType::Updated,
&trigger_source,
);
// Replace full counts with signed deltas so shrinking hunks
// (e.g., human deletes 3 of 10 agent lines) produce negative
// values that correctly reduce the total on SUM.
record.lines_added = hunk.line_info.new_count as i64 - prev_lines_added as i64;
record.lines_removed = hunk.line_info.old_count as i64 - prev_lines_removed as i64;
let entry = acc.entry(hunk.id.clone()).or_insert((0, 0));
entry.0 += record.lines_added;
entry.1 += record.lines_removed;
// Emit aggregate for signals bridge
if let Some(tx) = &ctx.aggregate_tx {
let _ = tx.send(LocAggregate::LinesChanged {
author_type: record.author_type.unwrap_or(AuthorType::Human),
lines_added: record.lines_added,
lines_removed: record.lines_removed,
file_path: record.file_path.clone(),
});
}
write_record(&record, writer).await;
}
HunkEvent::HunkRemoved {
path,
hunk_id,
reason,
} => {
match reason {
HunkRemovalReason::Accepted => {
// Accepted hunks keep their LOC contribution — just
// clear the accumulated state without writing a
// negating record.
acc.remove(&hunk_id);
}
HunkRemovalReason::Rejected | HunkRemovalReason::Superseded => {
// Rejected/superseded hunks lose their LOC — negate
// the accumulated totals so SUM zeroes them out.
if let Some((total_added, total_removed)) = acc.remove(&hunk_id)
&& (total_added != 0 || total_removed != 0)
{
// Emit revert aggregate for signals bridge
if let Some(tx) = &ctx.aggregate_tx {
let _ = tx.send(LocAggregate::LinesReverted {
lines_added_reverted: total_added.max(0),
lines_removed_reverted: total_removed.max(0),
});
}
let record = HunkRecord {
hunk_id: hunk_id.clone(),
file_path: path,
hunk_start: 0,
hunk_end: 0,
lines_added: -total_added,
lines_removed: -total_removed,
author_type: None,
author_id: None,
agent_id: ctx.agent_id.clone(),
session_id: ctx.session_id.clone(),
timestamp: Utc::now(),
prompt_index: None,
source_type: None,
event_type: EventType::Removed,
removal_reason: Some(reason),
};
write_record(&record, writer).await;
}
}
}
}
HunkEvent::HunkMoved { .. } => {
tracing::trace!("LOC sink: ignoring HunkMoved event");
}
HunkEvent::FileAdded { .. } => {
tracing::trace!("LOC sink: ignoring FileAdded event");
}
HunkEvent::FileRemoved { .. } => {
tracing::trace!("LOC sink: ignoring FileRemoved event");
}
HunkEvent::BaselineUpdated { .. } => {
tracing::trace!("LOC sink: ignoring BaselineUpdated event");
}
}
}
/// Write a [`HunkRecord`], logging on success/failure.
async fn write_record(record: &HunkRecord, writer: &mut impl HunkRecordWriter) {
if let Err(e) = writer.write(record).await {
tracing::warn!(
error = %e,
hunk_id = %record.hunk_id,
file_path = %record.file_path.display(),
"LOC sink: failed to write hunk record, dropping"
);
} else {
tracing::debug!(
hunk_id = %record.hunk_id,
file_path = %record.file_path.display(),
author_type = ?record.author_type,
"LOC sink: wrote hunk record"
);
}
}
/// Drain remaining events after cancellation.
async fn drain_remaining(
event_rx: &mut mpsc::UnboundedReceiver<HunkEvent>,
writer: &mut impl HunkRecordWriter,
ctx: &LocSinkContext,
acc: &mut HashMap<HunkId, (i64, i64)>,
) {
let mut count = 0usize;
while let Ok(event) = event_rx.try_recv() {
handle_event(event, writer, ctx, acc).await;
count += 1;
}
if count > 0 {
tracing::debug!(
count,
"LOC sink: drained remaining events after cancellation"
);
}
}

View file

@ -0,0 +1,782 @@
use std::path::PathBuf;
use std::sync::Arc;
use chrono::Utc;
use tokio::sync::mpsc;
use crate::events::HunkEvent;
use crate::types::{Hunk, HunkId, HunkLineInfo, HunkSource};
use super::*;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn sample_agent_hunk() -> Hunk {
Hunk {
id: HunkId::from_string("test-hunk-001".into()),
path: PathBuf::from("/tmp/foo.rs"),
line_info: HunkLineInfo {
old_start: 10,
old_count: 3,
new_start: 10,
new_count: 5,
},
source: HunkSource::AgentEdit { prompt_index: 2 },
old_text: Some("old\nlines\nhere".into()),
new_text: "new\nlines\nhere\nplus\nmore".into(),
patch: None,
created_at: Utc::now(),
selected: false,
}
}
fn sample_external_hunk() -> Hunk {
Hunk {
id: HunkId::from_string("test-hunk-002".into()),
path: PathBuf::from("/tmp/bar.rs"),
line_info: HunkLineInfo {
old_start: 1,
old_count: 0,
new_start: 1,
new_count: 4,
},
source: HunkSource::External,
old_text: None,
new_text: "line1\nline2\nline3\nline4".into(),
patch: None,
created_at: Utc::now(),
selected: false,
}
}
fn sample_deletion_hunk() -> Hunk {
Hunk {
id: HunkId::from_string("test-hunk-003".into()),
path: PathBuf::from("/tmp/del.rs"),
line_info: HunkLineInfo {
old_start: 5,
old_count: 3,
new_start: 0,
new_count: 0,
},
source: HunkSource::AgentEdit { prompt_index: 1 },
old_text: Some("deleted\nlines\nhere".into()),
new_text: String::new(),
patch: None,
created_at: Utc::now(),
selected: false,
}
}
/// In-memory writer for testing.
struct VecWriter {
records: Vec<HunkRecord>,
flush_count: usize,
}
impl VecWriter {
fn new() -> Self {
Self {
records: Vec::new(),
flush_count: 0,
}
}
}
impl HunkRecordWriter for VecWriter {
async fn write(&mut self, record: &HunkRecord) -> std::io::Result<()> {
self.records.push(record.clone());
Ok(())
}
async fn flush(&mut self) -> std::io::Result<()> {
self.flush_count += 1;
Ok(())
}
}
/// Thread-safe wrapper around `VecWriter` for use with `run_loc_sink`
/// (which takes ownership of the writer).
struct SharedWriter(std::sync::Arc<std::sync::Mutex<VecWriter>>);
impl SharedWriter {
fn new() -> (Self, std::sync::Arc<std::sync::Mutex<VecWriter>>) {
let inner = std::sync::Arc::new(std::sync::Mutex::new(VecWriter::new()));
(Self(inner.clone()), inner)
}
}
impl HunkRecordWriter for SharedWriter {
async fn write(&mut self, record: &HunkRecord) -> std::io::Result<()> {
// Do the work inside the lock synchronously — don't hold MutexGuard across .await
self.0.lock().unwrap().records.push(record.clone());
Ok(())
}
async fn flush(&mut self) -> std::io::Result<()> {
self.0.lock().unwrap().flush_count += 1;
Ok(())
}
}
fn make_ctx() -> LocSinkContext {
LocSinkContext {
session_id: "sess-001".into(),
agent_id: "agent-abc".into(),
user_id: Some("user-xyz".into()),
aggregate_tx: None,
}
}
// ---------------------------------------------------------------------------
// Unit tests: HunkRecord::from_hunk
// ---------------------------------------------------------------------------
#[test]
fn from_hunk_agent_edit() {
let hunk = sample_agent_hunk();
let record = HunkRecord::from_hunk(
&hunk,
"sess-1",
"agent-1",
Some("user-1"),
EventType::Added,
&hunk.source,
);
assert_eq!(record.hunk_id, HunkId::from_string("test-hunk-001".into()));
assert_eq!(record.file_path, PathBuf::from("/tmp/foo.rs"));
assert_eq!(record.hunk_start, 10);
assert_eq!(record.hunk_end, 14); // 10 + 5 - 1
assert_eq!(record.lines_added, 5);
assert_eq!(record.lines_removed, 3);
assert_eq!(record.author_type, Some(AuthorType::Agent));
assert_eq!(record.author_id, Some("agent-1".into()));
assert_eq!(record.agent_id, "agent-1");
assert_eq!(record.session_id, "sess-1");
assert_eq!(record.prompt_index, Some(2));
assert_eq!(record.source_type, Some(SourceType::AgentEdit));
assert_eq!(record.event_type, EventType::Added);
}
#[test]
fn from_hunk_event_type_updated() {
let hunk = sample_agent_hunk();
let record = HunkRecord::from_hunk(
&hunk,
"sess-1",
"agent-1",
None,
EventType::Updated,
&hunk.source,
);
assert_eq!(record.event_type, EventType::Updated);
}
#[test]
fn from_hunk_external() {
let hunk = sample_external_hunk();
let record = HunkRecord::from_hunk(
&hunk,
"sess-1",
"agent-1",
Some("user-1"),
EventType::Added,
&hunk.source,
);
assert_eq!(record.author_type, Some(AuthorType::Human));
assert_eq!(record.author_id, Some("user-1".into()));
assert_eq!(record.prompt_index, None);
assert_eq!(record.source_type, Some(SourceType::External));
assert_eq!(record.hunk_start, 1);
assert_eq!(record.hunk_end, 4); // 1 + 4 - 1
}
#[test]
fn from_hunk_external_no_user_id() {
let hunk = sample_external_hunk();
let record = HunkRecord::from_hunk(
&hunk,
"sess-1",
"agent-1",
None,
EventType::Added,
&hunk.source,
);
assert_eq!(record.author_type, Some(AuthorType::Human));
assert_eq!(record.author_id, None);
}
#[test]
fn from_hunk_external_edit_on_agent_file() {
let mut hunk = sample_external_hunk();
hunk.source = HunkSource::ExternalEditOnAgentFile;
let record = HunkRecord::from_hunk(
&hunk,
"sess-1",
"agent-1",
Some("user-1"),
EventType::Added,
&hunk.source,
);
assert_eq!(record.author_type, Some(AuthorType::Human));
assert_eq!(
record.source_type,
Some(SourceType::ExternalEditOnAgentFile)
);
}
#[test]
fn from_hunk_pure_deletion() {
let hunk = sample_deletion_hunk();
let record = HunkRecord::from_hunk(
&hunk,
"sess-1",
"agent-1",
None,
EventType::Added,
&hunk.source,
);
// Pure deletion: new_count == 0, so uses old_start/old_count
assert_eq!(record.hunk_start, 5);
assert_eq!(record.hunk_end, 7); // 5 + 3 - 1
assert_eq!(record.lines_added, 0i64);
assert_eq!(record.lines_removed, 3i64);
}
/// Verify that attribution_source overrides the hunk's preserved source.
#[test]
fn from_hunk_trigger_source_overrides_preserved_source() {
let hunk = sample_agent_hunk(); // hunk.source = AgentEdit
let trigger = HunkSource::ExternalEditOnAgentFile;
let record = HunkRecord::from_hunk(
&hunk,
"sess-1",
"agent-1",
Some("user-1"),
EventType::Updated,
&trigger,
);
assert_eq!(record.author_type, Some(AuthorType::Human));
assert_eq!(
record.source_type,
Some(SourceType::ExternalEditOnAgentFile)
);
assert_eq!(record.author_id, Some("user-1".into()));
assert_eq!(record.prompt_index, None);
assert_eq!(record.event_type, EventType::Updated);
}
// ---------------------------------------------------------------------------
// Sink tests
// ---------------------------------------------------------------------------
#[tokio::test]
async fn sink_processes_added_and_content_changed() {
let (tx, rx) = mpsc::unbounded_channel();
let ctx = make_ctx();
let cancel = tokio_util::sync::CancellationToken::new();
let hunk = sample_agent_hunk();
let mut updated_hunk = sample_agent_hunk();
updated_hunk.line_info.new_count = 8; // grew from 5 to 8 lines
// Send a mix of events — only HunkAdded and HunkContentChanged should produce records
tx.send(HunkEvent::FileAdded {
path: PathBuf::from("/tmp/foo.rs"),
is_agent_file: true,
})
.unwrap();
tx.send(HunkEvent::HunkAdded {
path: PathBuf::from("/tmp/foo.rs"),
hunk: Arc::new(hunk),
})
.unwrap();
tx.send(HunkEvent::HunkContentChanged {
path: PathBuf::from("/tmp/foo.rs"),
hunk: Arc::new(updated_hunk),
trigger_source: HunkSource::AgentEdit { prompt_index: 2 },
prev_lines_added: 5, // original hunk had 5 lines added
prev_lines_removed: 3, // original hunk had 3 lines removed
})
.unwrap();
tx.send(HunkEvent::HunkMoved {
path: PathBuf::from("/tmp/foo.rs"),
hunk_id: HunkId::from_string("test-hunk-001".into()),
new_line_info: HunkLineInfo {
old_start: 10,
old_count: 3,
new_start: 12,
new_count: 5,
},
})
.unwrap();
tx.send(HunkEvent::HunkRemoved {
path: PathBuf::from("/tmp/foo.rs"),
hunk_id: HunkId::from_string("test-hunk-001".into()),
reason: crate::events::HunkRemovalReason::Superseded,
})
.unwrap();
tx.send(HunkEvent::FileRemoved {
path: PathBuf::from("/tmp/foo.rs"),
})
.unwrap();
tx.send(HunkEvent::BaselineUpdated {
path: PathBuf::from("/tmp/foo.rs"),
})
.unwrap();
// Drop sender to close the channel
drop(tx);
let (shared_writer, shared) = SharedWriter::new();
run_loc_sink(rx, shared_writer, ctx, cancel).await;
let w = shared.lock().unwrap();
assert_eq!(
w.records.len(),
3,
"HunkAdded + HunkContentChanged + HunkRemoved should produce 3 records"
);
// First record: added (full counts)
assert_eq!(
w.records[0].hunk_id,
HunkId::from_string("test-hunk-001".into())
);
assert_eq!(w.records[0].event_type, EventType::Added);
assert_eq!(w.records[0].lines_added, 5);
assert_eq!(w.records[0].lines_removed, 3);
// Second record: updated (delta: 8-5=3 added, 3-3=0 removed)
assert_eq!(w.records[1].event_type, EventType::Updated);
assert_eq!(w.records[1].lines_added, 3i64);
assert_eq!(w.records[1].lines_removed, 0i64);
// Third record: removed (negates accumulated: -(5+3)=-8, -(3+0)=-3)
assert_eq!(w.records[2].event_type, EventType::Removed);
assert_eq!(w.records[2].lines_added, -8i64);
assert_eq!(w.records[2].lines_removed, -3i64);
// SUM should be zero
let total: i64 = w.records.iter().map(|r| r.lines_added).sum();
assert_eq!(total, 0);
}
/// When a hunk is removed, the sink must emit a negating record so that
/// SUM-based totals zero out the hunk's contribution.
#[tokio::test]
async fn sink_removed_hunk_zeroes_out_accumulated_total() {
let (tx, rx) = mpsc::unbounded_channel();
let ctx = make_ctx();
let cancel = tokio_util::sync::CancellationToken::new();
let hunk = sample_agent_hunk(); // lines_added=5, lines_removed=3
let hunk_id = hunk.id.clone();
let path = hunk.path.clone();
// Add, then remove
tx.send(HunkEvent::HunkAdded {
path: path.clone(),
hunk: Arc::new(hunk),
})
.unwrap();
tx.send(HunkEvent::HunkRemoved {
path: path.clone(),
hunk_id: hunk_id.clone(),
reason: crate::events::HunkRemovalReason::Rejected,
})
.unwrap();
drop(tx);
let (shared_writer, shared) = SharedWriter::new();
run_loc_sink(rx, shared_writer, ctx, cancel).await;
let w = shared.lock().unwrap();
assert_eq!(w.records.len(), 2, "Should have added + removed records");
// First: added
assert_eq!(w.records[0].event_type, EventType::Added);
assert_eq!(w.records[0].lines_added, 5);
assert_eq!(w.records[0].lines_removed, 3);
// Second: removed (negated)
assert_eq!(w.records[1].event_type, EventType::Removed);
assert_eq!(w.records[1].lines_added, -5);
assert_eq!(w.records[1].lines_removed, -3);
// SUM should be zero
let total_added: i64 = w.records.iter().map(|r| r.lines_added).sum();
let total_removed: i64 = w.records.iter().map(|r| r.lines_removed).sum();
assert_eq!(total_added, 0, "Removed hunk should zero out lines_added");
assert_eq!(
total_removed, 0,
"Removed hunk should zero out lines_removed"
);
}
/// Full scenario: agent adds, human expands, then hunk is removed.
/// The negating record must cancel the entire accumulated total.
#[tokio::test]
async fn sink_removed_hunk_after_updates_zeroes_correctly() {
let (tx, rx) = mpsc::unbounded_channel();
let ctx = make_ctx();
let cancel = tokio_util::sync::CancellationToken::new();
let hunk = sample_agent_hunk(); // lines_added=5, lines_removed=3
let hunk_id = hunk.id.clone();
let path = hunk.path.clone();
let mut updated = sample_agent_hunk();
updated.line_info.new_count = 8; // grew from 5 → 8
// Add → update → remove
tx.send(HunkEvent::HunkAdded {
path: path.clone(),
hunk: Arc::new(hunk),
})
.unwrap();
tx.send(HunkEvent::HunkContentChanged {
path: path.clone(),
hunk: Arc::new(updated),
trigger_source: HunkSource::ExternalEditOnAgentFile,
prev_lines_added: 5,
prev_lines_removed: 3,
})
.unwrap();
tx.send(HunkEvent::HunkRemoved {
path: path.clone(),
hunk_id: hunk_id.clone(),
reason: crate::events::HunkRemovalReason::Superseded,
})
.unwrap();
drop(tx);
let (shared_writer, shared) = SharedWriter::new();
run_loc_sink(rx, shared_writer, ctx, cancel).await;
let w = shared.lock().unwrap();
assert_eq!(w.records.len(), 3, "added + updated + removed");
// SUM should be zero: the hunk was fully removed
let total_added: i64 = w.records.iter().map(|r| r.lines_added).sum();
let total_removed: i64 = w.records.iter().map(|r| r.lines_removed).sum();
assert_eq!(total_added, 0);
assert_eq!(total_removed, 0);
}
/// Accepted hunks keep their LOC contribution — no negating record is written.
#[tokio::test]
async fn sink_accepted_hunk_preserves_loc() {
let (tx, rx) = mpsc::unbounded_channel();
let ctx = make_ctx();
let cancel = tokio_util::sync::CancellationToken::new();
let hunk = sample_agent_hunk(); // lines_added=5, lines_removed=3
let hunk_id = hunk.id.clone();
let path = hunk.path.clone();
tx.send(HunkEvent::HunkAdded {
path: path.clone(),
hunk: Arc::new(hunk),
})
.unwrap();
tx.send(HunkEvent::HunkRemoved {
path: path.clone(),
hunk_id: hunk_id.clone(),
reason: crate::events::HunkRemovalReason::Accepted,
})
.unwrap();
drop(tx);
let (shared_writer, shared) = SharedWriter::new();
run_loc_sink(rx, shared_writer, ctx, cancel).await;
let w = shared.lock().unwrap();
// Only the Added record — no Removed record for accepted hunks
assert_eq!(
w.records.len(),
1,
"Accepted hunk should NOT produce a Removed record"
);
assert_eq!(w.records[0].event_type, EventType::Added);
// LOC is preserved
let total_added: i64 = w.records.iter().map(|r| r.lines_added).sum();
assert_eq!(total_added, 5, "Accepted hunk's LOC should be preserved");
}
/// When a hunk *shrinks* (e.g., human deletes 3 of 10 agent lines), the
/// delta must be negative so SUM-based LOC totals stay accurate.
#[tokio::test]
async fn sink_shrinking_hunk_produces_negative_delta() {
let (tx, rx) = mpsc::unbounded_channel();
let ctx = make_ctx();
let cancel = tokio_util::sync::CancellationToken::new();
// Agent adds 10 lines
let mut hunk = sample_agent_hunk();
hunk.line_info.new_count = 10;
hunk.line_info.old_count = 0;
// Human deletes 3 → hunk shrinks to 7
let mut shrunk = sample_agent_hunk();
shrunk.line_info.new_count = 7;
shrunk.line_info.old_count = 0;
tx.send(HunkEvent::HunkAdded {
path: hunk.path.clone(),
hunk: Arc::new(hunk),
})
.unwrap();
tx.send(HunkEvent::HunkContentChanged {
path: shrunk.path.clone(),
hunk: Arc::new(shrunk),
trigger_source: HunkSource::ExternalEditOnAgentFile,
prev_lines_added: 10,
prev_lines_removed: 0,
})
.unwrap();
drop(tx);
let (shared_writer, shared) = SharedWriter::new();
run_loc_sink(rx, shared_writer, ctx, cancel).await;
let w = shared.lock().unwrap();
assert_eq!(w.records.len(), 2);
// First: agent added 10 lines
assert_eq!(w.records[0].author_type, Some(AuthorType::Agent));
assert_eq!(w.records[0].lines_added, 10i64);
// Second: human shrunk the hunk by 3 → negative delta
assert_eq!(w.records[1].author_type, Some(AuthorType::Human));
assert_eq!(w.records[1].event_type, EventType::Updated);
assert_eq!(w.records[1].lines_added, -3i64);
assert_eq!(w.records[1].lines_removed, 0i64);
// SUM(lines_added) by author: agent=10, human=-3, net=7 ✅
let agent_total: i64 = w
.records
.iter()
.filter(|r| r.author_type == Some(AuthorType::Agent))
.map(|r| r.lines_added)
.sum();
let human_total: i64 = w
.records
.iter()
.filter(|r| r.author_type == Some(AuthorType::Human))
.map(|r| r.lines_added)
.sum();
assert_eq!(agent_total, 10);
assert_eq!(human_total, -3);
assert_eq!(agent_total + human_total, 7); // net lines in file
}
#[tokio::test]
async fn sink_drains_on_cancellation() {
let (tx, rx) = mpsc::unbounded_channel();
let ctx = make_ctx();
let cancel = tokio_util::sync::CancellationToken::new();
let hunk1 = sample_agent_hunk();
let hunk2 = sample_external_hunk();
// Send events before cancellation
tx.send(HunkEvent::HunkAdded {
path: hunk1.path.clone(),
hunk: Arc::new(hunk1),
})
.unwrap();
tx.send(HunkEvent::HunkAdded {
path: hunk2.path.clone(),
hunk: Arc::new(hunk2),
})
.unwrap();
// Cancel immediately
cancel.cancel();
let (shared_writer, shared) = SharedWriter::new();
run_loc_sink(rx, shared_writer, ctx, cancel).await;
let w = shared.lock().unwrap();
assert_eq!(
w.records.len(),
2,
"Both events should be drained on cancellation"
);
assert!(w.flush_count > 0, "Writer should be flushed on shutdown");
}
// ---------------------------------------------------------------------------
// JSONL round-trip test
// ---------------------------------------------------------------------------
#[tokio::test]
async fn jsonl_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hunk_records.jsonl");
let mut writer = JsonlHunkRecordWriter::new(path.clone());
let hunk = sample_agent_hunk();
let record = HunkRecord::from_hunk(
&hunk,
"sess-rt",
"agent-rt",
Some("user-rt"),
EventType::Added,
&hunk.source,
);
writer.write(&record).await.unwrap();
writer.flush().await.unwrap();
// Read back and deserialize
let contents = tokio::fs::read_to_string(&path).await.unwrap();
let lines: Vec<&str> = contents.trim().lines().collect();
assert_eq!(lines.len(), 1);
let deserialized: HunkRecord = serde_json::from_str(lines[0]).unwrap();
assert_eq!(deserialized, record);
}
#[tokio::test]
async fn jsonl_writer_creates_parent_dirs() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("deep").join("records.jsonl");
let mut writer = JsonlHunkRecordWriter::new(path.clone());
let hunk = sample_agent_hunk();
let record = HunkRecord::from_hunk(
&hunk,
"sess-1",
"agent-1",
None,
EventType::Added,
&hunk.source,
);
writer.write(&record).await.unwrap();
assert!(path.exists());
}
#[tokio::test]
async fn jsonl_writer_appends() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("records.jsonl");
let mut writer = JsonlHunkRecordWriter::new(path.clone());
let hunk1 = sample_agent_hunk();
let hunk2 = sample_external_hunk();
let r1 = HunkRecord::from_hunk(&hunk1, "s", "a", None, EventType::Added, &hunk1.source);
let r2 = HunkRecord::from_hunk(&hunk2, "s", "a", None, EventType::Added, &hunk2.source);
writer.write(&r1).await.unwrap();
writer.write(&r2).await.unwrap();
writer.flush().await.unwrap();
let contents = tokio::fs::read_to_string(&path).await.unwrap();
let lines: Vec<&str> = contents.trim().lines().collect();
assert_eq!(lines.len(), 2);
}
// ---------------------------------------------------------------------------
// Deserialization validation
// ---------------------------------------------------------------------------
/// Invalid enum values must be rejected during deserialization.
/// This validates that the serde enum gate works — a typo like "foo"
/// in the JSONL can't silently sneak past.
#[test]
fn deserialize_rejects_invalid_author_type() {
let hunk = sample_agent_hunk();
let record = HunkRecord::from_hunk(&hunk, "s", "a", None, EventType::Added, &hunk.source);
let mut json = serde_json::to_string(&record).unwrap();
// Replace valid "agent" with invalid "foo"
json = json.replacen("\"agent\"", "\"foo\"", 1);
let result = serde_json::from_str::<HunkRecord>(&json);
assert!(result.is_err(), "Should reject invalid author_type");
}
#[test]
fn deserialize_rejects_invalid_event_type() {
let hunk = sample_agent_hunk();
let record = HunkRecord::from_hunk(&hunk, "s", "a", None, EventType::Added, &hunk.source);
let mut json = serde_json::to_string(&record).unwrap();
json = json.replacen("\"added\"", "\"foo\"", 1);
let result = serde_json::from_str::<HunkRecord>(&json);
assert!(result.is_err(), "Should reject invalid event_type");
}
#[test]
fn deserialize_rejects_invalid_source_type() {
let hunk = sample_agent_hunk();
let record = HunkRecord::from_hunk(&hunk, "s", "a", None, EventType::Added, &hunk.source);
let mut json = serde_json::to_string(&record).unwrap();
json = json.replacen("\"agentEdit\"", "\"foo\"", 1);
let result = serde_json::from_str::<HunkRecord>(&json);
assert!(result.is_err(), "Should reject invalid source_type");
}
// ---------------------------------------------------------------------------
// Writer failure resilience
// ---------------------------------------------------------------------------
/// The sink must continue processing events even when the writer fails.
/// This validates the "log warning and drop the record" error policy.
#[tokio::test]
async fn sink_continues_after_writer_failure() {
let (tx, rx) = mpsc::unbounded_channel();
let ctx = make_ctx();
let cancel = tokio_util::sync::CancellationToken::new();
let hunk1 = sample_agent_hunk();
let hunk2 = sample_external_hunk();
tx.send(HunkEvent::HunkAdded {
path: hunk1.path.clone(),
hunk: Arc::new(hunk1),
})
.unwrap();
tx.send(HunkEvent::HunkAdded {
path: hunk2.path.clone(),
hunk: Arc::new(hunk2),
})
.unwrap();
drop(tx);
/// Writer that always fails on write but tracks flush calls.
struct FailingWriter(std::sync::Arc<std::sync::Mutex<bool>>);
impl HunkRecordWriter for FailingWriter {
async fn write(&mut self, _record: &HunkRecord) -> std::io::Result<()> {
Err(std::io::Error::other("disk full"))
}
async fn flush(&mut self) -> std::io::Result<()> {
*self.0.lock().unwrap() = true;
Ok(())
}
}
let flush_called = std::sync::Arc::new(std::sync::Mutex::new(false));
let writer = FailingWriter(flush_called.clone());
// This must not panic — the sink should log warnings and continue.
run_loc_sink(rx, writer, ctx, cancel).await;
// Flush must still be called on shutdown (sink didn't abort early).
assert!(
*flush_called.lock().unwrap(),
"Sink should flush on shutdown even after write failures"
);
}

View file

@ -0,0 +1,970 @@
//! Core types for hunk tracking.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::{path::PathBuf, sync::Arc};
use thiserror::Error;
/// Unique identifier for a hunk.
/// Uses UUID for guaranteed uniqueness across sessions.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct HunkId(pub Arc<str>);
impl HunkId {
/// Generate a new unique hunk ID
pub fn new() -> Self {
Self(uuid::Uuid::new_v4().to_string().into())
}
/// Create from existing string (for deserialization/testing)
pub fn from_string(s: String) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Default for HunkId {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for HunkId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Show first 8 characters for display (respects char boundaries)
let short: String = self.0.chars().take(8).collect();
write!(f, "{}", short)
}
}
/// Line information for a hunk (mirrors unified diff header).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HunkLineInfo {
/// 1-indexed start line in baseline (old) file
pub old_start: usize,
/// Number of lines from baseline that were changed/deleted
pub old_count: usize,
/// 1-indexed start line in current (new) file
pub new_start: usize,
/// Number of lines in current that were added/modified
pub new_count: usize,
}
impl std::fmt::Display for HunkLineInfo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"@@ -{},{} +{},{} @@",
self.old_start, self.old_count, self.new_start, self.new_count
)
}
}
/// The source of a hunk - who made the change.
///
/// This enum distinguishes between:
/// - Changes made directly by the agent (with prompt attribution)
/// - External changes to files the agent has touched (tracked for session context)
/// - External changes to files the agent hasn't touched
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum HunkSource {
/// Change made by an agent tool at a specific prompt.
/// The prompt_index identifies which agent turn made this change.
AgentEdit {
/// Prompt index when the change was made (required)
prompt_index: usize,
},
/// External edit (by user) to a file the agent has previously touched.
/// These are tracked separately so we know they're "part of agent session"
/// but weren't written by the agent itself.
ExternalEditOnAgentFile,
/// External edit to a file the agent has NOT touched.
/// Only tracked when TrackingMode::AllDirty is enabled.
External,
}
impl HunkSource {
/// Returns true if this was directly written by the agent
pub fn is_agent_edit(&self) -> bool {
matches!(self, HunkSource::AgentEdit { .. })
}
/// Returns true if this is any kind of agent-related source
/// (either agent edit or user edit on agent file)
pub fn is_agent_tracked(&self) -> bool {
matches!(
self,
HunkSource::AgentEdit { .. } | HunkSource::ExternalEditOnAgentFile
)
}
/// Returns true if this was an external (user) edit
pub fn is_external(&self) -> bool {
matches!(
self,
HunkSource::External | HunkSource::ExternalEditOnAgentFile
)
}
/// Get prompt index if this was an agent edit
pub fn prompt_index(&self) -> Option<usize> {
match self {
HunkSource::AgentEdit { prompt_index } => Some(*prompt_index),
_ => None,
}
}
}
/// Error type for hunk actions (accept/reject).
#[derive(Debug, Error)]
pub enum HunkActionError {
#[error("Hunk not found: {0}")]
HunkNotFound(HunkId),
#[error("Failed to write file {path}: {source}")]
WriteError {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("Failed to delete file {path}: {source}")]
DeleteError {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("Failed to read file {path}: {source}")]
ReadError {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
/// A single hunk representing a contiguous change.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Hunk {
/// Unique identifier for this hunk
pub id: HunkId,
/// Absolute file path
pub path: PathBuf,
/// Line position information
pub line_info: HunkLineInfo,
/// Who made this change
pub source: HunkSource,
/// The old text (lines removed/changed), None for new file
pub old_text: Option<String>,
/// The new text (lines added/changed)
pub new_text: String,
/// Unified diff patch fragment for this hunk (e.g., "@@ -10,3 +10,5 @@\n-old\n+new\n")
pub patch: Option<String>,
/// When this hunk was first detected
pub created_at: DateTime<Utc>,
/// Whether this hunk is selected in the UI
#[serde(skip)]
pub selected: bool,
}
impl Hunk {
/// Create a new hunk for a file that was created (no baseline)
pub fn file_created(path: PathBuf, content: String, source: HunkSource) -> Self {
let line_count = content.lines().count().max(1);
Self {
id: HunkId::new(),
path,
line_info: HunkLineInfo {
old_start: 0,
old_count: 0,
new_start: 1,
new_count: line_count,
},
source,
old_text: None,
new_text: content,
patch: None,
created_at: Utc::now(),
selected: false,
}
}
/// Create a new hunk for a file that was deleted
pub fn file_deleted(path: PathBuf, content: String, source: HunkSource) -> Self {
let line_count = content.lines().count().max(1);
Self {
id: HunkId::new(),
path,
line_info: HunkLineInfo {
old_start: 1,
old_count: line_count,
new_start: 0,
new_count: 0,
},
source,
old_text: Some(content),
new_text: String::new(),
patch: None,
created_at: Utc::now(),
selected: false,
}
}
/// Get a short summary for display
pub fn summary(&self) -> String {
let additions = self.new_text.lines().count();
let deletions = self
.old_text
.as_ref()
.map(|t| t.lines().count())
.unwrap_or(0);
format!("+{}/-{}", additions, deletions)
}
/// Get the display path with line number
pub fn display_path(&self) -> String {
format!("{}:{}", self.path.display(), self.line_info.new_start)
}
}
/// Action to take on a hunk.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HunkAction {
/// Accept the hunk - update baseline to include this change.
/// After accept: baseline = current_content for the affected lines.
Accept,
/// Reject the hunk - revert file content back to baseline.
/// After reject: file on disk is overwritten with baseline content.
Reject,
}
/// Updates sent to clients when hunks change.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum HunkUpdate {
/// A new hunk was created
Added(Hunk),
/// A hunk was removed (accepted, rejected, or reverted)
Removed { hunk_id: HunkId },
/// A hunk's position changed but content is the same
Moved {
hunk_id: HunkId,
new_line_info: HunkLineInfo,
},
}
/// Summary of a tracked file (for UI display).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileSummary {
pub path: PathBuf,
pub hunk_count: usize,
pub has_agent_changes: bool,
pub has_external_changes: bool,
}
/// Filter for querying hunks.
#[derive(Debug, Clone, Default)]
pub struct HunkFilter {
/// Filter by source
pub source: Option<HunkSourceFilter>,
/// Filter by path pattern (glob)
pub path_pattern: Option<String>,
}
/// Filter for querying hunks by source.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HunkSourceFilter {
Agent,
External,
}
/// How the hunk tracker should monitor files.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TrackingMode {
/// Only track files the agent has written to
#[default]
AgentOnly,
/// Track all git dirty files (agent files + external dirty files)
AllDirty,
}
// ============================================================================
// Session Stats & Summary
// ============================================================================
/// Simple counters for session summary. Reset on baseline reset (commit).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionStats {
/// Number of hunks that have been accepted
pub accepted_hunks: usize,
/// Number of hunks that have been rejected
pub rejected_hunks: usize,
/// Lines added in accepted hunks
pub accepted_lines_added: usize,
/// Lines removed in accepted hunks
pub accepted_lines_removed: usize,
/// Lines added in rejected hunks (informational)
pub rejected_lines_added: usize,
/// Lines removed in rejected hunks (informational)
pub rejected_lines_removed: usize,
}
/// Summary of pending changes for a single agent turn.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TurnSummary {
/// The prompt index this turn corresponds to
pub prompt_index: usize,
/// Files modified in this turn (unique paths)
pub files: Vec<PathBuf>,
/// Pending hunks in this turn (wrapped in Arc for cheap cloning)
pub pending_hunks: Vec<Arc<Hunk>>,
/// Lines added (sum of pending hunk new_count)
pub lines_added: usize,
/// Lines removed (sum of pending hunk old_count)
pub lines_removed: usize,
}
/// Complete session summary: stats + pending hunks grouped by turn.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionSummary {
/// Counters for accepted/rejected
pub stats: SessionStats,
/// Pending hunks grouped by turn
pub turns: Vec<TurnSummary>,
/// Total unique files with agent-attributed pending hunks
pub files_modified: usize,
/// Files still having agent-attributed pending hunks
pub files_with_pending: usize,
/// Total pending hunks (agent-attributed only)
pub pending_hunks: usize,
/// Pending lines added (agent-attributed only)
pub pending_lines_added: usize,
/// Pending lines removed (agent-attributed only)
pub pending_lines_removed: usize,
/// Pending hunks without prompt_index (e.g., external edits)
pub unattributed_pending: usize,
}
// ============================================================================
// Content Status Types (for explicit API responses)
// ============================================================================
/// Status of file content - explicit discrimination for API consumers.
/// This replaces the ambiguous `Option<String>` where `None` could mean
/// missing, binary, or too large.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FileContentStatus {
/// File doesn't exist (deleted or never existed)
#[default]
Missing,
/// File is binary (contains NUL bytes)
Binary,
/// File exceeds MAX_TRACKED_TEXT_BYTES (content not retained)
TooLarge,
/// File is a Git LFS pointer (raw blob is a small text stub; working
/// copy holds the smudged content — not diffable)
LfsPointer,
/// Path is a symbolic link (not diffable)
Symlink,
/// File is diffable text (content available)
Full,
}
/// View of file content with explicit status for API responses.
/// Combines status metadata with optional content string.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileContentView {
/// Explicit status of the content
pub status: FileContentStatus,
/// Size in bytes (available for Binary/TooLarge/Full states)
#[serde(skip_serializing_if = "Option::is_none")]
pub byte_len: Option<usize>,
/// Text content (only present when status is Full)
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
}
impl FileContentView {
/// Create a view for missing content
pub fn missing() -> Self {
Self {
status: FileContentStatus::Missing,
byte_len: None,
content: None,
}
}
/// Create a view for binary content
pub fn binary(byte_len: Option<usize>) -> Self {
Self {
status: FileContentStatus::Binary,
byte_len,
content: None,
}
}
/// Create a view for too-large content
pub fn too_large(byte_len: usize) -> Self {
Self {
status: FileContentStatus::TooLarge,
byte_len: Some(byte_len),
content: None,
}
}
/// Create a view for a Git LFS pointer
pub fn lfs_pointer(byte_len: usize) -> Self {
Self {
status: FileContentStatus::LfsPointer,
byte_len: Some(byte_len),
content: None,
}
}
/// Create a view for a symbolic link
pub fn symlink() -> Self {
Self {
status: FileContentStatus::Symlink,
byte_len: None,
content: None,
}
}
/// Create a view for full text content
pub fn full(content: String) -> Self {
let byte_len = content.len();
Self {
status: FileContentStatus::Full,
byte_len: Some(byte_len),
content: Some(content),
}
}
/// Convert from internal FileContentState to API-facing FileContentView.
/// This is the canonical conversion for query responses.
pub fn from_content_state(state: &crate::actor::state::FileContentState) -> Self {
use crate::actor::state::FileContentState;
match state {
FileContentState::Missing => Self::missing(),
FileContentState::Binary { byte_len } => Self::binary(*byte_len),
FileContentState::TooLarge { byte_len } => Self::too_large(*byte_len),
FileContentState::LfsPointer { byte_len } => Self::lfs_pointer(*byte_len),
FileContentState::Symlink => Self::symlink(),
FileContentState::Full(content) => Self::full(content.clone()),
}
}
}
/// Per-file content entry returned by `GetAllFileContents`.
///
/// Contains baseline, current content, agent attribution, and staging
/// state for a single tracked file — everything a client needs to render
/// diffs without per-file round trips.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileContentEntry {
pub path: PathBuf,
pub baseline: FileContentView,
pub current: FileContentView,
pub is_agent_file: bool,
pub staged: bool,
}
/// File diff data including hunks and full file content.
/// Used to provide all data needed for diff rendering in one response.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileHunkData {
/// Hunks for this file (each hunk includes its own patch fragment)
pub hunks: Vec<Arc<Hunk>>,
// === Explicit content status (new fields) ===
/// Baseline content with explicit status (git HEAD)
pub baseline: FileContentView,
/// Current content with explicit status (on disk)
pub current: FileContentView,
// === Legacy fields for backward compatibility ===
// These are populated from FileContentView for existing callers.
// Will be deprecated once all callers migrate to baseline/current views.
/// Baseline content (git HEAD) - legacy, use `baseline.content` instead
#[serde(skip_serializing_if = "Option::is_none")]
pub baseline_content: Option<String>,
/// Current content (on disk) - legacy, use `current.content` instead
#[serde(skip_serializing_if = "Option::is_none")]
pub current_content: Option<String>,
}
// ============================================================================
// Snapshot / Restore (for cross-session sync-back)
// ============================================================================
// FileContentState is crate-internal (actor::state is pub(crate));
// imported here for snapshot serialization.
use crate::actor::state::FileContentState;
/// Snapshot of a single tracked file's hunk state.
/// Preserves the full FileContentState (including Binary/TooLarge) for correctness
/// in fork and cross-session sync flows.
///
/// `Serialize`/`Deserialize` let the rewind checkpoint store persist this to disk
/// (see [`HunkTurnDelta`]).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileHunkStateSnapshot {
/// Content at git HEAD or session start (baseline for diffing).
/// FileContentState::Missing means file didn't exist at baseline (new file).
/// FileContentState::TooLarge/Binary means content not retained (metadata only).
pub baseline: FileContentState,
/// Last known content (from agent write or disk read).
/// FileContentState::Missing means file doesn't exist currently.
pub current_content: FileContentState,
/// Active hunks for this file.
pub hunks: Vec<Hunk>,
/// Whether the agent has written to this file.
pub is_agent_file: bool,
/// Whether the baseline has been patched by an accept action.
pub baseline_accepted: bool,
}
/// Snapshot of all hunk tracker state.
///
/// Used to preserve pending hunks across session kill/reload cycles
/// (e.g., fork sync-back). Without this,
/// the session reload creates a fresh `HunkTrackerActor` with empty state,
/// causing all un-reviewed hunks to silently disappear — the user sees
/// their changes "auto-applied" because they're on disk but no longer
/// shown as reviewable.
#[derive(Debug, Clone)]
pub struct HunkTrackerSnapshot {
/// All tracked files with their baselines, current content, hunks, and agent flags.
pub file_states: HashMap<PathBuf, FileHunkStateSnapshot>,
/// Secondary index: prompt_index → set of hunk IDs for that turn.
pub turn_index: HashMap<usize, HashSet<HunkId>>,
/// Session-level stats (accepted/rejected counts).
pub session_stats: SessionStats,
}
/// Incremental, single-turn slice of hunk-tracker state, captured per
/// `prompt_index` for the rewind checkpoint store: snapshots of the turn's
/// touched files plus its hunk-id set, never a whole-tracker copy. Restore
/// composes deltas (ascending, last write per path wins) into a
/// [`HunkTrackerSnapshot`].
///
/// `Serialize`/`Deserialize` let the checkpoint store persist a delta to disk.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HunkTurnDelta {
/// The turn this delta belongs to.
pub prompt_index: usize,
/// Snapshots of the files touched in this turn (those owning the turn's hunks).
pub file_states: HashMap<PathBuf, FileHunkStateSnapshot>,
/// The hunk IDs attributed to this turn (`turn_index[prompt_index]`).
pub hunk_ids: HashSet<HunkId>,
}
impl HunkTrackerSnapshot {
/// Rewrite all absolute paths in the snapshot from one directory prefix
/// to another. This is a **pure function** — no filesystem I/O.
///
/// Used when transferring hunk state between sessions that operate in
/// different directories (e.g., root cwd ↔ fork worktree). Rewrites:
/// - `file_states` HashMap keys
/// - `Hunk.path` field inside each file's hunks
///
/// Both `old_cwd` and `canonical_old_cwd` should be provided by the
/// caller (who canonicalizes while the directories still exist on disk).
/// This avoids filesystem I/O inside the transform and ensures correct
/// behavior even after worktree cleanup.
///
/// Files whose paths cannot be rewritten (e.g., tracked outside the
/// worktree) are kept at their original path with a warning log.
pub fn rewrite_paths(
&mut self,
old_cwd: &std::path::Path,
canonical_old_cwd: &std::path::Path,
new_cwd: &std::path::Path,
) {
let rewritten: HashMap<PathBuf, FileHunkStateSnapshot> = self
.file_states
.drain()
.map(|(path, mut state)| {
let new_path = rewrite_single_path(&path, old_cwd, canonical_old_cwd, new_cwd);
let target_path = new_path.unwrap_or_else(|| {
tracing::warn!(
original_path = %path.display(),
old_cwd = %old_cwd.display(),
new_cwd = %new_cwd.display(),
"Cannot rewrite path: not under old_cwd, keeping original"
);
path
});
for hunk in &mut state.hunks {
hunk.path = target_path.clone();
}
(target_path, state)
})
.collect();
self.file_states = rewritten;
}
}
/// Rewrite a single absolute path from one directory prefix to another.
/// Pure function — no filesystem I/O.
///
/// Tries both raw and canonicalized prefix variants to handle macOS
/// symlinks (e.g., `/var` → `/private/var`) and paths stored with vs.
/// without symlink resolution.
///
/// Returns `None` if the path cannot be made relative to `old_cwd`
/// under any prefix variant.
fn rewrite_single_path(
path: &std::path::Path,
old_cwd: &std::path::Path,
canonical_old: &std::path::Path,
new_cwd: &std::path::Path,
) -> Option<PathBuf> {
// Try stripping old_cwd prefix using both raw and canonical variants.
// No canonicalize() calls here — the caller provides both variants.
let relative = path
.strip_prefix(canonical_old)
.ok()
.or_else(|| path.strip_prefix(old_cwd).ok());
relative.map(|rel| new_cwd.join(rel))
}
#[cfg(test)]
mod snapshot_tests {
use super::*;
use chrono::Utc;
fn make_snapshot(paths: &[&str]) -> HunkTrackerSnapshot {
let mut file_states = HashMap::new();
let mut turn_index: HashMap<usize, HashSet<HunkId>> = HashMap::new();
for (i, path_str) in paths.iter().enumerate() {
let path = PathBuf::from(path_str);
let hunk_id = HunkId::from_string(format!("hunk-{i}"));
let hunk = Hunk {
id: hunk_id.clone(),
path: path.clone(),
line_info: HunkLineInfo {
old_start: 1,
old_count: 1,
new_start: 1,
new_count: 2,
},
source: HunkSource::AgentEdit { prompt_index: i },
old_text: Some("old".to_string()),
new_text: "new".to_string(),
patch: None,
created_at: Utc::now(),
selected: false,
};
turn_index.entry(i).or_default().insert(hunk_id);
file_states.insert(
path,
FileHunkStateSnapshot {
baseline: FileContentState::Full("old".to_string()),
current_content: FileContentState::Full("new".to_string()),
hunks: vec![hunk],
is_agent_file: true,
baseline_accepted: false,
},
);
}
HunkTrackerSnapshot {
file_states,
turn_index,
session_stats: SessionStats::default(),
}
}
#[test]
fn rewrite_simple_prefix() {
let mut snap = make_snapshot(&["/old/cwd/file.txt", "/old/cwd/sub/deep.rs"]);
let old = std::path::Path::new("/old/cwd");
let new = std::path::Path::new("/new/cwd");
snap.rewrite_paths(old, old, new);
let paths: Vec<_> = snap.file_states.keys().collect();
assert!(paths.contains(&&PathBuf::from("/new/cwd/file.txt")));
assert!(paths.contains(&&PathBuf::from("/new/cwd/sub/deep.rs")));
}
#[test]
fn rewrite_updates_hunk_paths() {
let mut snap = make_snapshot(&["/old/cwd/file.txt"]);
let old = std::path::Path::new("/old/cwd");
let new = std::path::Path::new("/new/cwd");
snap.rewrite_paths(old, old, new);
let state = snap
.file_states
.get(&PathBuf::from("/new/cwd/file.txt"))
.unwrap();
assert_eq!(state.hunks[0].path, PathBuf::from("/new/cwd/file.txt"));
}
#[test]
fn rewrite_canonical_prefix_fallback() {
// Simulate macOS: path stored as /private/var/... but old_cwd is /var/...
let mut snap = make_snapshot(&["/private/var/folders/work/file.txt"]);
let old_raw = std::path::Path::new("/var/folders/work");
let old_canonical = std::path::Path::new("/private/var/folders/work");
let new = std::path::Path::new("/new/cwd");
snap.rewrite_paths(old_raw, old_canonical, new);
assert!(
snap.file_states
.contains_key(&PathBuf::from("/new/cwd/file.txt"))
);
}
#[test]
fn rewrite_path_outside_old_cwd_kept_with_original() {
let mut snap = make_snapshot(&["/other/dir/file.txt"]);
let old = std::path::Path::new("/old/cwd");
let new = std::path::Path::new("/new/cwd");
snap.rewrite_paths(old, old, new);
// Path outside old_cwd is kept at original path
assert!(
snap.file_states
.contains_key(&PathBuf::from("/other/dir/file.txt"))
);
}
#[test]
fn rewrite_identity_is_noop() {
let mut snap = make_snapshot(&["/same/cwd/file.txt"]);
let cwd = std::path::Path::new("/same/cwd");
snap.rewrite_paths(cwd, cwd, cwd);
assert!(
snap.file_states
.contains_key(&PathBuf::from("/same/cwd/file.txt"))
);
let state = snap
.file_states
.get(&PathBuf::from("/same/cwd/file.txt"))
.unwrap();
assert_eq!(state.hunks[0].path, PathBuf::from("/same/cwd/file.txt"));
}
#[test]
fn rewrite_preserves_turn_index_and_stats() {
let mut snap = make_snapshot(&["/old/cwd/file.txt"]);
snap.session_stats.accepted_hunks = 5;
let old = std::path::Path::new("/old/cwd");
let new = std::path::Path::new("/new/cwd");
snap.rewrite_paths(old, old, new);
// turn_index and stats are untouched
assert!(!snap.turn_index.is_empty());
assert_eq!(snap.session_stats.accepted_hunks, 5);
}
// === Snapshot preserves Binary/TooLarge (regression test) ===
#[test]
fn snapshot_preserves_binary_state() {
let mut file_states = HashMap::new();
file_states.insert(
PathBuf::from("/test/binary.bin"),
FileHunkStateSnapshot {
baseline: FileContentState::Binary {
byte_len: Some(100),
},
current_content: FileContentState::Binary {
byte_len: Some(100),
},
hunks: vec![],
is_agent_file: true,
baseline_accepted: false,
},
);
let snap = HunkTrackerSnapshot {
file_states,
turn_index: HashMap::new(),
session_stats: SessionStats::default(),
};
// Snapshot should preserve Binary (not collapse to Missing)
let state = snap
.file_states
.get(&PathBuf::from("/test/binary.bin"))
.unwrap();
assert!(matches!(state.baseline, FileContentState::Binary { .. }));
assert!(matches!(
state.current_content,
FileContentState::Binary { .. }
));
}
#[test]
fn snapshot_preserves_too_large_state() {
let mut file_states = HashMap::new();
file_states.insert(
PathBuf::from("/test/huge.txt"),
FileHunkStateSnapshot {
baseline: FileContentState::TooLarge {
byte_len: 2_000_000,
},
current_content: FileContentState::TooLarge {
byte_len: 2_000_000,
},
hunks: vec![],
is_agent_file: true,
baseline_accepted: false,
},
);
let snap = HunkTrackerSnapshot {
file_states,
turn_index: HashMap::new(),
session_stats: SessionStats::default(),
};
// Snapshot should preserve TooLarge (not collapse to Missing)
let state = snap
.file_states
.get(&PathBuf::from("/test/huge.txt"))
.unwrap();
assert!(matches!(state.baseline, FileContentState::TooLarge { .. }));
assert!(matches!(
state.current_content,
FileContentState::TooLarge { .. }
));
}
}
// ============================================================================
// FileContentView Tests (content status propagation)
// ============================================================================
#[cfg(test)]
mod content_view_tests {
use super::*;
use crate::actor::state::FileContentState;
#[test]
fn from_content_state_missing() {
let state = FileContentState::Missing;
let view = FileContentView::from_content_state(&state);
assert_eq!(view.status, FileContentStatus::Missing);
assert!(view.byte_len.is_none());
assert!(view.content.is_none());
}
#[test]
fn from_content_state_binary() {
let state = FileContentState::Binary {
byte_len: Some(1024),
};
let view = FileContentView::from_content_state(&state);
assert_eq!(view.status, FileContentStatus::Binary);
assert_eq!(view.byte_len, Some(1024));
assert!(view.content.is_none());
}
#[test]
fn from_content_state_binary_no_len() {
let state = FileContentState::Binary { byte_len: None };
let view = FileContentView::from_content_state(&state);
assert_eq!(view.status, FileContentStatus::Binary);
assert!(view.byte_len.is_none());
assert!(view.content.is_none());
}
#[test]
fn from_content_state_too_large() {
let state = FileContentState::TooLarge {
byte_len: 2_000_000,
};
let view = FileContentView::from_content_state(&state);
assert_eq!(view.status, FileContentStatus::TooLarge);
assert_eq!(view.byte_len, Some(2_000_000));
assert!(view.content.is_none());
}
#[test]
fn from_content_state_lfs_pointer() {
let state = FileContentState::LfsPointer { byte_len: 130 };
let view = FileContentView::from_content_state(&state);
assert_eq!(view.status, FileContentStatus::LfsPointer);
assert_eq!(view.byte_len, Some(130));
assert!(view.content.is_none());
}
#[test]
fn from_content_state_full() {
let content = "hello world".to_string();
let state = FileContentState::Full(content.clone());
let view = FileContentView::from_content_state(&state);
assert_eq!(view.status, FileContentStatus::Full);
assert_eq!(view.byte_len, Some(11));
assert_eq!(view.content, Some(content));
}
#[test]
fn view_constructors() {
// Test the convenience constructors
let missing = FileContentView::missing();
assert_eq!(missing.status, FileContentStatus::Missing);
let binary = FileContentView::binary(Some(512));
assert_eq!(binary.status, FileContentStatus::Binary);
assert_eq!(binary.byte_len, Some(512));
let too_large = FileContentView::too_large(5_000_000);
assert_eq!(too_large.status, FileContentStatus::TooLarge);
assert_eq!(too_large.byte_len, Some(5_000_000));
let lfs = FileContentView::lfs_pointer(130);
assert_eq!(lfs.status, FileContentStatus::LfsPointer);
assert_eq!(lfs.byte_len, Some(130));
assert!(lfs.content.is_none());
let full = FileContentView::full("test".to_string());
assert_eq!(full.status, FileContentStatus::Full);
assert_eq!(full.byte_len, Some(4));
assert_eq!(full.content, Some("test".to_string()));
}
#[test]
fn default_view_is_missing() {
let view = FileContentView::default();
assert_eq!(view.status, FileContentStatus::Missing);
assert!(view.byte_len.is_none());
assert!(view.content.is_none());
}
#[test]
fn default_status_is_missing() {
let status = FileContentStatus::default();
assert_eq!(status, FileContentStatus::Missing);
}
}