docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
This commit is contained in:
@@ -84,7 +84,7 @@ impl HunkTrackerActor {
|
||||
let accepted = matches!(action, HunkAction::Accept);
|
||||
self.update_session_stats(&hunk.line_info, accepted);
|
||||
|
||||
// Remove from turn_index
|
||||
// Drop from turn_index
|
||||
self.remove_from_turn_index(hunk_id, &hunk.source);
|
||||
|
||||
match action {
|
||||
@@ -383,7 +383,7 @@ impl HunkTrackerActor {
|
||||
let accepted = matches!(action, HunkAction::Accept);
|
||||
self.update_session_stats(&hunk.line_info, accepted);
|
||||
|
||||
// Remove from turn_index
|
||||
// Drop from turn_index
|
||||
self.remove_from_turn_index(&hunk.id, &hunk.source);
|
||||
|
||||
affected_hunk_ids.push(hunk.id.clone());
|
||||
|
||||
@@ -190,7 +190,7 @@ mod tests {
|
||||
assert!(!is_binary(empty));
|
||||
}
|
||||
|
||||
// === TooLarge / bounded read tests (SF-2) ===
|
||||
// TooLarge / bounded read tests (SF-2)
|
||||
|
||||
#[test]
|
||||
fn test_classify_bytes_too_large() {
|
||||
@@ -237,7 +237,7 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// === LFS pointer tests ===
|
||||
// LFS pointer tests
|
||||
|
||||
#[test]
|
||||
fn test_is_lfs_pointer_valid() {
|
||||
@@ -310,7 +310,8 @@ mod tests {
|
||||
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
|
||||
// null byte in prefix
|
||||
data[50] = 0;
|
||||
std::fs::write(&path, &data).unwrap();
|
||||
let state = read_file_bounded(&path).await;
|
||||
// Size > limit means TooLarge (bounded read guarantee - no full allocation)
|
||||
|
||||
@@ -415,7 +415,8 @@ impl HunkTrackerActor {
|
||||
}
|
||||
result.content
|
||||
}
|
||||
Err(_) => missing_content(), // spawn_blocking was cancelled or panicked
|
||||
// spawn_blocking was cancelled or panicked
|
||||
Err(_) => missing_content(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,8 @@ impl HunkTrackerActor {
|
||||
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
|
||||
// new_hunk keeps its new ID
|
||||
continue;
|
||||
}
|
||||
|
||||
claimed_old_ids.insert(best_match.id.clone());
|
||||
|
||||
@@ -567,7 +567,7 @@ impl HunkTrackerActor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore a previously snapshotted state, replacing all current file
|
||||
/// Restore a earlier 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) {
|
||||
@@ -589,7 +589,7 @@ impl HunkTrackerActor {
|
||||
self.turn_index = snapshot.turn_index;
|
||||
self.session_stats = snapshot.session_stats;
|
||||
|
||||
// TODO: Re-emit HunkEvent::FileAdded / HunkEvent::HunkAdded for
|
||||
// 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 }
|
||||
|
||||
@@ -29,7 +29,7 @@ use super::state::{FileContentState, FileHunkState};
|
||||
/// 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
|
||||
/// 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";
|
||||
|
||||
@@ -37,7 +37,7 @@ pub const REFRESH_SKIP_LOG_PREFIX: &str = "refresh_all_baselines: git state unch
|
||||
///
|
||||
/// 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.
|
||||
/// whitespace as clean. Bare `\r` (classic Mac) is deliberately out of scope.
|
||||
fn strip_single_trailing_newline(content: &str) -> &str {
|
||||
content
|
||||
.strip_suffix("\r\n")
|
||||
@@ -67,7 +67,8 @@ impl HunkTrackerActor {
|
||||
|
||||
// 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
|
||||
// Used by recompute_hunks below
|
||||
let current_state_for_hunks = current_state.clone();
|
||||
|
||||
// Binary or TooLarge content: still track as an agent file (so
|
||||
// `get_all_tracked_paths` reports it for worktree replication)
|
||||
@@ -132,7 +133,7 @@ impl HunkTrackerActor {
|
||||
},
|
||||
);
|
||||
|
||||
// Emit FileAdded event
|
||||
// Emit `FileAdded` event
|
||||
self.send_event(HunkEvent::FileAdded {
|
||||
path: path.clone(),
|
||||
is_agent_file: true,
|
||||
@@ -277,7 +278,8 @@ impl HunkTrackerActor {
|
||||
path.clone(),
|
||||
FileHunkState {
|
||||
baseline,
|
||||
current_content: missing_content(), // Will be set by recompute_hunks
|
||||
// Will be set by recompute_hunks
|
||||
current_content: missing_content(),
|
||||
hunks: vec![],
|
||||
is_agent_file: false,
|
||||
baseline_accepted: false,
|
||||
@@ -311,7 +313,7 @@ impl HunkTrackerActor {
|
||||
(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.
|
||||
// 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);
|
||||
@@ -367,7 +369,8 @@ impl HunkTrackerActor {
|
||||
// `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
|
||||
// Not in HEAD either, nothing to track
|
||||
return;
|
||||
}
|
||||
|
||||
// Seed file_states with baseline and Missing current content.
|
||||
@@ -427,7 +430,7 @@ impl HunkTrackerActor {
|
||||
// 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
|
||||
// Drop 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)
|
||||
@@ -455,7 +458,7 @@ impl HunkTrackerActor {
|
||||
/// - 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)
|
||||
/// - Drop files that are clean (baseline == current, not agent files)
|
||||
pub(super) async fn refresh_all_baselines(&mut self) {
|
||||
self.refresh_all_baselines_except(&HashSet::new()).await;
|
||||
}
|
||||
@@ -572,7 +575,7 @@ impl HunkTrackerActor {
|
||||
state.current_content = new_current;
|
||||
state.baseline_accepted = false;
|
||||
|
||||
// Check if file is now clean (baseline == current).
|
||||
// Check if file is 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,
|
||||
@@ -601,7 +604,7 @@ impl HunkTrackerActor {
|
||||
}
|
||||
// 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.
|
||||
// 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);
|
||||
@@ -698,7 +701,7 @@ impl HunkTrackerActor {
|
||||
|
||||
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
|
||||
// Drop 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)
|
||||
|
||||
@@ -15,7 +15,6 @@ use crate::types::{
|
||||
use super::HunkTrackerActor;
|
||||
|
||||
impl HunkTrackerActor {
|
||||
/// Get all hunks.
|
||||
pub(super) fn get_all_hunks(&self) -> Vec<Arc<Hunk>> {
|
||||
self.file_states
|
||||
.values()
|
||||
|
||||
@@ -10,7 +10,7 @@ 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
|
||||
pub(crate) const MAX_TRACKED_TEXT_BYTES: usize = 1024 * 1024;
|
||||
|
||||
/// Explicit state of file content storage.
|
||||
/// Replaces Option<String> for baseline/current_content to avoid unbounded memory.
|
||||
|
||||
@@ -144,7 +144,6 @@ impl TestHarness {
|
||||
self.working_dir.join(path)
|
||||
}
|
||||
|
||||
/// Get all hunks
|
||||
async fn get_all_hunks(&self) -> Vec<Arc<Hunk>> {
|
||||
self.handle.get_all_hunks().await
|
||||
}
|
||||
@@ -160,7 +159,6 @@ impl TestHarness {
|
||||
self.handle.get_file_hunk_data(self.abs_path(path)).await
|
||||
}
|
||||
|
||||
/// Accept a hunk
|
||||
async fn accept_hunk(&self, hunk_id: &crate::types::HunkId) -> bool {
|
||||
self.handle
|
||||
.hunk_action(hunk_id.clone(), HunkAction::Accept)
|
||||
@@ -211,9 +209,7 @@ impl TestHarness {
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Basic Hunk Tracking Tests
|
||||
// =========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_new_file_creates_single_hunk() {
|
||||
@@ -349,9 +345,7 @@ async fn test_revert_to_baseline_removes_hunk() {
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Hunk Accept/Reject Tests
|
||||
// =========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_accept_hunk_removes_it() {
|
||||
@@ -395,9 +389,7 @@ async fn test_reject_hunk_reverts_file() {
|
||||
assert!(hunks_after.is_empty(), "Rejected hunk should be removed");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Event Emission Tests
|
||||
// =========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_hunk_added_event_emitted() {
|
||||
@@ -426,7 +418,8 @@ async fn test_hunk_removed_event_on_revert() {
|
||||
harness.write_baseline("foo.rs", "original\n");
|
||||
harness.agent_write("foo.rs", "modified\n", 0);
|
||||
harness.settle().await;
|
||||
harness.drain_events(); // Clear initial events
|
||||
// Clear initial events
|
||||
harness.drain_events();
|
||||
|
||||
// Revert
|
||||
harness.agent_write("foo.rs", "original\n", 1);
|
||||
@@ -439,9 +432,7 @@ async fn test_hunk_removed_event_on_revert() {
|
||||
assert!(has_removed, "Should emit HunkRemoved event when reverting");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Prompt Index Attribution Tests
|
||||
// =========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_hunks_have_prompt_index() {
|
||||
@@ -558,7 +549,6 @@ line 3
|
||||
);
|
||||
harness.settle().await;
|
||||
|
||||
// Now create an external edit on the agent file
|
||||
harness.external_write(
|
||||
"external_only.rs",
|
||||
r#"line 1
|
||||
@@ -787,9 +777,7 @@ line 5
|
||||
assert_eq!(summary.files_with_pending, 0);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Source Attribution Preservation Tests
|
||||
// =========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_external_edit_preserves_agent_hunk_source() {
|
||||
@@ -909,9 +897,7 @@ line 10
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Binary File Handling Tests
|
||||
// =========================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_binary_file_agent_write_ignored() {
|
||||
@@ -969,9 +955,7 @@ async fn test_text_file_with_valid_utf8_tracked() {
|
||||
assert_eq!(hunks[0].new_text, "Hello, 世界!\n");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Accept/Reject Per-Hunk Tests (Bug Demonstration)
|
||||
// =========================================================================
|
||||
// These tests explicitly demonstrate the bug where accept/reject affects
|
||||
// ALL hunks in a file instead of just the targeted hunk.
|
||||
//
|
||||
@@ -1048,11 +1032,9 @@ line 10
|
||||
"Immediately after accept: 1 hunk in list (state.hunks.retain)"
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// BUG: Now trigger a recompute by making a trivial external change
|
||||
// This will diff baseline vs current, and since baseline == current
|
||||
// (from the buggy accept), all hunks will disappear!
|
||||
// ============================================================
|
||||
|
||||
// Make a tiny change that doesn't affect the hunks
|
||||
// This triggers recompute_hunks internally
|
||||
@@ -1150,10 +1132,8 @@ line 10
|
||||
assert!(success, "Reject should succeed");
|
||||
harness.settle().await;
|
||||
|
||||
// ============================================================
|
||||
// BUG: After rejecting ONE hunk, the ENTIRE file is reverted!
|
||||
// FIX: Now we only revert the specific hunk's lines
|
||||
// ============================================================
|
||||
|
||||
// Read file content from disk
|
||||
let content = std::fs::read_to_string(harness.working_dir.join("bug_reject.rs")).unwrap();
|
||||
@@ -1209,7 +1189,6 @@ original line 2
|
||||
harness.accept_hunk(&hunks[0].id).await;
|
||||
harness.settle().await;
|
||||
|
||||
// Now make a NEW change to a DIFFERENT line
|
||||
harness.agent_write(
|
||||
"baseline_bug.rs",
|
||||
r#"modified line 1
|
||||
@@ -1221,9 +1200,7 @@ NEW CHANGE
|
||||
|
||||
let new_hunks = harness.get_all_hunks().await;
|
||||
|
||||
// ============================================================
|
||||
// This SHOULD work correctly - new change creates new hunk
|
||||
// ============================================================
|
||||
assert_eq!(
|
||||
new_hunks.len(),
|
||||
1,
|
||||
@@ -1244,9 +1221,7 @@ NEW CHANGE
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Tests that will PASS after the fix is implemented
|
||||
// =========================================================================
|
||||
|
||||
/// EXPECTED BEHAVIOR: Accept one hunk, other hunks remain
|
||||
///
|
||||
@@ -1624,9 +1599,7 @@ line 12
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Per-Turn Attribution Tests (Bug Demonstration)
|
||||
// =========================================================================
|
||||
// These tests demonstrate the bug where agent-to-agent overlapping edits
|
||||
// lose the latest prompt_index attribution.
|
||||
|
||||
@@ -1701,13 +1674,11 @@ line 5
|
||||
"Hunk ID should be preserved for overlapping edit"
|
||||
);
|
||||
|
||||
// ============================================================
|
||||
// BUG: The hunk should now be attributed to turn 1, but it's still turn 0
|
||||
// FIX: Now agent-to-agent edits update the prompt_index
|
||||
// ============================================================
|
||||
match &hunks_after_turn_1[0].source {
|
||||
crate::types::HunkSource::AgentEdit { prompt_index } => {
|
||||
// FIX APPLIED: prompt_index is now 1 (the latest agent turn)
|
||||
// FIX APPLIED: prompt_index is 1 (the latest agent turn)
|
||||
assert_eq!(
|
||||
*prompt_index, 1,
|
||||
"FIXED: Hunk should be re-attributed to turn 1"
|
||||
@@ -1775,9 +1746,7 @@ line 5
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Integration Bug Test: record_agent_write vs handle_file_change
|
||||
// =========================================================================
|
||||
// This test demonstrates the bug in the CLI shell where tool execution
|
||||
// only triggers fs_notify (handle_file_change) but never calls record_agent_write.
|
||||
// This means ALL hunks from agent tools are classified as External, not AgentEdit.
|
||||
@@ -1811,7 +1780,7 @@ async fn test_bug_fs_notify_path_creates_external_hunks_not_agent_hunks() {
|
||||
crate::types::HunkSource::External => {
|
||||
// This is the CURRENT BROKEN BEHAVIOR
|
||||
// Since forward_to_hunk_tracker only calls handle_file_change,
|
||||
// and the file wasn't previously tracked as an agent file,
|
||||
// and the file wasn't already tracked as an agent file,
|
||||
// the hunk is created as External.
|
||||
}
|
||||
crate::types::HunkSource::AgentEdit { prompt_index } => {
|
||||
@@ -1840,7 +1809,8 @@ async fn test_record_agent_write_creates_agent_edit_hunks() {
|
||||
harness.write_baseline("tool_correct.rs", "original content\n");
|
||||
|
||||
// Simulate what SHOULD happen: tool calls record_agent_write directly
|
||||
let prompt_index = 5; // Example prompt index
|
||||
// Example prompt index
|
||||
let prompt_index = 5;
|
||||
harness.agent_write("tool_correct.rs", "modified by agent tool\n", prompt_index);
|
||||
harness.settle().await;
|
||||
|
||||
@@ -2232,9 +2202,7 @@ async fn test_repro_turn_action_preserves_other_turns() {
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Worktree Diff Bug: previous_content as fallback baseline
|
||||
// =========================================================================
|
||||
// When a session runs in a worktree created from dirty state, files that
|
||||
// exist on disk but are not committed to git should use previous_content
|
||||
// as the baseline, not None. Otherwise the diff shows the entire file
|
||||
@@ -2262,7 +2230,8 @@ async fn test_worktree_previous_content_used_as_baseline_when_not_in_git() {
|
||||
"hi.txt",
|
||||
"hello world\nanother line\n",
|
||||
0,
|
||||
Some("hello world\n"), // previous_content from the tool
|
||||
// previous_content from the tool
|
||||
Some("hello world\n"),
|
||||
);
|
||||
harness.settle().await;
|
||||
|
||||
@@ -2297,7 +2266,8 @@ async fn test_new_file_without_previous_content_shows_all_lines() {
|
||||
"brand_new.txt",
|
||||
"line 1\nline 2\n",
|
||||
0,
|
||||
None, // No previous content — truly new file
|
||||
// No previous content — truly new file
|
||||
None,
|
||||
);
|
||||
harness.settle().await;
|
||||
|
||||
@@ -2333,7 +2303,8 @@ async fn test_git_baseline_takes_precedence_over_previous_content() {
|
||||
"committed.txt",
|
||||
"original line 1\nmodified line 2\n",
|
||||
0,
|
||||
Some("some other content\n"), // previous_content differs from git HEAD
|
||||
// previous_content differs from git HEAD
|
||||
Some("some other content\n"),
|
||||
);
|
||||
harness.settle().await;
|
||||
|
||||
@@ -2356,9 +2327,7 @@ async fn test_git_baseline_takes_precedence_over_previous_content() {
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Baseline refresh after accept + git restore
|
||||
// =========================================================================
|
||||
|
||||
/// Reproduces the bug where accepting all hunks then running `git restore .`
|
||||
/// leaves the hunk tracker with a stale baseline, producing a giant backwards
|
||||
@@ -2504,12 +2473,10 @@ async fn test_binary_file_survives_baseline_refresh() {
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// HunkContentChanged event tests
|
||||
// =========================================================================
|
||||
// `HunkContentChanged` event tests
|
||||
|
||||
/// When the agent edits the same region twice, the hunk tracker must emit
|
||||
/// HunkContentChanged (not just HunkAdded) so LOC tracking records the update.
|
||||
/// `HunkContentChanged` (not just `HunkAdded`) so LOC tracking records the update.
|
||||
#[tokio::test]
|
||||
async fn test_content_changed_emitted_on_overlapping_agent_edit() {
|
||||
let mut harness = TestHarness::new();
|
||||
@@ -2520,7 +2487,8 @@ async fn test_content_changed_emitted_on_overlapping_agent_edit() {
|
||||
// Agent modifies lines 2-3 (prompt 0)
|
||||
harness.agent_write("content.rs", "line1\nchanged2\nchanged3\nline4\nline5\n", 0);
|
||||
harness.settle().await;
|
||||
harness.drain_events(); // consume initial events
|
||||
// consume initial events
|
||||
harness.drain_events();
|
||||
|
||||
// Agent edits the same region again, expanding it (prompt 1)
|
||||
harness.agent_write(
|
||||
@@ -2532,7 +2500,7 @@ async fn test_content_changed_emitted_on_overlapping_agent_edit() {
|
||||
|
||||
let events = harness.drain_events();
|
||||
|
||||
// Must contain at least one HunkContentChanged event
|
||||
// Must contain at least one `HunkContentChanged` event
|
||||
let content_changed_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter(|e| matches!(e, HunkEvent::HunkContentChanged { .. }))
|
||||
@@ -2568,7 +2536,7 @@ async fn test_content_changed_emitted_on_overlapping_agent_edit() {
|
||||
}
|
||||
|
||||
/// When a human externally edits a region that the agent already touched,
|
||||
/// HunkContentChanged must have trigger_source=ExternalEditOnAgentFile.
|
||||
/// `HunkContentChanged` must have trigger_source=ExternalEditOnAgentFile.
|
||||
#[tokio::test]
|
||||
async fn test_content_changed_external_edit_on_agent_hunk() {
|
||||
let mut harness = TestHarness::new();
|
||||
@@ -2625,12 +2593,12 @@ async fn test_content_changed_external_edit_on_agent_hunk() {
|
||||
/// different locations). Then agent writes a new version that merges
|
||||
/// both regions into one contiguous change. The diff engine produces
|
||||
/// one merged hunk. `find_matching_old_hunk` matches one old hunk and
|
||||
/// claims its ID. The other old hunk is now "orphaned" — but the merged
|
||||
/// claims its ID. The other old hunk is "orphaned" — but the merged
|
||||
/// new hunk still overlaps with it.
|
||||
///
|
||||
/// For HunkContentChanged, the prev lookup should find the matched old
|
||||
/// For `HunkContentChanged`, the prev lookup should find the matched old
|
||||
/// hunk by ID (primary path). We separately verify that non-ID-matched
|
||||
/// hunks that overlap still get a HunkRemoved event (the overlap fallback
|
||||
/// hunks that overlap still get a `HunkRemoved` event (the overlap fallback
|
||||
/// for prev is only used when a NEW hunk gets a fresh ID but has overlap).
|
||||
///
|
||||
/// To test the actual fallback: we need a case where `find_matching_old_hunk`
|
||||
@@ -2649,8 +2617,8 @@ async fn test_content_changed_prev_lookup_uses_overlap_fallback() {
|
||||
|
||||
// Agent writes: change line 3 and line 17 (two separate hunks far apart)
|
||||
let mut v1: Vec<String> = (1..=20).map(|i| format!("line{i}\n")).collect();
|
||||
v1[2] = "CHANGED3\n".to_string(); // line 3
|
||||
v1[16] = "CHANGED17\n".to_string(); // line 17
|
||||
v1[2] = "CHANGED3\n".to_string();
|
||||
v1[16] = "CHANGED17\n".to_string();
|
||||
harness.agent_write("overlap.rs", &v1.join(""), 0);
|
||||
harness.settle().await;
|
||||
|
||||
@@ -2662,20 +2630,23 @@ async fn test_content_changed_prev_lookup_uses_overlap_fallback() {
|
||||
hunks_v1.iter().map(|h| &h.line_info).collect::<Vec<_>>()
|
||||
);
|
||||
let old_hunk_ids: Vec<_> = hunks_v1.iter().map(|h| h.id.clone()).collect();
|
||||
harness.drain_events(); // consume v1 events
|
||||
// consume v1 events
|
||||
harness.drain_events();
|
||||
|
||||
// Agent writes again: change line 3 AND line 4 (expanding the first hunk
|
||||
// so it's different content). Also change line 17 differently.
|
||||
let mut v2: Vec<String> = (1..=20).map(|i| format!("line{i}\n")).collect();
|
||||
v2[2] = "CHANGED3_V2\n".to_string();
|
||||
v2[3] = "CHANGED4_V2\n".to_string(); // expand first hunk
|
||||
v2[16] = "CHANGED17_V2\n".to_string(); // change second hunk
|
||||
// expand first hunk
|
||||
v2[3] = "CHANGED4_V2\n".to_string();
|
||||
// change second hunk
|
||||
v2[16] = "CHANGED17_V2\n".to_string();
|
||||
harness.agent_write("overlap.rs", &v2.join(""), 1);
|
||||
harness.settle().await;
|
||||
|
||||
let events = harness.drain_events();
|
||||
|
||||
// We should see HunkContentChanged events with prev_lines_added > 0.
|
||||
// We should see `HunkContentChanged` events with `prev_lines_added` > 0.
|
||||
// At least one of them should have come from the overlap fallback path
|
||||
// (the old hunk whose ID was claimed by a different new hunk).
|
||||
let content_changed: Vec<_> = events
|
||||
@@ -2691,7 +2662,7 @@ async fn test_content_changed_prev_lookup_uses_overlap_fallback() {
|
||||
})
|
||||
.collect();
|
||||
|
||||
// There should be at least one HunkContentChanged
|
||||
// There should be at least one `HunkContentChanged`
|
||||
assert!(
|
||||
!content_changed.is_empty(),
|
||||
"Should emit HunkContentChanged for overlapping edits. Events: {:?}",
|
||||
@@ -2701,7 +2672,7 @@ async fn test_content_changed_prev_lookup_uses_overlap_fallback() {
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// Every HunkContentChanged should have prev_lines_added > 0
|
||||
// Every `HunkContentChanged` should have `prev_lines_added` > 0
|
||||
// (they all overlap with an old hunk that had lines)
|
||||
for (hunk_id, prev_added, _prev_removed) in &content_changed {
|
||||
assert!(
|
||||
@@ -2713,7 +2684,7 @@ async fn test_content_changed_prev_lookup_uses_overlap_fallback() {
|
||||
);
|
||||
}
|
||||
|
||||
// Verify that at least one HunkContentChanged has a NEW hunk ID
|
||||
// Verify that at least one `HunkContentChanged` has a NEW hunk ID
|
||||
// (not matching any old hunk ID) — this proves the overlap fallback
|
||||
// path was used (the hunk got a fresh ID because the old ID was
|
||||
// already claimed by another new hunk).
|
||||
@@ -2723,7 +2694,7 @@ async fn test_content_changed_prev_lookup_uses_overlap_fallback() {
|
||||
|
||||
// Note: this assertion may not always hold depending on diff engine
|
||||
// behavior (both hunks might get matched by ID). If it fails, the
|
||||
// test still validates that prev_lines_added > 0 for all events,
|
||||
// test still validates that `prev_lines_added` > 0 for all events,
|
||||
// which is the core correctness property. Log rather than fail.
|
||||
if !has_new_id {
|
||||
eprintln!(
|
||||
@@ -2734,9 +2705,7 @@ async fn test_content_changed_prev_lookup_uses_overlap_fallback() {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SF-1: State transition tests (Full <-> TooLarge, Full <-> Binary)
|
||||
// ============================================================================
|
||||
|
||||
/// SF-1: Test Full -> TooLarge transition via handle_file_change
|
||||
/// When a tracked text file grows beyond MAX_TRACKED_TEXT_BYTES, it should
|
||||
@@ -2761,7 +2730,6 @@ async fn test_transition_full_to_too_large_external_edit() {
|
||||
let tracked = harness.handle.get_all_tracked_paths().await;
|
||||
assert!(tracked.contains(&file_path), "File should be tracked");
|
||||
|
||||
// Now grow the file beyond MAX_TRACKED_TEXT_BYTES
|
||||
let large_content = "x".repeat(MAX_TRACKED_TEXT_BYTES + 100);
|
||||
std::fs::write(&file_path, &large_content).unwrap();
|
||||
|
||||
@@ -2946,9 +2914,7 @@ async fn test_too_large_survives_baseline_refresh() {
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// get_file_hunk_data() Query API Tests
|
||||
// =========================================================================
|
||||
// These tests verify the explicit FileContentStatus contract exposed by
|
||||
// get_file_hunk_data(), ensuring Missing, Binary, TooLarge, and Full states
|
||||
// are correctly propagated through the query/API surface.
|
||||
@@ -3251,9 +3217,7 @@ async fn test_get_file_hunk_data_mixed_states() {
|
||||
assert!(data.hunks.is_empty());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Action-Path Hardening Tests
|
||||
// =========================================================================
|
||||
// These tests verify that accept/reject actions are safe under the explicit
|
||||
// content-state model, and that transitions correctly clear/create hunks.
|
||||
|
||||
@@ -3595,9 +3559,7 @@ async fn test_file_creation_hunk_action() {
|
||||
assert!(!exists, "File should be deleted after rejecting creation");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Validation + UI Messaging Smoke Tests
|
||||
// =========================================================================
|
||||
// These tests verify that the API correctly exposes file content status
|
||||
// for clients to display appropriate UI messages (e.g., "file too large").
|
||||
|
||||
@@ -3611,7 +3573,8 @@ async fn test_ui_messaging_too_large_file() {
|
||||
|
||||
// Create a large file
|
||||
let file_path = harness.working_dir.join("huge_ui_test.txt");
|
||||
let large_size = MAX_TRACKED_TEXT_BYTES + 500_000; // ~1.5 MB
|
||||
// ~1.5 MB
|
||||
let large_size = MAX_TRACKED_TEXT_BYTES + 500_000;
|
||||
let large_content = "x".repeat(large_size);
|
||||
std::fs::write(&file_path, &large_content).unwrap();
|
||||
|
||||
@@ -3854,9 +3817,7 @@ async fn test_memory_bounded_multiple_large_files() {
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Issue #2: Deleted committed files visible as deletion hunks
|
||||
// =========================================================================
|
||||
|
||||
/// Deleting a committed file that was never tracked by the hunk tracker
|
||||
/// should produce a deletion hunk (in AllDirty mode).
|
||||
@@ -3915,7 +3876,8 @@ async fn test_deleted_committed_file_produces_deletion_hunk() {
|
||||
/// produce any hunks (we only track agent files).
|
||||
#[tokio::test]
|
||||
async fn test_deleted_committed_file_ignored_in_agent_only_mode() {
|
||||
let mut harness = TestHarness::new(); // AgentOnly mode
|
||||
// AgentOnly mode
|
||||
let mut harness = TestHarness::new();
|
||||
|
||||
// Commit a file (never tracked by hunk tracker)
|
||||
harness.write_baseline("foo.txt", "content\n");
|
||||
@@ -3983,14 +3945,13 @@ async fn test_deleted_file_cleaned_up_after_commit() {
|
||||
let hunks = harness.get_all_hunks().await;
|
||||
assert_eq!(hunks.len(), 1, "Should have deletion hunk");
|
||||
|
||||
// Now commit the deletion
|
||||
git(&harness.working_dir, &["add", "cleanup.txt"]);
|
||||
git(
|
||||
&harness.working_dir,
|
||||
&["commit", "-m", "delete cleanup.txt"],
|
||||
);
|
||||
|
||||
// Refresh baselines (simulates git_head_changed)
|
||||
// Refresh baselines (simulates `git_head_changed`)
|
||||
harness.handle.refresh_all_baselines();
|
||||
harness.settle().await;
|
||||
|
||||
@@ -4047,11 +4008,9 @@ async fn test_reject_deletion_of_committed_file_restores_it() {
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Issue #1: Staged-only files visible after git reset --soft HEAD^
|
||||
// =========================================================================
|
||||
|
||||
/// After `git reset --soft HEAD^`, a file that was added in the undone
|
||||
/// After `git reset --soft HEAD^`, a file added by the undone
|
||||
/// commit should appear as a new file (staged in index, not in HEAD).
|
||||
#[tokio::test]
|
||||
async fn test_soft_reset_staged_new_file_visible() {
|
||||
@@ -4066,7 +4025,7 @@ async fn test_soft_reset_staged_new_file_visible() {
|
||||
// Soft reset: moves HEAD back but keeps index and worktree
|
||||
git(&harness.working_dir, &["reset", "--soft", "HEAD^"]);
|
||||
|
||||
// Trigger baseline refresh (simulates git_head_changed)
|
||||
// Trigger baseline refresh (simulates `git_head_changed`)
|
||||
harness.handle.refresh_all_baselines();
|
||||
harness.settle().await;
|
||||
|
||||
@@ -4265,7 +4224,7 @@ async fn test_mixed_reset_modified_files_visible() {
|
||||
);
|
||||
}
|
||||
|
||||
/// After `git reset HEAD~1` (mixed reset), newly added files in the undone
|
||||
/// After `git reset HEAD~1` (mixed reset), newly created files in the undone
|
||||
/// commit should appear as untracked new files with creation hunks.
|
||||
#[tokio::test]
|
||||
async fn test_mixed_reset_new_file_visible() {
|
||||
@@ -4305,9 +4264,7 @@ async fn test_mixed_reset_new_file_visible() {
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// GetAllFileContents Tests
|
||||
// =========================================================================
|
||||
|
||||
/// Empty actor returns no file contents.
|
||||
#[tokio::test]
|
||||
@@ -4550,9 +4507,7 @@ async fn test_get_all_file_contents_returns_absolute_paths() {
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// refresh_all_baselines: non-diffable file cleanup via git dirty cache
|
||||
// =========================================================================
|
||||
|
||||
/// A committed binary file that is NOT dirty in git status should be removed
|
||||
/// from tracking after refresh_all_baselines (no longer a phantom).
|
||||
@@ -4723,9 +4678,7 @@ async fn test_dirty_lfs_file_survives_refresh() {
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Gitignored file filtering tests
|
||||
// =========================================================================
|
||||
|
||||
/// Gitignored files (e.g., cargo build artifacts in `target/`) should NOT
|
||||
/// be tracked in AllDirty mode. The git dirty cache never contains ignored
|
||||
@@ -4820,10 +4773,9 @@ async fn test_gitignored_file_cleaned_up_by_refresh_all_baselines() {
|
||||
let tracked = harness.handle.get_all_tracked_paths().await;
|
||||
assert!(tracked.contains(&real_file), "Dirty file should be tracked");
|
||||
|
||||
// Now restore the file so it's clean
|
||||
std::fs::write(&real_file, "original\n").unwrap();
|
||||
|
||||
// Trigger refresh — the file is now clean (baseline == current), should be removed
|
||||
// Trigger refresh — the file is clean (baseline == current), should be removed
|
||||
harness.handle.refresh_all_baselines();
|
||||
let _ = harness.handle.get_all_hunks().await;
|
||||
|
||||
@@ -4998,9 +4950,7 @@ async fn test_untracked_directory_tracks_child_files_not_directory() {
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Command Coalescing Tests
|
||||
// =========================================================================
|
||||
|
||||
use crate::actor::{CoalescedBatch, CoalescedPathAction};
|
||||
use crate::commands::HunkTrackerCommand;
|
||||
@@ -5279,7 +5229,7 @@ async fn test_coalescing_delete_then_recreate() {
|
||||
|
||||
/// Integration test: refresh_all + file changes are correctly coalesced.
|
||||
/// When refresh_all is in the batch, tracked-file changes should be skipped
|
||||
/// (refresh_all handles them), but new files should still be added.
|
||||
/// (refresh_all handles them), but new files should still be tracked.
|
||||
#[tokio::test]
|
||||
async fn test_coalescing_refresh_all_with_file_changes() {
|
||||
let mut harness = TestHarness::with_mode(TrackingMode::AllDirty);
|
||||
@@ -5290,7 +5240,6 @@ async fn test_coalescing_refresh_all_with_file_changes() {
|
||||
harness.agent_write("existing.rs", "modified\n", 0);
|
||||
harness.settle().await;
|
||||
|
||||
// Now queue: file change on existing + refresh_all + new file change
|
||||
std::fs::write(harness.working_dir.join("existing.rs"), "v2\n").unwrap();
|
||||
harness
|
||||
.handle
|
||||
@@ -5396,9 +5345,7 @@ async fn test_snapshot_turn_delta_is_per_turn() {
|
||||
assert!(empty.file_states.is_empty() && empty.hunk_ids.is_empty());
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Baseline refresh during git rebases (scan counting + hunk preservation)
|
||||
// =========================================================================
|
||||
|
||||
/// Count of `BaselineUpdated` events in a drained batch. The real
|
||||
/// `refresh_all_baselines` scan path emits one per still-tracked file, while
|
||||
@@ -5412,9 +5359,7 @@ fn baseline_updates(events: &[HunkEvent]) -> usize {
|
||||
.count()
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Scoped (pathspec-limited) dirty-cache scans
|
||||
// =========================================================================
|
||||
|
||||
/// Construct an actor directly (not spawned, via the production constructor)
|
||||
/// so tests can call `pub(super)` methods like `refresh_git_dirty_cache` and
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::types::{
|
||||
/// Commands sent to the HunkTrackerActor via mpsc channel.
|
||||
#[derive(Debug)]
|
||||
pub enum HunkTrackerCommand {
|
||||
// === Mutation Commands (fire-and-forget) ===
|
||||
// Mutation Commands (fire-and-forget)
|
||||
/// Agent tool wrote to a file - record it and compute hunks
|
||||
RecordAgentWrite {
|
||||
path: PathBuf,
|
||||
@@ -40,7 +40,7 @@ pub enum HunkTrackerCommand {
|
||||
/// Set tracking mode
|
||||
SetMode { mode: TrackingMode },
|
||||
|
||||
// === Action Commands (accept/reject hunks) ===
|
||||
// Action Commands (accept/reject hunks)
|
||||
/// Apply action (accept/reject) to a specific hunk
|
||||
HunkAction {
|
||||
hunk_id: HunkId,
|
||||
@@ -68,7 +68,7 @@ pub enum HunkTrackerCommand {
|
||||
reply: oneshot::Sender<Result<Vec<HunkId>, HunkActionError>>,
|
||||
},
|
||||
|
||||
// === Query Commands (request-response via oneshot) ===
|
||||
// Query Commands (request-response via oneshot)
|
||||
/// Get all current hunks
|
||||
GetAllHunks {
|
||||
reply: oneshot::Sender<Vec<Arc<Hunk>>>,
|
||||
@@ -121,7 +121,7 @@ pub enum HunkTrackerCommand {
|
||||
reply: oneshot::Sender<Vec<FileContentEntry>>,
|
||||
},
|
||||
|
||||
// === Session Summary Commands ===
|
||||
// Session Summary Commands
|
||||
/// Get complete session summary (stats + pending turns)
|
||||
GetSessionSummary {
|
||||
reply: oneshot::Sender<SessionSummary>,
|
||||
@@ -140,7 +140,7 @@ pub enum HunkTrackerCommand {
|
||||
/// content from disk. Used after a git HEAD/index change to reconcile stale state.
|
||||
RefreshAllBaselines,
|
||||
|
||||
// === Snapshot / Restore Commands (for cross-session sync-back) ===
|
||||
// Snapshot / Restore Commands (for cross-session sync-back)
|
||||
/// Take a snapshot of all hunk tracker state for preservation across
|
||||
/// session kill/reload cycles.
|
||||
SnapshotState {
|
||||
@@ -153,7 +153,7 @@ pub enum HunkTrackerCommand {
|
||||
reply: oneshot::Sender<HunkTurnDelta>,
|
||||
},
|
||||
|
||||
/// Restore a previously snapshotted state. Replaces all current file
|
||||
/// Restore a earlier snapshotted state. Replaces all current file
|
||||
/// states, turn index, and session stats.
|
||||
RestoreState(HunkTrackerSnapshot),
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ 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
|
||||
const MAX_DIFF_FILE_SIZE: usize = 1024 * 1024;
|
||||
|
||||
/// Generate a unified diff patch string from baseline and current content.
|
||||
/// This produces a patch that can be parsed by Pierre's `getSingularPatch`.
|
||||
@@ -112,7 +112,8 @@ pub fn generate_hunk_patch(baseline: &str, current: &str, hunk: &Hunk) -> String
|
||||
|
||||
// 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
|
||||
// Context is same in both
|
||||
let header_new_start = context_before_start + 1;
|
||||
|
||||
let _ = writeln!(
|
||||
output,
|
||||
@@ -297,7 +298,8 @@ impl HunkBuilder {
|
||||
source,
|
||||
old_text,
|
||||
new_text,
|
||||
patch: None, // Patch is generated later when requested
|
||||
// Patch is generated later when requested
|
||||
patch: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
selected: false,
|
||||
}
|
||||
@@ -346,7 +348,8 @@ pub fn patch_lines(
|
||||
insert_text: &str,
|
||||
) -> String {
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let start_idx = start_line.saturating_sub(1); // Convert to 0-indexed
|
||||
// Convert to 0-indexed
|
||||
let start_idx = start_line.saturating_sub(1);
|
||||
|
||||
let mut result = Vec::new();
|
||||
|
||||
@@ -608,7 +611,8 @@ mod tests {
|
||||
line_info: HunkLineInfo {
|
||||
old_start: 10,
|
||||
old_count: 1,
|
||||
new_start: 12, // slightly shifted
|
||||
// slightly shifted
|
||||
new_start: 12,
|
||||
new_count: 1,
|
||||
},
|
||||
source: agent_source(),
|
||||
@@ -630,7 +634,8 @@ mod tests {
|
||||
line_info: HunkLineInfo {
|
||||
old_start: 100,
|
||||
old_count: 1,
|
||||
new_start: 102, // slightly shifted
|
||||
// slightly shifted
|
||||
new_start: 102,
|
||||
new_count: 1,
|
||||
},
|
||||
source: agent_source(),
|
||||
@@ -665,7 +670,8 @@ mod tests {
|
||||
old_start: 1,
|
||||
old_count: 1,
|
||||
new_start: 1,
|
||||
new_count: 2, // covers new lines 1-2
|
||||
// covers new lines 1-2
|
||||
new_count: 2,
|
||||
},
|
||||
source: agent_source(),
|
||||
old_text: Some("old-small\n".to_string()),
|
||||
@@ -682,7 +688,8 @@ mod tests {
|
||||
old_start: 3,
|
||||
old_count: 1,
|
||||
new_start: 3,
|
||||
new_count: 4, // covers new lines 3-6
|
||||
// covers new lines 3-6
|
||||
new_count: 4,
|
||||
},
|
||||
source: agent_source(),
|
||||
old_text: Some("old-large\n".to_string()),
|
||||
@@ -692,7 +699,8 @@ mod tests {
|
||||
selected: false,
|
||||
});
|
||||
|
||||
let old_hunks = vec![old_hunk_small.clone(), old_hunk_large.clone()]; // small first!
|
||||
// small first!
|
||||
let old_hunks = vec![old_hunk_small.clone(), old_hunk_large.clone()];
|
||||
|
||||
// New hunk overlaps both, but more with large:
|
||||
// new lines 2-5 (end=6)
|
||||
@@ -706,7 +714,8 @@ mod tests {
|
||||
old_start: 2,
|
||||
old_count: 4,
|
||||
new_start: 2,
|
||||
new_count: 4, // lines 2-5
|
||||
// lines 2-5
|
||||
new_count: 4,
|
||||
},
|
||||
source: agent_source(),
|
||||
old_text: Some("different-old\n".to_string()),
|
||||
|
||||
@@ -6,7 +6,7 @@ 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
|
||||
/// Why the tracker removed a hunk. 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")]
|
||||
@@ -30,7 +30,7 @@ pub enum HunkEvent {
|
||||
/// A new hunk was created
|
||||
HunkAdded { path: PathBuf, hunk: Arc<Hunk> },
|
||||
|
||||
/// A hunk was removed.
|
||||
/// A hunk stopped being tracked.
|
||||
HunkRemoved {
|
||||
path: PathBuf,
|
||||
hunk_id: HunkId,
|
||||
@@ -69,6 +69,6 @@ pub enum HunkEvent {
|
||||
/// 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)
|
||||
/// The tracker updated a file's baseline (after accept or commit)
|
||||
BaselineUpdated { path: PathBuf },
|
||||
}
|
||||
|
||||
@@ -68,7 +68,6 @@ impl HunkTrackerHandle {
|
||||
.send(HunkTrackerCommand::HandleFileDeleted { path });
|
||||
}
|
||||
|
||||
/// Refresh git dirty cache.
|
||||
pub fn refresh_git_dirty_cache(&self) {
|
||||
let _ = self.cmd_tx.send(HunkTrackerCommand::RefreshGitDirtyCache);
|
||||
}
|
||||
@@ -140,7 +139,6 @@ impl HunkTrackerHandle {
|
||||
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
|
||||
@@ -169,7 +167,6 @@ impl HunkTrackerHandle {
|
||||
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 {
|
||||
@@ -293,7 +290,7 @@ impl HunkTrackerHandle {
|
||||
reply_rx.await.ok()
|
||||
}
|
||||
|
||||
/// Restore a previously snapshotted state. Replaces all current file
|
||||
/// Restore a earlier 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.
|
||||
|
||||
@@ -17,9 +17,7 @@ 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)]
|
||||
@@ -65,7 +63,7 @@ pub enum EventType {
|
||||
Added,
|
||||
/// An existing hunk's content changed in place.
|
||||
Updated,
|
||||
/// A hunk was removed. `lines_added` / `lines_removed` are negated
|
||||
/// Removal of an existing hunk. `lines_added` / `lines_removed` are negated
|
||||
/// so that `SUM` zeroes out the hunk's accumulated contribution.
|
||||
Removed,
|
||||
}
|
||||
@@ -80,9 +78,7 @@ impl std::fmt::Display for EventType {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HunkRecord
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single LOC attribution record derived from a [`Hunk`].
|
||||
///
|
||||
@@ -126,7 +122,7 @@ pub struct HunkRecord {
|
||||
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.
|
||||
/// Reason for the removal. Only set for [`EventType::Removed`] records.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub removal_reason: Option<HunkRemovalReason>,
|
||||
}
|
||||
@@ -201,9 +197,7 @@ impl HunkRecord {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HunkRecordWriter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Trait for persisting [`HunkRecord`]s.
|
||||
///
|
||||
@@ -279,9 +273,7 @@ impl HunkRecordWriter for JsonlHunkRecordWriter {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 `kigi-shell`).
|
||||
@@ -290,7 +282,7 @@ impl HunkRecordWriter for JsonlHunkRecordWriter {
|
||||
/// The bridge task translates them into `SignalEvent` variants.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LocAggregate {
|
||||
/// Lines were added or changed (from HunkAdded or HunkContentChanged).
|
||||
/// New or modified lines (from `HunkAdded` or `HunkContentChanged`).
|
||||
LinesChanged {
|
||||
author_type: AuthorType,
|
||||
lines_added: i64,
|
||||
@@ -305,9 +297,7 @@ pub enum LocAggregate {
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sink configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Context passed to the LOC sink at spawn time.
|
||||
pub struct LocSinkContext {
|
||||
@@ -322,9 +312,7 @@ pub struct LocSinkContext {
|
||||
pub aggregate_tx: Option<mpsc::UnboundedSender<LocAggregate>>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// run_loc_sink
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Consume [`HunkEvent`]s and write LOC attribution records.
|
||||
///
|
||||
@@ -344,7 +332,7 @@ pub async fn run_loc_sink(
|
||||
ctx: LocSinkContext,
|
||||
cancellation_token: tokio_util::sync::CancellationToken,
|
||||
) {
|
||||
// Accumulated (lines_added, lines_removed) per hunk_id.
|
||||
// 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();
|
||||
|
||||
@@ -383,7 +371,7 @@ async fn handle_event(
|
||||
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).
|
||||
// `lines_added`/`lines_removed` are the full counts (no prior state).
|
||||
let record = HunkRecord::from_hunk(
|
||||
hunk,
|
||||
&ctx.session_id,
|
||||
|
||||
@@ -9,9 +9,7 @@ use crate::types::{Hunk, HunkId, HunkLineInfo, HunkSource};
|
||||
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn sample_agent_hunk() -> Hunk {
|
||||
Hunk {
|
||||
@@ -129,9 +127,7 @@ fn make_ctx() -> LocSinkContext {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unit tests: HunkRecord::from_hunk
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn from_hunk_agent_edit() {
|
||||
@@ -148,7 +144,8 @@ fn from_hunk_agent_edit() {
|
||||
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
|
||||
// 10 + 5 - 1
|
||||
assert_eq!(record.hunk_end, 14);
|
||||
assert_eq!(record.lines_added, 5);
|
||||
assert_eq!(record.lines_removed, 3);
|
||||
assert_eq!(record.author_type, Some(AuthorType::Agent));
|
||||
@@ -191,7 +188,8 @@ fn from_hunk_external() {
|
||||
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
|
||||
// 1 + 4 - 1
|
||||
assert_eq!(record.hunk_end, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -244,7 +242,8 @@ fn from_hunk_pure_deletion() {
|
||||
|
||||
// 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
|
||||
// 5 + 3 - 1
|
||||
assert_eq!(record.hunk_end, 7);
|
||||
assert_eq!(record.lines_added, 0i64);
|
||||
assert_eq!(record.lines_removed, 3i64);
|
||||
}
|
||||
@@ -252,7 +251,8 @@ fn from_hunk_pure_deletion() {
|
||||
/// 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
|
||||
// hunk.source = AgentEdit
|
||||
let hunk = sample_agent_hunk();
|
||||
let trigger = HunkSource::ExternalEditOnAgentFile;
|
||||
let record = HunkRecord::from_hunk(
|
||||
&hunk,
|
||||
@@ -273,9 +273,7 @@ fn from_hunk_trigger_source_overrides_preserved_source() {
|
||||
assert_eq!(record.event_type, EventType::Updated);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sink tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn sink_processes_added_and_content_changed() {
|
||||
@@ -285,7 +283,8 @@ async fn sink_processes_added_and_content_changed() {
|
||||
|
||||
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
|
||||
// grew from 5 to 8 lines
|
||||
updated_hunk.line_info.new_count = 8;
|
||||
|
||||
// Send a mix of events — only HunkAdded and HunkContentChanged should produce records
|
||||
tx.send(HunkEvent::FileAdded {
|
||||
@@ -302,8 +301,10 @@ async fn sink_processes_added_and_content_changed() {
|
||||
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
|
||||
// original hunk had 5 lines added
|
||||
prev_lines_added: 5,
|
||||
// original hunk had 3 lines removed
|
||||
prev_lines_removed: 3,
|
||||
})
|
||||
.unwrap();
|
||||
tx.send(HunkEvent::HunkMoved {
|
||||
@@ -377,7 +378,8 @@ async fn sink_removed_hunk_zeroes_out_accumulated_total() {
|
||||
let ctx = make_ctx();
|
||||
let cancel = tokio_util::sync::CancellationToken::new();
|
||||
|
||||
let hunk = sample_agent_hunk(); // lines_added=5, lines_removed=3
|
||||
// lines_added=5, lines_removed=3
|
||||
let hunk = sample_agent_hunk();
|
||||
let hunk_id = hunk.id.clone();
|
||||
let path = hunk.path.clone();
|
||||
|
||||
@@ -429,12 +431,14 @@ async fn sink_removed_hunk_after_updates_zeroes_correctly() {
|
||||
let ctx = make_ctx();
|
||||
let cancel = tokio_util::sync::CancellationToken::new();
|
||||
|
||||
let hunk = sample_agent_hunk(); // lines_added=5, lines_removed=3
|
||||
// lines_added=5, lines_removed=3
|
||||
let hunk = sample_agent_hunk();
|
||||
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
|
||||
// grew from 5 → 8
|
||||
updated.line_info.new_count = 8;
|
||||
|
||||
// Add → update → remove
|
||||
tx.send(HunkEvent::HunkAdded {
|
||||
@@ -478,7 +482,8 @@ async fn sink_accepted_hunk_preserves_loc() {
|
||||
let ctx = make_ctx();
|
||||
let cancel = tokio_util::sync::CancellationToken::new();
|
||||
|
||||
let hunk = sample_agent_hunk(); // lines_added=5, lines_removed=3
|
||||
// lines_added=5, lines_removed=3
|
||||
let hunk = sample_agent_hunk();
|
||||
let hunk_id = hunk.id.clone();
|
||||
let path = hunk.path.clone();
|
||||
|
||||
@@ -576,7 +581,8 @@ async fn sink_shrinking_hunk_produces_negative_delta() {
|
||||
.sum();
|
||||
assert_eq!(agent_total, 10);
|
||||
assert_eq!(human_total, -3);
|
||||
assert_eq!(agent_total + human_total, 7); // net lines in file
|
||||
// net lines in file
|
||||
assert_eq!(agent_total + human_total, 7);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -615,9 +621,7 @@ async fn sink_drains_on_cancellation() {
|
||||
assert!(w.flush_count > 0, "Writer should be flushed on shutdown");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSONL round-trip test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn jsonl_round_trip() {
|
||||
@@ -687,9 +691,7 @@ async fn jsonl_writer_appends() {
|
||||
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"
|
||||
@@ -728,9 +730,7 @@ fn deserialize_rejects_invalid_source_type() {
|
||||
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.
|
||||
|
||||
@@ -47,11 +47,11 @@ impl std::fmt::Display for HunkId {
|
||||
pub struct HunkLineInfo {
|
||||
/// 1-indexed start line in baseline (old) file
|
||||
pub old_start: usize,
|
||||
/// Number of lines from baseline that were changed/deleted
|
||||
/// Number of baseline lines this hunk changes or deletes
|
||||
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
|
||||
/// Number of current-file lines this hunk adds or modifies
|
||||
pub new_count: usize,
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ pub enum HunkSource {
|
||||
prompt_index: usize,
|
||||
},
|
||||
|
||||
/// External edit (by user) to a file the agent has previously touched.
|
||||
/// External edit (by user) to a file the agent has already touched.
|
||||
/// These are tracked separately so we know they're "part of agent session"
|
||||
/// but weren't written by the agent itself.
|
||||
ExternalEditOnAgentFile,
|
||||
@@ -253,7 +253,7 @@ pub enum HunkAction {
|
||||
pub enum HunkUpdate {
|
||||
/// A new hunk was created
|
||||
Added(Hunk),
|
||||
/// A hunk was removed (accepted, rejected, or reverted)
|
||||
/// A hunk left the pending set (accepted, rejected, or reverted)
|
||||
Removed { hunk_id: HunkId },
|
||||
/// A hunk's position changed but content is the same
|
||||
Moved {
|
||||
@@ -299,9 +299,7 @@ pub enum TrackingMode {
|
||||
AllDirty,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session Stats & Summary
|
||||
// ============================================================================
|
||||
|
||||
/// Simple counters for session summary. Reset on baseline reset (commit).
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
@@ -359,9 +357,7 @@ pub struct SessionSummary {
|
||||
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
|
||||
@@ -494,13 +490,13 @@ pub struct FileHunkData {
|
||||
/// Hunks for this file (each hunk includes its own patch fragment)
|
||||
pub hunks: Vec<Arc<Hunk>>,
|
||||
|
||||
// === Explicit content status (new fields) ===
|
||||
// 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 ===
|
||||
// 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
|
||||
@@ -511,9 +507,7 @@ pub struct FileHunkData {
|
||||
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.
|
||||
@@ -782,7 +776,7 @@ mod snapshot_tests {
|
||||
assert_eq!(snap.session_stats.accepted_hunks, 5);
|
||||
}
|
||||
|
||||
// === Snapshot preserves Binary/TooLarge (regression test) ===
|
||||
// Snapshot preserves Binary/TooLarge (regression test)
|
||||
|
||||
#[test]
|
||||
fn snapshot_preserves_binary_state() {
|
||||
@@ -855,9 +849,7 @@ mod snapshot_tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FileContentView Tests (content status propagation)
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod content_view_tests {
|
||||
|
||||
Reference in New Issue
Block a user