diff --git a/src/daemon/cache.rs b/src/daemon/cache.rs index eb0a8e0..1b96262 100644 --- a/src/daemon/cache.rs +++ b/src/daemon/cache.rs @@ -3,34 +3,99 @@ use anyhow::{Context, Result}; use std::fs; use std::path::Path; -/// Write a generated commit message and its diff hash to the cache. -pub fn write(cache_dir: &Path, repo_id: &str, message: &str, diff_hash: &str) -> Result<()> { +/// Write a generated commit message, diff hash, and optional index tree OID. +pub fn write( + cache_dir: &Path, + repo_id: &str, + message: &str, + diff_hash: &str, + staged_tree: Option<&str>, +) -> Result<()> { let dir = cache_dir.join(repo_id); fs::create_dir_all(&dir) .with_context(|| format!("failed to create cache dir: {}", dir.display()))?; fs::write(dir.join("message"), message).context("failed to write cached message")?; + fs::write(dir.join("diff_hash"), diff_hash).context("failed to write cached diff hash")?; - fs::write(dir.join("diff_hash"), diff_hash).context("failed to write cached diff has")?; + match staged_tree { + Some(t) => { + fs::write(dir.join("staged_tree"), t).context("failed to write cached staged tree")? + } + None => { + let _ = fs::remove_file(dir.join("staged_tree")); + } + } Ok(()) } /// Read a cached commit message for a repo. -/// Returns None if no cache exists. +/// Returns None if no cache exists (missing message or diff_hash). pub fn read(cache_dir: &Path, repo_id: &str) -> Option { let dir = cache_dir.join(repo_id); let message = fs::read_to_string(dir.join("message")).ok()?; let diff_hash = fs::read_to_string(dir.join("diff_hash")).ok()?; + let staged_tree = fs::read_to_string(dir.join("staged_tree")) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); Some(CacheEntry { message: message.trim().to_string(), diff_hash: diff_hash.trim().to_string(), + staged_tree, }) } pub struct CacheEntry { pub message: String, pub diff_hash: String, + pub staged_tree: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_with_staged_tree() { + let dir = tempfile::tempdir().unwrap(); + write( + dir.path(), + "repo1", + "feat: thing", + "abc", + Some("tree_oid_1"), + ) + .unwrap(); + + let entry = read(dir.path(), "repo1").unwrap(); + assert_eq!(entry.message, "feat: thing"); + assert_eq!(entry.diff_hash, "abc"); + assert_eq!(entry.staged_tree.as_deref(), Some("tree_oid_1")); + } + + #[test] + fn roundtrip_without_staged_tree() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "repo2", "fix: bug", "def", None).unwrap(); + + let entry = read(dir.path(), "repo2").unwrap(); + assert_eq!(entry.message, "fix: bug"); + assert_eq!(entry.diff_hash, "def"); + assert!(entry.staged_tree.is_none()); + } + + #[test] + fn staged_tree_cleared_on_overwrite() { + let dir = tempfile::tempdir().unwrap(); + write(dir.path(), "repo3", "m1", "h1", Some("tree_a")).unwrap(); + write(dir.path(), "repo3", "m2", "h2", None).unwrap(); + + let entry = read(dir.path(), "repo3").unwrap(); + assert_eq!(entry.message, "m2"); + assert!(entry.staged_tree.is_none()); + } } diff --git a/src/daemon/watcher.rs b/src/daemon/watcher.rs index 626a1a4..1f5876d 100644 --- a/src/daemon/watcher.rs +++ b/src/daemon/watcher.rs @@ -5,7 +5,7 @@ use std::sync::mpsc; use std::time::{Duration, Instant}; use anyhow::{Context, Result}; -use git2::{DiffFormat, DiffOptions, Repository}; +use git2::{DiffFormat, DiffOptions, Oid, Repository}; use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher}; use sha2::{Digest, Sha256}; @@ -57,6 +57,7 @@ impl RepoWatcher { repo, workdir, last_diff_hash: None, + last_staged_tree: None, debounce_secs: 15, }) } @@ -143,12 +144,12 @@ impl RepoWatcher { event_bus: &mut Option, ) { match self.check_diff(config) { - Ok(Some((diff, hash))) => { + Ok(Some(result)) => { if let Some(bus) = event_bus.as_mut() { bus.broadcast(RepoPhase::Generating, None, None); } - match self.generate_and_cache(config, paths, &diff, hash) { + match self.generate_and_cache(config, paths, result) { Ok(()) => { if let Some(bus) = event_bus.as_mut() { bus.broadcast(RepoPhase::Ready, self.last_diff_hash.clone(), None); @@ -175,8 +176,8 @@ impl RepoWatcher { #[cfg(not(unix))] fn run_generation_cycle(&mut self, config: &SottoConfig, paths: &Paths) { match self.check_diff(config) { - Ok(Some((diff, hash))) => { - if let Err(e) = self.generate_and_cache(config, paths, &diff, hash) { + Ok(Some(result)) => { + if let Err(e) = self.generate_and_cache(config, paths, result) { eprintln!("sotto: {e}"); } } @@ -208,40 +209,69 @@ impl RepoWatcher { }) } - /// Returns `Some((diff_text, hash))` when a new diff needs generation, - /// `None` when the diff is empty or the hash hasn't changed. - fn check_diff(&self, config: &SottoConfig) -> Result> { + /// Returns `Some(DiffResult)` when a new diff needs generation, + /// `None` when the diff is empty or nothing meaningful changed. + fn check_diff(&self, config: &SottoConfig) -> Result> { let staged_diff = self.get_staged_diff(config)?; let workdir_diff = self.get_workdir_diff(config)?; - let diff = if !staged_diff.is_empty() { - staged_diff + let (diff, staged_tree) = if !staged_diff.is_empty() { + let tree_oid = self.index_tree_oid(); + (staged_diff, tree_oid) } else if !workdir_diff.is_empty() { - workdir_diff + (workdir_diff, None) } else { return Ok(None); }; + // When staging content we already generated for, the tree OID will + // match even though the raw patch bytes differ — skip the regen. + if let Some(ref tree) = staged_tree + && self.last_staged_tree.as_ref() == Some(tree) + { + return Ok(None); + } + let hash = hash_string(&diff); if self.last_diff_hash.as_ref() == Some(&hash) { return Ok(None); } - Ok(Some((diff, hash))) + Ok(Some(DiffResult { + diff, + hash, + staged_tree, + })) + } + + /// The tree OID that `git commit` would record given the current index. + /// Returns `None` if the index can't be read or written as a tree. + // FIXME: Duplicated in `shell/complete.rs`; consolidate. Confirm this matches `git write-tree` / + // real commits for unusual index states (sparse checkout, conflict entries, etc.). + fn index_tree_oid(&self) -> Option { + let mut index = self.repo.index().ok()?; + let oid: Oid = index.write_tree().ok()?; + Some(oid.to_string()) } fn generate_and_cache( &mut self, config: &SottoConfig, paths: &Paths, - diff: &str, - hash: String, + result: DiffResult, ) -> Result<()> { let repo_id = self.repo_cache_id()?; - let message = generator::generate(config, diff)?; - cache::write(&paths.cache_dir, &repo_id, &message, &hash)?; - self.last_diff_hash = Some(hash); + let message = generator::generate(config, &result.diff)?; + cache::write( + &paths.cache_dir, + &repo_id, + &message, + &result.hash, + result.staged_tree.as_deref(), + )?; + self.last_diff_hash = Some(result.hash); + self.last_staged_tree = result.staged_tree; Ok(()) } @@ -319,9 +349,16 @@ fn hash_string(input: &str) -> String { .collect() } +struct DiffResult { + diff: String, + hash: String, + staged_tree: Option, +} + pub struct RepoWatcher { repo: Repository, workdir: PathBuf, last_diff_hash: Option, + last_staged_tree: Option, debounce_secs: u64, } diff --git a/src/shell/complete.rs b/src/shell/complete.rs index 5f40c1c..313bae9 100644 --- a/src/shell/complete.rs +++ b/src/shell/complete.rs @@ -1,4 +1,4 @@ -use git2::{DiffFormat, DiffOptions, Repository}; +use git2::{DiffFormat, DiffOptions, Oid, Repository}; use sha2::{Digest, Sha256}; use crate::config::{Paths, SottoConfig}; @@ -53,7 +53,11 @@ fn try_socket_fast_path(_paths: &Paths, _repo_id: &str) -> Option { None } -/// Original path: read cache, recompute diffs locally, validate the hash. +/// Original path: read cache, recompute diffs locally, validate staleness. +/// +/// For staged content, prefer comparing the index tree OID rather than raw +/// patch bytes — staging the same content the daemon already saw should reuse +/// the cached message even though the diff text formatting may differ. fn try_disk_validated(paths: &Paths, repo: &Repository, repo_id: &str) -> Option { let entry = cache::read(&paths.cache_dir, repo_id)?; let config = SottoConfig::load_silently(paths)?; @@ -61,6 +65,13 @@ fn try_disk_validated(paths: &Paths, repo: &Repository, repo_id: &str) -> Option let staged_diff = get_staged_diff(repo, config.max_diff_lines).ok()?; if !staged_diff.is_empty() { + if let Some(ref cached_tree) = entry.staged_tree + && let Some(current_tree) = index_tree_oid(repo) + && *cached_tree == current_tree + { + return Some(entry.message); + } + let staged_hash = hash_string(&staged_diff); if staged_hash != entry.diff_hash { return None; @@ -76,6 +87,14 @@ fn try_disk_validated(paths: &Paths, repo: &Repository, repo_id: &str) -> Option Some(entry.message) } +// FIXME: Duplicated in `daemon/watcher.rs`; consolidate. Confirm this matches `git write-tree` / +// real commits for unusual index states (sparse checkout, conflict entries, etc.). +fn index_tree_oid(repo: &Repository) -> Option { + let mut index = repo.index().ok()?; + let oid: Oid = index.write_tree().ok()?; + Some(oid.to_string()) +} + fn get_workdir_diff(repo: &Repository, max_lines: usize) -> Result { let mut opts = DiffOptions::new(); opts.include_untracked(false);