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:
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(
|
||||
¤t,
|
||||
hunk.line_info.new_start,
|
||||
hunk.line_info.new_count,
|
||||
old_text,
|
||||
);
|
||||
}
|
||||
|
||||
// Write patched content
|
||||
tokio::fs::write(path, ¤t)
|
||||
.await
|
||||
.map_err(|e| HunkActionError::WriteError {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let Some(state) = self.file_states.get_mut(path) else {
|
||||
return Ok(());
|
||||
};
|
||||
let current_state = FileContentState::Full(current);
|
||||
state.current_content = current_state.clone();
|
||||
let hunk_ids: Vec<HunkId> = hunks.iter().map(|h| h.id.clone()).collect();
|
||||
state.hunks.retain(|h| !hunk_ids.contains(&h.id));
|
||||
|
||||
for hunk in hunks {
|
||||
self.send_event(HunkEvent::HunkRemoved {
|
||||
path: path.to_path_buf(),
|
||||
hunk_id: hunk.id.clone(),
|
||||
reason: HunkRemovalReason::Rejected,
|
||||
});
|
||||
}
|
||||
|
||||
// Recompute remaining hunks
|
||||
let remaining_source = self
|
||||
.file_states
|
||||
.get(path)
|
||||
.and_then(|state| state.hunks.first())
|
||||
.map(|h| h.source)
|
||||
.unwrap_or(HunkSource::External);
|
||||
|
||||
self.recompute_hunks(path, Some(current_state), remaining_source);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -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 (¤t_state, &git_head_state) {
|
||||
(FileContentState::Full(current), FileContentState::Full(head)) => {
|
||||
strip_single_trailing_newline(current) == strip_single_trailing_newline(head)
|
||||
}
|
||||
(FileContentState::Missing, FileContentState::Missing) => true,
|
||||
(FileContentState::Binary { .. }, FileContentState::Binary { .. }) => true,
|
||||
(FileContentState::TooLarge { .. }, FileContentState::TooLarge { .. }) => true,
|
||||
(FileContentState::LfsPointer { .. }, FileContentState::LfsPointer { .. }) => true,
|
||||
(FileContentState::Symlink, FileContentState::Symlink) => true,
|
||||
// Symlink on disk vs Full(target) in HEAD (or vice versa):
|
||||
// git stores symlinks as plain text blobs, so the types
|
||||
// differ even when the file is unchanged. Consult dirty cache.
|
||||
(FileContentState::Symlink, FileContentState::Full(_))
|
||||
| (FileContentState::Full(_), FileContentState::Symlink) => {
|
||||
let rel = path.strip_prefix(&self.working_dir).unwrap_or(&path);
|
||||
!self.git_dirty_cache.contains(rel)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if content_matches_head {
|
||||
if let Some(state) = self.file_states.get_mut(&path) {
|
||||
state.baseline = git_head_state.clone();
|
||||
state.current_content = git_head_state;
|
||||
state.baseline_accepted = false;
|
||||
state.hunks.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let source = if self.file_states.get(&path).is_some_and(|s| s.is_agent_file) {
|
||||
HunkSource::ExternalEditOnAgentFile
|
||||
} else {
|
||||
HunkSource::External
|
||||
};
|
||||
|
||||
self.recompute_hunks(&path, Some(current_state), source);
|
||||
}
|
||||
|
||||
/// Handle a file deletion notification from fs_notify.
|
||||
///
|
||||
/// Some git operations (e.g., `git restore .`) emit Remove events for
|
||||
/// files that are immediately re-created with different content. To
|
||||
/// avoid treating these as true deletions, we check whether the file
|
||||
/// still exists on disk before marking it as deleted.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `path` - Absolute path to the deleted file
|
||||
pub(super) async fn handle_file_deleted(&mut self, path: PathBuf) {
|
||||
if !self.file_states.contains_key(&path) {
|
||||
// File not tracked by hunk tracker.
|
||||
// In AgentOnly mode, skip untracked files (same as handle_file_change).
|
||||
if self.mode == TrackingMode::AgentOnly {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the file still exists on disk, this is not a real deletion.
|
||||
if path.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the file exists in git HEAD. If so, this is a meaningful
|
||||
// deletion that should produce a deletion hunk (e.g., user ran
|
||||
// `rm foo.txt` on a committed file).
|
||||
let baseline = self.read_baseline(&path).await;
|
||||
if matches!(baseline, FileContentState::Missing) {
|
||||
return; // Not in HEAD either, nothing to track
|
||||
}
|
||||
|
||||
// Seed file_states with baseline and Missing current content.
|
||||
self.file_states.insert(
|
||||
path.clone(),
|
||||
FileHunkState {
|
||||
baseline,
|
||||
current_content: missing_content(),
|
||||
hunks: vec![],
|
||||
is_agent_file: false,
|
||||
baseline_accepted: false,
|
||||
},
|
||||
);
|
||||
self.send_event(HunkEvent::FileAdded {
|
||||
path: path.clone(),
|
||||
is_agent_file: false,
|
||||
});
|
||||
|
||||
// Recompute hunks: (Full baseline, Missing current) -> file_deleted hunk
|
||||
self.recompute_hunks(&path, None, HunkSource::External);
|
||||
return;
|
||||
}
|
||||
|
||||
// File IS tracked — existing logic below.
|
||||
|
||||
// If the file still exists on disk, this was a replace (e.g.,
|
||||
// `git restore`), not a true deletion. Delegate to
|
||||
// handle_file_change which handles baseline refresh correctly.
|
||||
if path.exists() {
|
||||
self.handle_file_change(path).await;
|
||||
return;
|
||||
}
|
||||
|
||||
let source = if self
|
||||
.file_states
|
||||
.get(&path)
|
||||
.map(|s| s.is_agent_file)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
// User deleted a file the agent has touched
|
||||
HunkSource::ExternalEditOnAgentFile
|
||||
} else {
|
||||
HunkSource::External
|
||||
};
|
||||
|
||||
// Set current_content to Missing (deleted) and recompute hunks.
|
||||
// Don't remove from file_states — the baseline still exists in HEAD.
|
||||
self.recompute_hunks(&path, None, source);
|
||||
}
|
||||
|
||||
/// Reset baseline for a file (typically after commit).
|
||||
pub(super) fn reset_baseline(&mut self, path: &Path) {
|
||||
if let Some(state) = self.file_states.get_mut(path) {
|
||||
// Update baseline to current content
|
||||
state.baseline = state.current_content.clone();
|
||||
state.baseline_accepted = false;
|
||||
// Clear hunks since baseline == current
|
||||
let old_hunks = std::mem::take(&mut state.hunks);
|
||||
|
||||
// Remove from turn_index and emit removed events for all hunks
|
||||
for hunk in old_hunks {
|
||||
if let Some(prompt_index) = hunk.source.prompt_index()
|
||||
&& let Some(set) = self.turn_index.get_mut(&prompt_index)
|
||||
{
|
||||
set.remove(&hunk.id);
|
||||
}
|
||||
self.send_event(HunkEvent::HunkRemoved {
|
||||
path: path.to_path_buf(),
|
||||
hunk_id: hunk.id.clone(),
|
||||
reason: HunkRemovalReason::Superseded,
|
||||
});
|
||||
}
|
||||
|
||||
self.send_event(HunkEvent::BaselineUpdated {
|
||||
path: path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh all baselines from the current git HEAD and re-read current
|
||||
/// content from disk for every tracked file.
|
||||
///
|
||||
/// This is called after a git HEAD/index change to reconcile stale
|
||||
/// state. For each tracked file:
|
||||
/// - Re-read baseline from the new HEAD
|
||||
/// - Re-read current content from disk
|
||||
/// - Recompute hunks
|
||||
/// - Drop files that are now clean (baseline == current, not agent files)
|
||||
pub(super) async fn refresh_all_baselines(&mut self) {
|
||||
self.refresh_all_baselines_except(&HashSet::new()).await;
|
||||
}
|
||||
|
||||
/// Same as `refresh_all_baselines` but skips paths in `skip`. Returns
|
||||
/// immediately when AgentOnly has nothing tracked (no per-file work to do).
|
||||
pub(super) async fn refresh_all_baselines_except(&mut self, skip: &HashSet<PathBuf>) {
|
||||
// AgentOnly with nothing tracked has no work: it never auto-discovers,
|
||||
// and the dirty/staged caches are read only for tracked files. Skipping
|
||||
// avoids a full-worktree gix scan per git change. AllDirty must still
|
||||
// scan — that is how it discovers newly-dirty files.
|
||||
if self.mode == TrackingMode::AgentOnly && self.file_states.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
if let Some(repo_sync_state) = self.read_repo_sync_state().await {
|
||||
if repo_sync_state == self.repo_sync_state {
|
||||
debug!("{REFRESH_SKIP_LOG_PREFIX}, skipping refresh");
|
||||
return;
|
||||
}
|
||||
self.repo_sync_state = repo_sync_state;
|
||||
}
|
||||
|
||||
// Refresh git dirty/staged caches BEFORE the main loop so the
|
||||
// is_clean check can consult them for non-diffable files (LFS,
|
||||
// binary, tooLarge). In AllDirty mode this also picks up newly
|
||||
// dirty files on the new branch, so it must scan the full worktree.
|
||||
// AgentOnly never auto-discovers and only consults the caches for
|
||||
// tracked paths, so the scan is scoped to them. Paths that can't be
|
||||
// made working-dir-relative are dropped: the caches are keyed
|
||||
// working-dir-relative, so such paths could never match a cache
|
||||
// entry anyway.
|
||||
let scope: Option<Vec<PathBuf>> = match self.mode {
|
||||
TrackingMode::AllDirty => None,
|
||||
TrackingMode::AgentOnly => Some(
|
||||
self.file_states
|
||||
.keys()
|
||||
.filter_map(|p| p.strip_prefix(&self.working_dir).ok())
|
||||
.map(Path::to_path_buf)
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
match scope {
|
||||
// Every tracked path fell outside working_dir: nothing in scope
|
||||
// can ever hit the caches, and an empty pathspec list would mean
|
||||
// a FULL worktree scan in gix — the inversion of the intent — so
|
||||
// skip the scan. Clear the caches rather than keep them: their
|
||||
// entries predate the HEAD/index move that brought us here, and
|
||||
// the repo_sync_state committed above would short-circuit every
|
||||
// later refresh into serving those stale entries (get_staged_files,
|
||||
// staged flags). Consistent-empty matches the scope: the caches
|
||||
// describe nothing we track.
|
||||
Some(rels) if rels.is_empty() => {
|
||||
self.git_dirty_cache.clear();
|
||||
self.git_staged_cache.clear();
|
||||
}
|
||||
scope => self.refresh_git_dirty_cache(scope).await,
|
||||
}
|
||||
|
||||
let paths: Vec<PathBuf> = self
|
||||
.file_states
|
||||
.keys()
|
||||
.filter(|p| !skip.contains(*p))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// Batch all git baseline reads into a single spawn_blocking call
|
||||
// to avoid per-file repo open / HEAD resolve overhead.
|
||||
let baselines = if paths.is_empty() {
|
||||
HashMap::new()
|
||||
} else {
|
||||
self.read_baselines_batch(&paths).await
|
||||
};
|
||||
|
||||
const PARALLEL_READ_LIMIT: usize = 64;
|
||||
let mut current_contents: HashMap<PathBuf, FileContentState> = {
|
||||
let mut results = HashMap::with_capacity(paths.len());
|
||||
for chunk in paths.chunks(PARALLEL_READ_LIMIT) {
|
||||
let mut join_set = tokio::task::JoinSet::new();
|
||||
for path in chunk {
|
||||
let p = path.clone();
|
||||
join_set.spawn(async move {
|
||||
let content = read_file_bounded(&p).await;
|
||||
(p, content)
|
||||
});
|
||||
}
|
||||
while let Some(result) = join_set.join_next().await {
|
||||
match result {
|
||||
Ok((path, content)) => {
|
||||
results.insert(path, content);
|
||||
}
|
||||
Err(e) => debug!("parallel read task failed: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
results
|
||||
};
|
||||
|
||||
for path in paths {
|
||||
let new_baseline = baselines.get(&path).cloned().unwrap_or(missing_content());
|
||||
|
||||
let new_current = current_contents.remove(&path).unwrap_or(missing_content());
|
||||
|
||||
let Some(state) = self.file_states.get_mut(&path) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let is_agent_file = state.is_agent_file;
|
||||
|
||||
// Update baseline and current content (move ownership, no clones)
|
||||
state.baseline = new_baseline;
|
||||
state.current_content = new_current;
|
||||
state.baseline_accepted = false;
|
||||
|
||||
// Check if file is now clean (baseline == current).
|
||||
// For Full states, compare text (ignoring trailing newline).
|
||||
// For non-diffable states (Binary/TooLarge/LFS): consult the git
|
||||
// dirty cache (refreshed above) — if git says the file is clean,
|
||||
// drop it from tracking to avoid phantom entries.
|
||||
// For Missing state: clean only if file doesn't exist.
|
||||
let is_clean = match (&state.baseline, &state.current_content) {
|
||||
(FileContentState::Full(b), FileContentState::Full(c)) => {
|
||||
strip_single_trailing_newline(b) == strip_single_trailing_newline(c)
|
||||
}
|
||||
(FileContentState::Binary { .. }, FileContentState::Binary { .. })
|
||||
| (FileContentState::TooLarge { .. }, FileContentState::TooLarge { .. })
|
||||
| (FileContentState::LfsPointer { .. }, FileContentState::LfsPointer { .. })
|
||||
| (FileContentState::Symlink, FileContentState::Symlink) => {
|
||||
// Non-diffable states with matching types: consult git dirty cache.
|
||||
// The dirty cache was refreshed above so it reflects current HEAD.
|
||||
let rel = path.strip_prefix(&self.working_dir).unwrap_or(&path);
|
||||
!self.git_dirty_cache.contains(rel)
|
||||
}
|
||||
// LFS pointer baseline with different current content type (e.g. the
|
||||
// normal smudge case: baseline=pointer, current=binary). These are
|
||||
// NOT diffable but may be clean per git status. Consult dirty cache.
|
||||
(FileContentState::LfsPointer { .. }, _)
|
||||
| (_, FileContentState::LfsPointer { .. }) => {
|
||||
let rel = path.strip_prefix(&self.working_dir).unwrap_or(&path);
|
||||
!self.git_dirty_cache.contains(rel)
|
||||
}
|
||||
// Symlink on disk vs Full(target) in HEAD (or vice versa):
|
||||
// git stores symlinks as plain text blobs, so the types
|
||||
// differ even when the file is unchanged. Consult dirty cache.
|
||||
(FileContentState::Symlink, FileContentState::Full(_))
|
||||
| (FileContentState::Full(_), FileContentState::Symlink) => {
|
||||
let rel = path.strip_prefix(&self.working_dir).unwrap_or(&path);
|
||||
!self.git_dirty_cache.contains(rel)
|
||||
}
|
||||
(FileContentState::Missing, FileContentState::Missing) => !path.exists(),
|
||||
// Not in git HEAD + not in dirty cache → gitignored; clean.
|
||||
(FileContentState::Missing, _) => {
|
||||
let rel = path.strip_prefix(&self.working_dir).unwrap_or(&path);
|
||||
!self.git_dirty_cache.contains(rel)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if is_clean && !is_agent_file {
|
||||
// File is clean and not an agent file — stop tracking it
|
||||
let old_state = self.file_states.remove(&path).unwrap();
|
||||
|
||||
// Clean up turn_index and emit removal events
|
||||
for hunk in &old_state.hunks {
|
||||
if let Some(prompt_index) = hunk.source.prompt_index()
|
||||
&& let Some(set) = self.turn_index.get_mut(&prompt_index)
|
||||
{
|
||||
set.remove(&hunk.id);
|
||||
}
|
||||
self.send_event(HunkEvent::HunkRemoved {
|
||||
path: path.clone(),
|
||||
hunk_id: hunk.id.clone(),
|
||||
reason: HunkRemovalReason::Superseded,
|
||||
});
|
||||
}
|
||||
self.send_event(HunkEvent::FileRemoved { path: path.clone() });
|
||||
|
||||
debug!("refresh_all_baselines: dropped clean file {:?}", path);
|
||||
} else if is_clean && is_agent_file {
|
||||
// Agent file is clean — clear hunks but keep tracking
|
||||
let old_hunks = std::mem::take(&mut state.hunks);
|
||||
for hunk in &old_hunks {
|
||||
if let Some(prompt_index) = hunk.source.prompt_index()
|
||||
&& let Some(set) = self.turn_index.get_mut(&prompt_index)
|
||||
{
|
||||
set.remove(&hunk.id);
|
||||
}
|
||||
self.send_event(HunkEvent::HunkRemoved {
|
||||
path: path.clone(),
|
||||
hunk_id: hunk.id.clone(),
|
||||
reason: HunkRemovalReason::Superseded,
|
||||
});
|
||||
}
|
||||
self.send_event(HunkEvent::BaselineUpdated { path: path.clone() });
|
||||
|
||||
debug!("refresh_all_baselines: agent file {:?} is now clean", path);
|
||||
} else {
|
||||
// File still has diffs — recompute hunks
|
||||
// Determine source: preserve agent attribution if it's an agent file
|
||||
let source = if is_agent_file {
|
||||
HunkSource::ExternalEditOnAgentFile
|
||||
} else {
|
||||
HunkSource::External
|
||||
};
|
||||
|
||||
// Pass current_content directly (already FileContentState, no re-classification)
|
||||
let current = self
|
||||
.file_states
|
||||
.get(&path)
|
||||
.map(|s| s.current_content.clone());
|
||||
self.recompute_hunks(&path, current, source);
|
||||
|
||||
self.send_event(HunkEvent::BaselineUpdated { path: path.clone() });
|
||||
|
||||
debug!("refresh_all_baselines: recomputed hunks for {:?}", path);
|
||||
}
|
||||
}
|
||||
|
||||
debug!("{REFRESH_SCAN_LOG_PREFIX} {:?}", start.elapsed());
|
||||
}
|
||||
|
||||
/// Set tracking mode.
|
||||
pub(super) async fn set_mode(&mut self, mode: TrackingMode) {
|
||||
let old_mode = self.mode;
|
||||
self.mode = mode;
|
||||
|
||||
if old_mode == TrackingMode::AgentOnly && mode == TrackingMode::AllDirty {
|
||||
// Switching to AllDirty - refresh git cache and track dirty files
|
||||
self.refresh_git_dirty_cache(None).await;
|
||||
} else if old_mode == TrackingMode::AllDirty && mode == TrackingMode::AgentOnly {
|
||||
// Switching to AgentOnly - remove non-agent files
|
||||
let non_agent_paths: Vec<PathBuf> = self
|
||||
.file_states
|
||||
.iter()
|
||||
.filter(|(_, state)| !state.is_agent_file)
|
||||
.map(|(path, _)| path.clone())
|
||||
.collect();
|
||||
|
||||
for path in non_agent_paths {
|
||||
if let Some(state) = self.file_states.remove(&path) {
|
||||
// Remove from turn_index and emit removed events for all hunks
|
||||
for hunk in state.hunks {
|
||||
if let Some(prompt_index) = hunk.source.prompt_index()
|
||||
&& let Some(set) = self.turn_index.get_mut(&prompt_index)
|
||||
{
|
||||
set.remove(&hunk.id);
|
||||
}
|
||||
self.send_event(HunkEvent::HunkRemoved {
|
||||
path: path.clone(),
|
||||
hunk_id: hunk.id.clone(),
|
||||
reason: HunkRemovalReason::Superseded,
|
||||
});
|
||||
}
|
||||
self.send_event(HunkEvent::FileRemoved { path });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user