Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
26
crates/codegen/xai-hunk-tracker/Cargo.toml
Normal file
26
crates/codegen/xai-hunk-tracker/Cargo.toml
Normal 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 }
|
||||
635
crates/codegen/xai-hunk-tracker/src/actor/actions.rs
Normal file
635
crates/codegen/xai-hunk-tracker/src/actor/actions.rs
Normal 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(
|
||||
¤t,
|
||||
hunk.line_info.new_start,
|
||||
hunk.line_info.new_count,
|
||||
old_text,
|
||||
);
|
||||
}
|
||||
|
||||
// Write patched content
|
||||
tokio::fs::write(path, ¤t)
|
||||
.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(())
|
||||
}
|
||||
}
|
||||
330
crates/codegen/xai-hunk-tracker/src/actor/file_utils.rs
Normal file
330
crates/codegen/xai-hunk-tracker/src/actor/file_utils.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
513
crates/codegen/xai-hunk-tracker/src/actor/git.rs
Normal file
513
crates/codegen/xai-hunk-tracker/src/actor/git.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
222
crates/codegen/xai-hunk-tracker/src/actor/hunks.rs
Normal file
222
crates/codegen/xai-hunk-tracker/src/actor/hunks.rs
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
604
crates/codegen/xai-hunk-tracker/src/actor/mod.rs
Normal file
604
crates/codegen/xai-hunk-tracker/src/actor/mod.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
719
crates/codegen/xai-hunk-tracker/src/actor/mutations.rs
Normal file
719
crates/codegen/xai-hunk-tracker/src/actor/mutations.rs
Normal 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 (¤t_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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
204
crates/codegen/xai-hunk-tracker/src/actor/queries.rs
Normal file
204
crates/codegen/xai-hunk-tracker/src/actor/queries.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
145
crates/codegen/xai-hunk-tracker/src/actor/state.rs
Normal file
145
crates/codegen/xai-hunk-tracker/src/actor/state.rs
Normal 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,
|
||||
}
|
||||
5843
crates/codegen/xai-hunk-tracker/src/actor/tests.rs
Normal file
5843
crates/codegen/xai-hunk-tracker/src/actor/tests.rs
Normal file
File diff suppressed because it is too large
Load diff
159
crates/codegen/xai-hunk-tracker/src/commands.rs
Normal file
159
crates/codegen/xai-hunk-tracker/src/commands.rs
Normal 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),
|
||||
}
|
||||
876
crates/codegen/xai-hunk-tracker/src/diff.rs
Normal file
876
crates/codegen/xai-hunk-tracker/src/diff.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
74
crates/codegen/xai-hunk-tracker/src/events.rs
Normal file
74
crates/codegen/xai-hunk-tracker/src/events.rs
Normal 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 },
|
||||
}
|
||||
303
crates/codegen/xai-hunk-tracker/src/handle.rs
Normal file
303
crates/codegen/xai-hunk-tracker/src/handle.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
82
crates/codegen/xai-hunk-tracker/src/lib.rs
Normal file
82
crates/codegen/xai-hunk-tracker/src/lib.rs
Normal 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::*;
|
||||
545
crates/codegen/xai-hunk-tracker/src/loc/mod.rs
Normal file
545
crates/codegen/xai-hunk-tracker/src/loc/mod.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
782
crates/codegen/xai-hunk-tracker/src/loc/tests.rs
Normal file
782
crates/codegen/xai-hunk-tracker/src/loc/tests.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
970
crates/codegen/xai-hunk-tracker/src/types.rs
Normal file
970
crates/codegen/xai-hunk-tracker/src/types.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue