Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 69 additions & 4 deletions src/daemon/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CacheEntry> {
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<String>,
}

#[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());
}
}
71 changes: 54 additions & 17 deletions src/daemon/watcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -57,6 +57,7 @@ impl RepoWatcher {
repo,
workdir,
last_diff_hash: None,
last_staged_tree: None,
debounce_secs: 15,
})
}
Expand Down Expand Up @@ -143,12 +144,12 @@ impl RepoWatcher {
event_bus: &mut Option<EventBus>,
) {
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);
Expand All @@ -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}");
}
}
Expand Down Expand Up @@ -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<Option<(String, String)>> {
/// 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<Option<DiffResult>> {
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<String> {
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(())
}

Expand Down Expand Up @@ -319,9 +349,16 @@ fn hash_string(input: &str) -> String {
.collect()
}

struct DiffResult {
diff: String,
hash: String,
staged_tree: Option<String>,
}

pub struct RepoWatcher {
repo: Repository,
workdir: PathBuf,
last_diff_hash: Option<String>,
last_staged_tree: Option<String>,
debounce_secs: u64,
}
23 changes: 21 additions & 2 deletions src/shell/complete.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use git2::{DiffFormat, DiffOptions, Repository};
use git2::{DiffFormat, DiffOptions, Oid, Repository};
use sha2::{Digest, Sha256};

use crate::config::{Paths, SottoConfig};
Expand Down Expand Up @@ -53,14 +53,25 @@ fn try_socket_fast_path(_paths: &Paths, _repo_id: &str) -> Option<String> {
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<String> {
let entry = cache::read(&paths.cache_dir, repo_id)?;
let config = SottoConfig::load_silently(paths)?;

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;
Expand All @@ -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<String> {
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<String, git2::Error> {
let mut opts = DiffOptions::new();
opts.include_untracked(false);
Expand Down