M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
ZacharyZhang-NY committed 2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 files changed
+1353757

No files matched your search

@@ -0,0 +1,635 @@
//! Action commands for the HunkTrackerActor.
//!
//! These methods handle accept/reject actions on hunks.
//!
//! # Invariants
//!
//! Hunks only exist for files where both baseline and current content are
//! patchable (i.e., `FileContentState::Full` or `FileContentState::Missing`
//! for creation/deletion). Files with `Binary` or `TooLarge` content states
//! have their hunks cleared by `recompute_hunks()`.
//!
//! This means action handlers should never encounter a non-patchable state
//! when processing a hunk. The guards in these methods are defensive fallbacks
//! that silently skip non-patchable states rather than panic.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::diff::patch_lines;
use crate::events::{HunkEvent, HunkRemovalReason};
use crate::types::{Hunk, HunkAction, HunkActionError, HunkId, HunkLineInfo, HunkSource};
use super::HunkTrackerActor;
use super::state::FileContentState;
impl HunkTrackerActor {
/// Update session stats when a hunk is accepted or rejected.
fn update_session_stats(&mut self, line_info: &HunkLineInfo, accepted: bool) {
let lines_added = line_info.new_count;
let lines_removed = line_info.old_count;
if accepted {
self.session_stats.accepted_hunks = self.session_stats.accepted_hunks.saturating_add(1);
self.session_stats.accepted_lines_added = self
.session_stats
.accepted_lines_added
.saturating_add(lines_added);
self.session_stats.accepted_lines_removed = self
.session_stats
.accepted_lines_removed
.saturating_add(lines_removed);
} else {
self.session_stats.rejected_hunks = self.session_stats.rejected_hunks.saturating_add(1);
self.session_stats.rejected_lines_added = self
.session_stats
.rejected_lines_added
.saturating_add(lines_added);
self.session_stats.rejected_lines_removed = self
.session_stats
.rejected_lines_removed
.saturating_add(lines_removed);
}
}
/// Remove a hunk from turn_index based on its source.
fn remove_from_turn_index(&mut self, hunk_id: &HunkId, source: &HunkSource) {
if let Some(prompt_index) = source.prompt_index()
&& let Some(set) = self.turn_index.get_mut(&prompt_index)
{
set.remove(hunk_id);
}
}
/// Apply action (accept/reject) to a specific hunk.
pub(super) async fn apply_hunk_action(
&mut self,
hunk_id: &HunkId,
action: HunkAction,
) -> Result<(), HunkActionError> {
// Find which file contains this hunk and capture the full hunk data
let hunk_info = self.file_states.iter().find_map(|(path, state)| {
state
.hunks
.iter()
.find(|h| &h.id == hunk_id)
.map(|h| (path.clone(), h.clone()))
});
let Some((path, hunk)) = hunk_info else {
return Err(HunkActionError::HunkNotFound(hunk_id.clone()));
};
// Update session stats before removing the hunk
let accepted = matches!(action, HunkAction::Accept);
self.update_session_stats(&hunk.line_info, accepted);
// Remove from turn_index
self.remove_from_turn_index(hunk_id, &hunk.source);
match action {
HunkAction::Accept => {
self.accept_hunk(&path, &hunk).await?;
}
HunkAction::Reject => {
self.reject_hunk(&path, &hunk).await?;
}
}
Ok(())
}
/// Accept a single hunk: patch baseline to include this hunk's changes.
async fn accept_hunk(&mut self, path: &Path, hunk: &Arc<Hunk>) -> Result<(), HunkActionError> {
// Collect data and perform mutations, then send events afterward
let (should_recompute, current, _source) = {
let state = self.file_states.get_mut(path);
let Some(state) = state else {
return Ok(());
};
// Handle file creation case (no baseline)
if matches!(state.baseline, FileContentState::Missing) {
// File was created - accepting makes current the baseline for this hunk's lines
// For a newly created file, accept just sets baseline = current
state.baseline = state.current_content.clone();
state.baseline_accepted = true;
state.hunks.retain(|h| h.id != hunk.id);
// Don't recompute for file creation (no other hunks exist)
(false, None, hunk.source)
} else {
// Patch baseline to include ONLY this hunk's changes
if let FileContentState::Full(baseline) = &state.baseline {
let patched = patch_lines(
baseline,
hunk.line_info.old_start,
hunk.line_info.old_count,
&hunk.new_text,
);
state.baseline = FileContentState::Full(patched);
}
state.baseline_accepted = true;
// Remove this hunk from the list
state.hunks.retain(|h| h.id != hunk.id);
// Pass FileContentState directly (R3/MF-4: preserve Binary/TooLarge, no String extraction)
let current = Some(state.current_content.clone());
let source = hunk.source;
(true, current, source)
}
};
// Send events after mutable borrow is released
self.send_event(HunkEvent::HunkRemoved {
path: path.to_path_buf(),
hunk_id: hunk.id.clone(),
reason: HunkRemovalReason::Accepted,
});
self.send_event(HunkEvent::BaselineUpdated {
path: path.to_path_buf(),
});
// Recompute remaining hunks (positions may have shifted)
if should_recompute {
// Use the source of the first remaining hunk if any, else External
let remaining_source = self
.file_states
.get(path)
.and_then(|state| state.hunks.first())
.map(|h| h.source)
.unwrap_or(HunkSource::External);
self.recompute_hunks(path, current, remaining_source);
}
Ok(())
}
/// Reject a single hunk: patch current content to revert this hunk's changes.
async fn reject_hunk(&mut self, path: &Path, hunk: &Arc<Hunk>) -> Result<(), HunkActionError> {
// First, determine what action to take and collect data
enum RejectAction {
RestoreDeleted { baseline: String },
DeleteCreated,
RevertChange { patched: String },
NoOp,
}
let action = {
let state = self.file_states.get(path);
let Some(state) = state else {
return Ok(());
};
if matches!(state.current_content, FileContentState::Missing)
&& matches!(state.baseline, FileContentState::Full(_))
{
// File was deleted, rejecting restores it
if let FileContentState::Full(baseline) = &state.baseline {
RejectAction::RestoreDeleted {
baseline: baseline.clone(),
}
} else {
RejectAction::NoOp
}
} else if matches!(state.baseline, FileContentState::Missing)
&& matches!(state.current_content, FileContentState::Full(_))
{
// File was created, rejecting deletes it
RejectAction::DeleteCreated
} else if let FileContentState::Full(current) = &state.current_content {
// Normal case: patch current content to revert this hunk's changes
let old_text = hunk.old_text.as_deref().unwrap_or("");
let patched = patch_lines(
current,
hunk.line_info.new_start,
hunk.line_info.new_count,
old_text,
);
RejectAction::RevertChange { patched }
} else {
RejectAction::NoOp
}
};
// Perform file I/O based on the action
match &action {
RejectAction::RestoreDeleted { baseline } => {
tokio::fs::write(path, baseline).await.map_err(|e| {
HunkActionError::WriteError {
path: path.to_path_buf(),
source: e,
}
})?;
}
RejectAction::DeleteCreated => {
tokio::fs::remove_file(path)
.await
.map_err(|e| HunkActionError::DeleteError {
path: path.to_path_buf(),
source: e,
})?;
}
RejectAction::RevertChange { patched } => {
tokio::fs::write(path, patched)
.await
.map_err(|e| HunkActionError::WriteError {
path: path.to_path_buf(),
source: e,
})?;
}
RejectAction::NoOp => return Ok(()),
}
// Update state and collect data for recompute
let (should_recompute, current, _source) = {
let state = self.file_states.get_mut(path);
let Some(state) = state else {
return Ok(());
};
match action {
RejectAction::RestoreDeleted { baseline } => {
state.current_content = FileContentState::Full(baseline);
state.hunks.retain(|h| h.id != hunk.id);
(false, None, hunk.source)
}
RejectAction::DeleteCreated => {
state.current_content = FileContentState::Missing;
state.hunks.retain(|h| h.id != hunk.id);
(false, None, hunk.source)
}
RejectAction::RevertChange { patched } => {
let patched_state = FileContentState::Full(patched);
state.current_content = patched_state.clone();
state.hunks.retain(|h| h.id != hunk.id);
(true, Some(patched_state), hunk.source)
}
RejectAction::NoOp => return Ok(()),
}
};
// Send event after mutable borrow is released
self.send_event(HunkEvent::HunkRemoved {
path: path.to_path_buf(),
hunk_id: hunk.id.clone(),
reason: HunkRemovalReason::Rejected,
});
// Recompute remaining hunks
if should_recompute {
// Use the source of the first remaining hunk if any, else External
let remaining_source = self
.file_states
.get(path)
.and_then(|state| state.hunks.first())
.map(|h| h.source)
.unwrap_or(HunkSource::External);
self.recompute_hunks(path, current, remaining_source);
}
Ok(())
}
/// Apply action (accept/reject) to all hunks for a file.
/// Uses batched processing to avoid stale hunk IDs during recomputation.
pub(super) async fn apply_file_action(
&mut self,
path: &Path,
action: HunkAction,
) -> Result<Vec<HunkId>, HunkActionError> {
let Some(state) = self.file_states.get(path) else {
return Ok(vec![]);
};
let hunks_to_process: Vec<Arc<Hunk>> = state.hunks.clone();
if hunks_to_process.is_empty() {
return Ok(vec![]);
}
self.apply_action_batch(&[(path.to_path_buf(), hunks_to_process)], action)
.await
}
/// Apply action (accept/reject) to all hunks.
/// Uses batched processing to avoid stale hunk IDs during recomputation.
pub(super) async fn apply_all_action(
&mut self,
action: HunkAction,
) -> Result<Vec<HunkId>, HunkActionError> {
// Collect all hunks grouped by file
let files_with_hunks: Vec<(PathBuf, Vec<Arc<Hunk>>)> = self
.file_states
.iter()
.filter(|(_, state)| !state.hunks.is_empty())
.map(|(path, state)| (path.clone(), state.hunks.clone()))
.collect();
if files_with_hunks.is_empty() {
return Ok(vec![]);
}
self.apply_action_batch(&files_with_hunks, action).await
}
/// Apply action (accept/reject) to all hunks for a specific turn.
/// Uses batched processing to avoid stale hunk IDs during recomputation.
pub(super) async fn apply_turn_action(
&mut self,
prompt_index: usize,
action: HunkAction,
) -> Result<Vec<HunkId>, HunkActionError> {
// Collect all hunks for this turn, grouped by file
let mut files_with_hunks: std::collections::HashMap<PathBuf, Vec<Arc<Hunk>>> =
std::collections::HashMap::new();
for (path, state) in &self.file_states {
let turn_hunks: Vec<Arc<Hunk>> = state
.hunks
.iter()
.filter(|h| h.source.prompt_index() == Some(prompt_index))
.cloned()
.collect();
if !turn_hunks.is_empty() {
files_with_hunks.insert(path.clone(), turn_hunks);
}
}
if files_with_hunks.is_empty() {
return Ok(vec![]);
}
let files_vec: Vec<_> = files_with_hunks.into_iter().collect();
self.apply_action_batch(&files_vec, action).await
}
/// Internal batched action processor.
/// Processes all hunks for each file atomically, then recomputes once per file.
async fn apply_action_batch(
&mut self,
files_with_hunks: &[(PathBuf, Vec<Arc<Hunk>>)],
action: HunkAction,
) -> Result<Vec<HunkId>, HunkActionError> {
let mut affected_hunk_ids = Vec::new();
for (path, hunks) in files_with_hunks {
// Process all hunks for this file in one go
for hunk in hunks {
// Update session stats
let accepted = matches!(action, HunkAction::Accept);
self.update_session_stats(&hunk.line_info, accepted);
// Remove from turn_index
self.remove_from_turn_index(&hunk.id, &hunk.source);
affected_hunk_ids.push(hunk.id.clone());
}
// Apply all patches for this file at once
match action {
HunkAction::Accept => {
self.accept_hunks_batch(path, hunks).await?;
}
HunkAction::Reject => {
self.reject_hunks_batch(path, hunks).await?;
}
}
}
Ok(affected_hunk_ids)
}
/// Accept multiple hunks for a file atomically.
/// Patches baseline incrementally, then recomputes once.
async fn accept_hunks_batch(
&mut self,
path: &Path,
hunks: &[Arc<Hunk>],
) -> Result<(), HunkActionError> {
if hunks.is_empty() {
return Ok(());
}
let (should_recompute, current) = {
let Some(state) = self.file_states.get_mut(path) else {
return Ok(());
};
// Special case: file creation (no baseline)
if matches!(state.baseline, FileContentState::Missing) {
// Accepting all hunks for a new file just sets baseline = current
state.baseline = state.current_content.clone();
state.baseline_accepted = true;
let hunk_ids: Vec<HunkId> = hunks.iter().map(|h| h.id.clone()).collect();
state.hunks.retain(|h| !hunk_ids.contains(&h.id));
// Send events
for hunk in hunks {
self.send_event(HunkEvent::HunkRemoved {
path: path.to_path_buf(),
hunk_id: hunk.id.clone(),
reason: HunkRemovalReason::Accepted,
});
}
self.send_event(HunkEvent::BaselineUpdated {
path: path.to_path_buf(),
});
return Ok(());
}
// Patch baseline for each hunk (process end-to-start to avoid shifts)
let mut sorted_hunks = hunks.to_vec();
sorted_hunks.sort_by_key(|h| std::cmp::Reverse(h.line_info.old_start));
for hunk in &sorted_hunks {
if let FileContentState::Full(baseline) = &state.baseline {
let patched = patch_lines(
baseline,
hunk.line_info.old_start,
hunk.line_info.old_count,
&hunk.new_text,
);
state.baseline = FileContentState::Full(patched);
}
}
state.baseline_accepted = true;
// Remove all accepted hunks
let hunk_ids: Vec<HunkId> = hunks.iter().map(|h| h.id.clone()).collect();
state.hunks.retain(|h| !hunk_ids.contains(&h.id));
// Pass FileContentState directly (R3/MF-4: preserve Binary/TooLarge)
let current = Some(state.current_content.clone());
(true, current)
};
// Send events
for hunk in hunks {
self.send_event(HunkEvent::HunkRemoved {
path: path.to_path_buf(),
hunk_id: hunk.id.clone(),
reason: HunkRemovalReason::Accepted,
});
}
self.send_event(HunkEvent::BaselineUpdated {
path: path.to_path_buf(),
});
// Recompute remaining hunks once
if should_recompute {
// Use the source of the first remaining hunk if any, else External
let remaining_source = self
.file_states
.get(path)
.and_then(|state| state.hunks.first())
.map(|h| h.source)
.unwrap_or(HunkSource::External);
self.recompute_hunks(path, current, remaining_source);
}
Ok(())
}
/// Reject multiple hunks for a file atomically.
/// Patches current content incrementally, then recomputes once.
async fn reject_hunks_batch(
&mut self,
path: &Path,
hunks: &[Arc<Hunk>],
) -> Result<(), HunkActionError> {
if hunks.is_empty() {
return Ok(());
}
let Some(state) = self.file_states.get(path) else {
return Ok(());
};
if matches!(state.current_content, FileContentState::Missing)
&& matches!(state.baseline, FileContentState::Full(_))
{
// File was deleted - restore it
if let FileContentState::Full(baseline) = &state.baseline {
tokio::fs::write(path, baseline).await.map_err(|e| {
HunkActionError::WriteError {
path: path.to_path_buf(),
source: e,
}
})?;
}
let Some(state) = self.file_states.get_mut(path) else {
return Ok(());
};
state.current_content = state.baseline.clone();
let hunk_ids: Vec<HunkId> = hunks.iter().map(|h| h.id.clone()).collect();
state.hunks.retain(|h| !hunk_ids.contains(&h.id));
for hunk in hunks {
self.send_event(HunkEvent::HunkRemoved {
path: path.to_path_buf(),
hunk_id: hunk.id.clone(),
reason: HunkRemovalReason::Rejected,
});
}
return Ok(());
} else if matches!(state.baseline, FileContentState::Missing)
&& matches!(state.current_content, FileContentState::Full(_))
{
// File was created - delete it
tokio::fs::remove_file(path)
.await
.map_err(|e| HunkActionError::DeleteError {
path: path.to_path_buf(),
source: e,
})?;
let Some(state) = self.file_states.get_mut(path) else {
return Ok(());
};
state.current_content = FileContentState::Missing;
let hunk_ids: Vec<HunkId> = hunks.iter().map(|h| h.id.clone()).collect();
state.hunks.retain(|h| !hunk_ids.contains(&h.id));
for hunk in hunks {
self.send_event(HunkEvent::HunkRemoved {
path: path.to_path_buf(),
hunk_id: hunk.id.clone(),
reason: HunkRemovalReason::Rejected,
});
}
return Ok(());
}
// Normal case: patch current content to revert all hunks.
// The early returns above handle (None, Some) and (Some, None).
// If both are non-Full, there's nothing to patch — bail out.
let Some(state) = self.file_states.get_mut(path) else {
return Ok(());
};
let current_content = match &state.current_content {
FileContentState::Full(s) => s.clone(),
_ => return Ok(()),
};
let mut current = current_content;
// Apply patches in reverse order (from end of file to beginning)
// to avoid line number shifts
let mut sorted_hunks = hunks.to_vec();
sorted_hunks.sort_by_key(|h| std::cmp::Reverse(h.line_info.new_start));
for hunk in &sorted_hunks {
let old_text = hunk.old_text.as_deref().unwrap_or("");
current = patch_lines(
&current,
hunk.line_info.new_start,
hunk.line_info.new_count,
old_text,
);
}
// Write patched content
tokio::fs::write(path, &current)
.await
.map_err(|e| HunkActionError::WriteError {
path: path.to_path_buf(),
source: e,
})?;
let Some(state) = self.file_states.get_mut(path) else {
return Ok(());
};
let current_state = FileContentState::Full(current);
state.current_content = current_state.clone();
let hunk_ids: Vec<HunkId> = hunks.iter().map(|h| h.id.clone()).collect();
state.hunks.retain(|h| !hunk_ids.contains(&h.id));
for hunk in hunks {
self.send_event(HunkEvent::HunkRemoved {
path: path.to_path_buf(),
hunk_id: hunk.id.clone(),
reason: HunkRemovalReason::Rejected,
});
}
// Recompute remaining hunks
let remaining_source = self
.file_states
.get(path)
.and_then(|state| state.hunks.first())
.map(|h| h.source)
.unwrap_or(HunkSource::External);
self.recompute_hunks(path, Some(current_state), remaining_source);
Ok(())
}
}
@@ -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));
}
}
@@ -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) => kigi_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(),
}
}
}
@@ -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,
});
}
}
}
}
}
}
@@ -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"
);
}
}
@@ -0,0 +1,719 @@
//! Mutation commands for the HunkTrackerActor.
//!
//! These methods handle file changes and state mutations.
//!
//! ## Path Convention
//!
//! All paths in the hunk tracker are stored as **absolute paths**. This provides:
//! - Unambiguous file identification
//! - No need for working_dir context when processing paths
//! - Simpler path handling across the codebase
//!
//! Callers should pass absolute paths to `record_agent_write`, `handle_file_change`,
//! and `handle_file_deleted`.
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use tracing::debug;
use crate::events::{HunkEvent, HunkRemovalReason};
use crate::types::{HunkSource, TrackingMode};
use super::HunkTrackerActor;
use super::file_utils::{classify_string, missing_content, read_file_bounded};
use super::state::{FileContentState, FileHunkState};
/// Log-line prefix emitted only when [`HunkTrackerActor::refresh_all_baselines`]
/// runs a real scan. Test scan counters match on it; keep it the single source
/// of truth for the string.
pub const REFRESH_SCAN_LOG_PREFIX: &str = "refresh_all_baselines: completed in";
/// Log-line prefix for the unchanged-git-state skip path of
/// [`HunkTrackerActor::refresh_all_baselines`] (no scan ran).
pub const REFRESH_SKIP_LOG_PREFIX: &str = "refresh_all_baselines: git state unchanged";
/// Strip a single trailing newline (`\r\n` or `\n`) for equality comparison.
///
/// Git-stored content typically has exactly one trailing newline appended.
/// We strip only one to avoid falsely treating files with meaningful trailing
/// whitespace as clean. Bare `\r` (classic Mac) is intentionally out of scope.
fn strip_single_trailing_newline(content: &str) -> &str {
content
.strip_suffix("\r\n")
.or_else(|| content.strip_suffix('\n'))
.unwrap_or(content)
}
impl HunkTrackerActor {
/// Record that an agent tool wrote to a file.
///
/// # Arguments
/// * `path` - Absolute path to the file
/// * `content` - New file content
/// * `prompt_index` - The prompt/turn index when this write occurred
/// * `previous_content` - Content of the file before this write (if known).
/// Used as a fallback baseline when the file doesn't exist in git HEAD
/// (e.g., in worktrees created from dirty state where uncommitted files
/// were copied but aren't tracked by git).
pub(super) async fn record_agent_write(
&mut self,
path: PathBuf,
content: String,
prompt_index: usize,
previous_content: Option<String>,
) {
let source = HunkSource::AgentEdit { prompt_index };
// Classify current content into FileContentState (single classification, cloned for file_states)
let current_state = classify_string(content.clone());
let current_state_for_hunks = current_state.clone(); // Used by recompute_hunks below
// Binary or TooLarge content: still track as an agent file (so
// `get_all_tracked_paths` reports it for worktree replication)
// but skip hunk computation — we don't diff these.
if !current_state.is_diffable() {
if !self.file_states.contains_key(&path) {
// For new files, establish baseline from git or previous_content
// Preserve previous_content when supplied (don't throw away available baseline)
let baseline = if let Some(prev) = previous_content {
classify_string(prev)
} else {
// Try git baseline, but don't block on it for large/binary writes
self.read_baseline(&path).await
};
self.file_states.insert(
path.clone(),
FileHunkState {
baseline,
current_content: current_state,
hunks: vec![],
is_agent_file: true,
baseline_accepted: false,
},
);
self.send_event(HunkEvent::FileAdded {
path,
is_agent_file: true,
});
} else if let Some(state) = self.file_states.get_mut(&path) {
state.is_agent_file = true;
state.current_content = current_state;
// Clear hunks since content is not diffable
state.hunks.clear();
}
return;
}
// Ensure file is tracked as an agent file
let is_new_file = !self.file_states.contains_key(&path);
if is_new_file {
// First time seeing this file - establish baseline
// For agent writes, path is already absolute
let baseline = match self.read_baseline(&path).await {
FileContentState::Missing => {
// File doesn't exist in git HEAD
// Use previous_content as fallback if available
previous_content
.map(classify_string)
.unwrap_or(missing_content())
}
other => other,
};
self.file_states.insert(
path.clone(),
FileHunkState {
baseline,
current_content: current_state,
hunks: vec![],
is_agent_file: true,
baseline_accepted: false,
},
);
// Emit FileAdded event
self.send_event(HunkEvent::FileAdded {
path: path.clone(),
is_agent_file: true,
});
} else {
// Mark as agent file if not already
if let Some(state) = self.file_states.get_mut(&path) {
state.is_agent_file = true;
}
}
// Recompute hunks with pre-classified content (no re-classification in recompute_hunks)
self.recompute_hunks(&path, Some(current_state_for_hunks), source);
}
/// Handle a file change notification from fs_notify.
pub(super) async fn handle_file_change(&mut self, path: PathBuf) {
self.process_file_change(path, None).await;
}
/// Handle multiple file change notifications as a batch.
///
/// Reads all needed baselines in a single call instead of per-file.
pub(super) async fn handle_file_changes_batch(&mut self, paths: Vec<PathBuf>) {
if paths.is_empty() {
return;
}
// Collect paths that need baseline reads:
// - Untracked paths (need initial baseline)
// - Tracked with baseline_accepted (need fresh baseline to detect restoration)
let baseline_paths: Vec<PathBuf> = paths
.iter()
.filter(|p| {
let tracked = self.file_states.contains_key(*p);
if self.mode == TrackingMode::AgentOnly && !tracked {
return false;
}
!tracked
|| self
.file_states
.get(*p)
.is_some_and(|s| s.baseline_accepted)
})
.cloned()
.collect();
let mut baselines = if baseline_paths.is_empty() {
HashMap::new()
} else {
self.read_baselines_batch(&baseline_paths).await
};
for path in paths {
let baseline = baselines.remove(&path);
self.process_file_change(path, baseline).await;
}
}
/// Shared implementation for processing a single file change.
///
/// When `preloaded_baseline` is `Some`, uses the provided baseline.
/// When `None`, reads the baseline from git on demand.
async fn process_file_change(
&mut self,
path: PathBuf,
mut preloaded_baseline: Option<FileContentState>,
) {
let is_tracked = self.file_states.contains_key(&path);
if self.mode == TrackingMode::AgentOnly && !is_tracked {
return;
}
let current_state = read_file_bounded(&path).await;
if !current_state.is_diffable() && !is_tracked {
let baseline = if self.mode == TrackingMode::AllDirty {
match preloaded_baseline.take() {
Some(b) => b,
None => self.read_baseline(&path).await,
}
} else {
missing_content()
};
// No git baseline + not in dirty cache, gitignored; skip.
// But allow directory paths through; they are legitimate fsnotify
// entries used to discover files inside new directories
// (inotify recursive-watch race recovery).
if self.mode == TrackingMode::AllDirty
&& matches!(baseline, FileContentState::Missing)
&& !path.is_dir()
{
let rel = path.strip_prefix(&self.working_dir).unwrap_or(&path);
if !self.git_dirty_cache.contains(rel) {
return;
}
}
let has_diffable_baseline = baseline.is_diffable();
self.file_states.insert(
path.clone(),
FileHunkState {
baseline,
current_content: current_state,
hunks: vec![],
is_agent_file: false,
baseline_accepted: false,
},
);
self.send_event(HunkEvent::FileAdded {
path: path.clone(),
is_agent_file: false,
});
// If the baseline exists in HEAD but the file is missing/non-diffable
// on disk, compute a deletion hunk (e.g., staged deletion after soft reset).
if has_diffable_baseline {
self.recompute_hunks(&path, None, HunkSource::External);
}
return;
}
if !is_tracked {
let baseline = match preloaded_baseline.take() {
Some(b) => b,
None => self.read_baseline(&path).await,
};
// No git baseline + not in dirty cache → gitignored; skip.
// But allow directory paths through (see comment above).
if self.mode == TrackingMode::AllDirty
&& matches!(baseline, FileContentState::Missing)
&& !path.is_dir()
{
let rel = path.strip_prefix(&self.working_dir).unwrap_or(&path);
if !self.git_dirty_cache.contains(rel) {
return;
}
}
self.file_states.insert(
path.clone(),
FileHunkState {
baseline,
current_content: missing_content(), // Will be set by recompute_hunks
hunks: vec![],
is_agent_file: false,
baseline_accepted: false,
},
);
self.send_event(HunkEvent::FileAdded {
path: path.clone(),
is_agent_file: false,
});
} else if self
.file_states
.get(&path)
.is_some_and(|s| s.baseline_accepted)
{
// Existing file with accepted baseline — check if content was
// restored to git HEAD (e.g., `git restore .`). If so, reset
// baseline so the file appears clean.
let git_head_state = match preloaded_baseline.take() {
Some(b) => b,
None => self.read_baseline(&path).await,
};
let content_matches_head = match (&current_state, &git_head_state) {
(FileContentState::Full(current), FileContentState::Full(head)) => {
strip_single_trailing_newline(current) == strip_single_trailing_newline(head)
}
(FileContentState::Missing, FileContentState::Missing) => true,
(FileContentState::Binary { .. }, FileContentState::Binary { .. }) => true,
(FileContentState::TooLarge { .. }, FileContentState::TooLarge { .. }) => true,
(FileContentState::LfsPointer { .. }, FileContentState::LfsPointer { .. }) => true,
(FileContentState::Symlink, FileContentState::Symlink) => true,
// Symlink on disk vs Full(target) in HEAD (or vice versa):
// git stores symlinks as plain text blobs, so the types
// differ even when the file is unchanged. Consult dirty cache.
(FileContentState::Symlink, FileContentState::Full(_))
| (FileContentState::Full(_), FileContentState::Symlink) => {
let rel = path.strip_prefix(&self.working_dir).unwrap_or(&path);
!self.git_dirty_cache.contains(rel)
}
_ => false,
};
if content_matches_head {
if let Some(state) = self.file_states.get_mut(&path) {
state.baseline = git_head_state.clone();
state.current_content = git_head_state;
state.baseline_accepted = false;
state.hunks.clear();
}
return;
}
}
let source = if self.file_states.get(&path).is_some_and(|s| s.is_agent_file) {
HunkSource::ExternalEditOnAgentFile
} else {
HunkSource::External
};
self.recompute_hunks(&path, Some(current_state), source);
}
/// Handle a file deletion notification from fs_notify.
///
/// Some git operations (e.g., `git restore .`) emit Remove events for
/// files that are immediately re-created with different content. To
/// avoid treating these as true deletions, we check whether the file
/// still exists on disk before marking it as deleted.
///
/// # Arguments
/// * `path` - Absolute path to the deleted file
pub(super) async fn handle_file_deleted(&mut self, path: PathBuf) {
if !self.file_states.contains_key(&path) {
// File not tracked by hunk tracker.
// In AgentOnly mode, skip untracked files (same as handle_file_change).
if self.mode == TrackingMode::AgentOnly {
return;
}
// If the file still exists on disk, this is not a real deletion.
if path.exists() {
return;
}
// Check if the file exists in git HEAD. If so, this is a meaningful
// deletion that should produce a deletion hunk (e.g., user ran
// `rm foo.txt` on a committed file).
let baseline = self.read_baseline(&path).await;
if matches!(baseline, FileContentState::Missing) {
return; // Not in HEAD either, nothing to track
}
// Seed file_states with baseline and Missing current content.
self.file_states.insert(
path.clone(),
FileHunkState {
baseline,
current_content: missing_content(),
hunks: vec![],
is_agent_file: false,
baseline_accepted: false,
},
);
self.send_event(HunkEvent::FileAdded {
path: path.clone(),
is_agent_file: false,
});
// Recompute hunks: (Full baseline, Missing current) -> file_deleted hunk
self.recompute_hunks(&path, None, HunkSource::External);
return;
}
// File IS tracked — existing logic below.
// If the file still exists on disk, this was a replace (e.g.,
// `git restore`), not a true deletion. Delegate to
// handle_file_change which handles baseline refresh correctly.
if path.exists() {
self.handle_file_change(path).await;
return;
}
let source = if self
.file_states
.get(&path)
.map(|s| s.is_agent_file)
.unwrap_or(false)
{
// User deleted a file the agent has touched
HunkSource::ExternalEditOnAgentFile
} else {
HunkSource::External
};
// Set current_content to Missing (deleted) and recompute hunks.
// Don't remove from file_states — the baseline still exists in HEAD.
self.recompute_hunks(&path, None, source);
}
/// Reset baseline for a file (typically after commit).
pub(super) fn reset_baseline(&mut self, path: &Path) {
if let Some(state) = self.file_states.get_mut(path) {
// Update baseline to current content
state.baseline = state.current_content.clone();
state.baseline_accepted = false;
// Clear hunks since baseline == current
let old_hunks = std::mem::take(&mut state.hunks);
// Remove from turn_index and emit removed events for all hunks
for hunk in old_hunks {
if let Some(prompt_index) = hunk.source.prompt_index()
&& let Some(set) = self.turn_index.get_mut(&prompt_index)
{
set.remove(&hunk.id);
}
self.send_event(HunkEvent::HunkRemoved {
path: path.to_path_buf(),
hunk_id: hunk.id.clone(),
reason: HunkRemovalReason::Superseded,
});
}
self.send_event(HunkEvent::BaselineUpdated {
path: path.to_path_buf(),
});
}
}
/// Refresh all baselines from the current git HEAD and re-read current
/// content from disk for every tracked file.
///
/// This is called after a git HEAD/index change to reconcile stale
/// state. For each tracked file:
/// - Re-read baseline from the new HEAD
/// - Re-read current content from disk
/// - Recompute hunks
/// - Drop files that are now clean (baseline == current, not agent files)
pub(super) async fn refresh_all_baselines(&mut self) {
self.refresh_all_baselines_except(&HashSet::new()).await;
}
/// Same as `refresh_all_baselines` but skips paths in `skip`. Returns
/// immediately when AgentOnly has nothing tracked (no per-file work to do).
pub(super) async fn refresh_all_baselines_except(&mut self, skip: &HashSet<PathBuf>) {
// AgentOnly with nothing tracked has no work: it never auto-discovers,
// and the dirty/staged caches are read only for tracked files. Skipping
// avoids a full-worktree gix scan per git change. AllDirty must still
// scan — that is how it discovers newly-dirty files.
if self.mode == TrackingMode::AgentOnly && self.file_states.is_empty() {
return;
}
let start = std::time::Instant::now();
if let Some(repo_sync_state) = self.read_repo_sync_state().await {
if repo_sync_state == self.repo_sync_state {
debug!("{REFRESH_SKIP_LOG_PREFIX}, skipping refresh");
return;
}
self.repo_sync_state = repo_sync_state;
}
// Refresh git dirty/staged caches BEFORE the main loop so the
// is_clean check can consult them for non-diffable files (LFS,
// binary, tooLarge). In AllDirty mode this also picks up newly
// dirty files on the new branch, so it must scan the full worktree.
// AgentOnly never auto-discovers and only consults the caches for
// tracked paths, so the scan is scoped to them. Paths that can't be
// made working-dir-relative are dropped: the caches are keyed
// working-dir-relative, so such paths could never match a cache
// entry anyway.
let scope: Option<Vec<PathBuf>> = match self.mode {
TrackingMode::AllDirty => None,
TrackingMode::AgentOnly => Some(
self.file_states
.keys()
.filter_map(|p| p.strip_prefix(&self.working_dir).ok())
.map(Path::to_path_buf)
.collect(),
),
};
match scope {
// Every tracked path fell outside working_dir: nothing in scope
// can ever hit the caches, and an empty pathspec list would mean
// a FULL worktree scan in gix — the inversion of the intent — so
// skip the scan. Clear the caches rather than keep them: their
// entries predate the HEAD/index move that brought us here, and
// the repo_sync_state committed above would short-circuit every
// later refresh into serving those stale entries (get_staged_files,
// staged flags). Consistent-empty matches the scope: the caches
// describe nothing we track.
Some(rels) if rels.is_empty() => {
self.git_dirty_cache.clear();
self.git_staged_cache.clear();
}
scope => self.refresh_git_dirty_cache(scope).await,
}
let paths: Vec<PathBuf> = self
.file_states
.keys()
.filter(|p| !skip.contains(*p))
.cloned()
.collect();
// Batch all git baseline reads into a single spawn_blocking call
// to avoid per-file repo open / HEAD resolve overhead.
let baselines = if paths.is_empty() {
HashMap::new()
} else {
self.read_baselines_batch(&paths).await
};
const PARALLEL_READ_LIMIT: usize = 64;
let mut current_contents: HashMap<PathBuf, FileContentState> = {
let mut results = HashMap::with_capacity(paths.len());
for chunk in paths.chunks(PARALLEL_READ_LIMIT) {
let mut join_set = tokio::task::JoinSet::new();
for path in chunk {
let p = path.clone();
join_set.spawn(async move {
let content = read_file_bounded(&p).await;
(p, content)
});
}
while let Some(result) = join_set.join_next().await {
match result {
Ok((path, content)) => {
results.insert(path, content);
}
Err(e) => debug!("parallel read task failed: {e}"),
}
}
}
results
};
for path in paths {
let new_baseline = baselines.get(&path).cloned().unwrap_or(missing_content());
let new_current = current_contents.remove(&path).unwrap_or(missing_content());
let Some(state) = self.file_states.get_mut(&path) else {
continue;
};
let is_agent_file = state.is_agent_file;
// Update baseline and current content (move ownership, no clones)
state.baseline = new_baseline;
state.current_content = new_current;
state.baseline_accepted = false;
// Check if file is now clean (baseline == current).
// For Full states, compare text (ignoring trailing newline).
// For non-diffable states (Binary/TooLarge/LFS): consult the git
// dirty cache (refreshed above) — if git says the file is clean,
// drop it from tracking to avoid phantom entries.
// For Missing state: clean only if file doesn't exist.
let is_clean = match (&state.baseline, &state.current_content) {
(FileContentState::Full(b), FileContentState::Full(c)) => {
strip_single_trailing_newline(b) == strip_single_trailing_newline(c)
}
(FileContentState::Binary { .. }, FileContentState::Binary { .. })
| (FileContentState::TooLarge { .. }, FileContentState::TooLarge { .. })
| (FileContentState::LfsPointer { .. }, FileContentState::LfsPointer { .. })
| (FileContentState::Symlink, FileContentState::Symlink) => {
// Non-diffable states with matching types: consult git dirty cache.
// The dirty cache was refreshed above so it reflects current HEAD.
let rel = path.strip_prefix(&self.working_dir).unwrap_or(&path);
!self.git_dirty_cache.contains(rel)
}
// LFS pointer baseline with different current content type (e.g. the
// normal smudge case: baseline=pointer, current=binary). These are
// NOT diffable but may be clean per git status. Consult dirty cache.
(FileContentState::LfsPointer { .. }, _)
| (_, FileContentState::LfsPointer { .. }) => {
let rel = path.strip_prefix(&self.working_dir).unwrap_or(&path);
!self.git_dirty_cache.contains(rel)
}
// Symlink on disk vs Full(target) in HEAD (or vice versa):
// git stores symlinks as plain text blobs, so the types
// differ even when the file is unchanged. Consult dirty cache.
(FileContentState::Symlink, FileContentState::Full(_))
| (FileContentState::Full(_), FileContentState::Symlink) => {
let rel = path.strip_prefix(&self.working_dir).unwrap_or(&path);
!self.git_dirty_cache.contains(rel)
}
(FileContentState::Missing, FileContentState::Missing) => !path.exists(),
// Not in git HEAD + not in dirty cache → gitignored; clean.
(FileContentState::Missing, _) => {
let rel = path.strip_prefix(&self.working_dir).unwrap_or(&path);
!self.git_dirty_cache.contains(rel)
}
_ => false,
};
if is_clean && !is_agent_file {
// File is clean and not an agent file — stop tracking it
let old_state = self.file_states.remove(&path).unwrap();
// Clean up turn_index and emit removal events
for hunk in &old_state.hunks {
if let Some(prompt_index) = hunk.source.prompt_index()
&& let Some(set) = self.turn_index.get_mut(&prompt_index)
{
set.remove(&hunk.id);
}
self.send_event(HunkEvent::HunkRemoved {
path: path.clone(),
hunk_id: hunk.id.clone(),
reason: HunkRemovalReason::Superseded,
});
}
self.send_event(HunkEvent::FileRemoved { path: path.clone() });
debug!("refresh_all_baselines: dropped clean file {:?}", path);
} else if is_clean && is_agent_file {
// Agent file is clean — clear hunks but keep tracking
let old_hunks = std::mem::take(&mut state.hunks);
for hunk in &old_hunks {
if let Some(prompt_index) = hunk.source.prompt_index()
&& let Some(set) = self.turn_index.get_mut(&prompt_index)
{
set.remove(&hunk.id);
}
self.send_event(HunkEvent::HunkRemoved {
path: path.clone(),
hunk_id: hunk.id.clone(),
reason: HunkRemovalReason::Superseded,
});
}
self.send_event(HunkEvent::BaselineUpdated { path: path.clone() });
debug!("refresh_all_baselines: agent file {:?} is now clean", path);
} else {
// File still has diffs — recompute hunks
// Determine source: preserve agent attribution if it's an agent file
let source = if is_agent_file {
HunkSource::ExternalEditOnAgentFile
} else {
HunkSource::External
};
// Pass current_content directly (already FileContentState, no re-classification)
let current = self
.file_states
.get(&path)
.map(|s| s.current_content.clone());
self.recompute_hunks(&path, current, source);
self.send_event(HunkEvent::BaselineUpdated { path: path.clone() });
debug!("refresh_all_baselines: recomputed hunks for {:?}", path);
}
}
debug!("{REFRESH_SCAN_LOG_PREFIX} {:?}", start.elapsed());
}
/// Set tracking mode.
pub(super) async fn set_mode(&mut self, mode: TrackingMode) {
let old_mode = self.mode;
self.mode = mode;
if old_mode == TrackingMode::AgentOnly && mode == TrackingMode::AllDirty {
// Switching to AllDirty - refresh git cache and track dirty files
self.refresh_git_dirty_cache(None).await;
} else if old_mode == TrackingMode::AllDirty && mode == TrackingMode::AgentOnly {
// Switching to AgentOnly - remove non-agent files
let non_agent_paths: Vec<PathBuf> = self
.file_states
.iter()
.filter(|(_, state)| !state.is_agent_file)
.map(|(path, _)| path.clone())
.collect();
for path in non_agent_paths {
if let Some(state) = self.file_states.remove(&path) {
// Remove from turn_index and emit removed events for all hunks
for hunk in state.hunks {
if let Some(prompt_index) = hunk.source.prompt_index()
&& let Some(set) = self.turn_index.get_mut(&prompt_index)
{
set.remove(&hunk.id);
}
self.send_event(HunkEvent::HunkRemoved {
path: path.clone(),
hunk_id: hunk.id.clone(),
reason: HunkRemovalReason::Superseded,
});
}
self.send_event(HunkEvent::FileRemoved { path });
}
}
}
}
}
@@ -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,
}
}
}
@@ -0,0 +1,145 @@
//! Internal state types for the HunkTrackerActor.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::SystemTime;
use crate::types::Hunk;
/// Maximum size (in bytes) of file text content to retain in memory.
/// Files larger than this are stored as TooLarge.
/// This is aligned with the diff limit to ensure consistent behavior.
pub(crate) const MAX_TRACKED_TEXT_BYTES: usize = 1024 * 1024; // 1 MB
/// Explicit state of file content storage.
/// Replaces Option<String> for baseline/current_content to avoid unbounded memory.
/// Files exceeding MAX_TRACKED_TEXT_BYTES or containing binary content are
/// stored with metadata only (no text retained).
///
/// `Serialize`/`Deserialize` back the disk-persisted rewind checkpoint store; the
/// default externally-tagged representation round-trips every variant.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum FileContentState {
/// File does not exist at this reference point.
Missing,
/// File content is not valid UTF-8 (binary).
/// byte_len is optional because we may not know the size.
Binary { byte_len: Option<usize> },
/// File exceeds MAX_TRACKED_TEXT_BYTES; content not retained.
TooLarge { byte_len: usize },
/// File content is a Git LFS pointer (small text stub that references
/// the real object in the LFS store). Not diffable because the working
/// copy holds the smudged (real) content while the git blob holds only
/// the pointer — comparing them produces a phantom diff.
LfsPointer { byte_len: usize },
/// Path is a symbolic link. Not diffable because the hunk tracker
/// follows symlinks when reading content, producing a phantom diff
/// against the git-stored symlink target string.
Symlink,
/// Full text content retained (within limit).
Full(String),
}
impl FileContentState {
/// Returns the text content if Full, None otherwise.
pub(crate) fn as_str(&self) -> Option<&str> {
match self {
FileContentState::Full(s) => Some(s),
_ => None,
}
}
/// Returns true if content can be used for diffing.
pub(crate) fn is_diffable(&self) -> bool {
matches!(self, FileContentState::Full(_))
}
/// Returns byte length if known.
#[allow(dead_code)]
pub(crate) fn byte_len(&self) -> Option<usize> {
match self {
FileContentState::Missing => Some(0),
FileContentState::Binary { byte_len } => *byte_len,
FileContentState::TooLarge { byte_len } => Some(*byte_len),
FileContentState::LfsPointer { byte_len } => Some(*byte_len),
FileContentState::Symlink => None,
FileContentState::Full(s) => Some(s.len()),
}
}
}
/// Cached state for git repository discovery.
/// Avoids repeated filesystem walks to find the repo root.
///
/// When a repo is discovered, we cache a `gix::ThreadSafeRepository` handle
/// so that subsequent operations can call `.to_thread_local()` (a cheap
/// `Arc` clone + thread-local wrapper) instead of re-opening the repo via
/// `gix::open()` on every `spawn_blocking` call.
#[derive(Clone)]
pub(crate) enum GitRepoState {
/// Haven't attempted discovery yet
Unknown,
/// Discovered that working_dir is not inside a git repository
NotARepo,
/// Successfully discovered the git repository
Discovered {
/// Cached thread-safe repo handle. `.to_thread_local()` is cheap.
repo: Arc<gix::ThreadSafeRepository>,
/// Prefix to convert working_dir-relative paths to repo-relative paths
/// (working_dir relative to repo_root, e.g., "subdir/nested" if working_dir is repo_root/subdir/nested)
prefix: PathBuf,
},
}
impl std::fmt::Debug for GitRepoState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unknown => write!(f, "Unknown"),
Self::NotARepo => write!(f, "NotARepo"),
Self::Discovered { prefix, .. } => f
.debug_struct("Discovered")
.field("prefix", prefix)
.finish_non_exhaustive(),
}
}
}
/// Git state used to decide when to refresh baselines.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct RepoSyncState {
/// Last observed HEAD commit id.
pub head_oid: Option<String>,
/// Last observed .git/index modification time.
pub index_mtime: Option<SystemTime>,
}
/// Internal state for a single tracked file.
pub(crate) struct FileHunkState {
/// Content at git HEAD or session start (baseline for diffing).
/// FileContentState::Missing means file didn't exist at baseline (new file).
/// FileContentState::TooLarge/Binary means content not retained (metadata only).
pub baseline: FileContentState,
/// Last known content (from agent write or disk read).
/// Used to detect external edits by comparing to disk content.
/// FileContentState::Missing means file doesn't exist currently.
pub current_content: FileContentState,
/// Active hunks for this file (computed from baseline vs current).
/// Wrapped in Arc for cheap cloning when returning from queries.
/// Hunks only exist for Full text states on both sides.
pub hunks: Vec<Arc<Hunk>>,
/// True if agent has written to this file.
/// Determines if file stays tracked in AgentOnly mode.
pub is_agent_file: bool,
/// True if the baseline has been patched by an accept action (diverged
/// from git HEAD). Used by `handle_file_change` to decide whether to
/// re-read the baseline from git HEAD: if this flag is set and the new
/// file content matches git HEAD, the baseline is refreshed and the flag
/// cleared. This handles `git restore .` without undoing accepts when
/// the user makes a normal (non-restore) edit.
pub baseline_accepted: bool,
}
File diff suppressed because it is too large. Load diff