Skip to content

Commit 9a03f0e

Browse files
jamesaphoenixclaude
andcommitted
release: v0.5.13 — auto-ignore git subtree imports, fix stale cache after ignore-config change
Two compounding problems made vendored upstream code (effect, etc.) flood the flow groups in repos that use `git subtree add` to vendor dependencies. On the otel-outbox-tracing-sentry worktree this surfaced as 3,500 files from `repos/effect/` swamping the 400 files of real changes. 1. No subtree awareness. Diffcore had no way to know that `repos/effect/` was vendored code rather than first-party source. Users had to manually add `repos/effect/**` to `[ignore].paths` in `.diffcore.toml`, and most never did because the source of the noise wasn't obvious. 2. Stale analysis cache. Even when users added the ignore pattern, the on-disk analysis cache at `.diffcore/cache/<sha>.json` didn't include ignore patterns in its key — so the cached result from before the config change kept getting served. `--no-cache` looked like a workaround but only skipped the IR cache; the analysis-cache check at main.rs:496 didn't honor the flag at all. Fix: - crates/diffcore-core/src/git.rs: new `detect_subtree_paths(repo)` walks HEAD and matches three `git subtree` message shapes — `Squashed '<path>/' content from commit`, `Add '<path>/' from commit '<sha>'`, and `Merge commit '<sha>' as '<path>'`. Returns deduplicated, sorted paths, filtered to those that still exist in the working directory (so a later rename/delete doesn't ghost-add ignore entries). - crates/diffcore-core/src/config.rs: new `IgnoreConfig.auto_subtrees` field defaulting to true. New `DiffcoreConfig::apply_detected_subtrees` method appends `<path>/**` globs to `ignore.paths`, skipping duplicates, and returns the detected paths so callers can log them. - crates/diffcore-core/src/cache.rs: `compute_cache_key` now takes the ignore-pattern list as a second argument and folds a sorted hash of the patterns into the SHA-256 key. Any ignore-config change — including auto-detected subtrees — now invalidates the cache automatically. - crates/diffcore-cli/src/main.rs: calls `apply_detected_subtrees` after loading config in both analyze entry points, logs `Auto-ignoring N git subtree path(s): …` at info level, passes ignore patterns into `compute_cache_key`, and finally honors `args.no_cache` for the analysis cache (previously it only affected the IR cache). - crates/diffcore-tauri/src/commands.rs: same wiring inside the Tauri analyze command so the desktop app gets the same behavior. Tests: - git.rs: 6 new tests — three for the message-parser covering each subtree marker shape, one for unrelated messages, plus `detect_subtree_paths` against a real temp repo that exercises the on-disk filter and the dedupe/sort guarantees. - config.rs: 4 new tests covering the `auto_subtrees` default, opt-out via TOML, the no-op-when-disabled path, and full append behavior against a real repo with a Squashed-style commit. - cache.rs: 2 new tests — `cache_key_includes_ignore_patterns` proves the key changes when patterns are added; `cache_key_ignore_pattern_order_independent` proves pattern order doesn't break cache hits. Verified locally on the otel-outbox-tracing-sentry worktree: file count of `repos/effect/...` entries in flow groups drops from 1,502 to 0, total groups drop from 660 to 164. Opt out with: [ignore] auto_subtrees = false Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 368a847 commit 9a03f0e

8 files changed

Lines changed: 402 additions & 26 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ members = ["crates/diffcore-core", "crates/diffcore-cli", "crates/diffcore-tauri
33
resolver = "2"
44

55
[workspace.package]
6-
version = "0.5.12"
6+
version = "0.5.13"
77
edition = "2021"
88
license = "MIT"
99

crates/diffcore-cli/src/main.rs

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -325,8 +325,9 @@ fn run_analyze_and_return(args: AnalyzeArgs) -> Result<AnalysisOutput, Box<dyn s
325325
.workdir()
326326
.ok_or("Bare repositories are not supported")?
327327
.to_path_buf();
328-
let config = DiffcoreConfig::load_with_global_llm_from_dir(&workdir)
328+
let mut config = DiffcoreConfig::load_with_global_llm_from_dir(&workdir)
329329
.map_err(|e| format!("Config error: {}", e))?;
330+
let _detected_subtrees = config.apply_detected_subtrees(&repo);
330331

331332
let include_uncommitted = if args.include_uncommitted {
332333
true
@@ -354,7 +355,7 @@ fn run_analyze_and_return(args: AnalyzeArgs) -> Result<AnalysisOutput, Box<dyn s
354355
});
355356
}
356357

357-
let cache_key = cache::compute_cache_key(&diff_result);
358+
let cache_key = cache::compute_cache_key(&diff_result, &config.ignore.paths);
358359
if let Some(cached) = cache::load_cached(&workdir, &cache_key) {
359360
return Ok(cached);
360361
}
@@ -442,6 +443,16 @@ fn run_analyze(args: AnalyzeArgs) -> Result<(), Box<dyn std::error::Error>> {
442443
let mut config = DiffcoreConfig::load_with_global_llm_from_dir(&workdir)
443444
.map_err(|e| format!("Config error: {}", e))?;
444445

446+
// Auto-detect git subtree imports and add them to the ignore list.
447+
let detected_subtrees = config.apply_detected_subtrees(&repo);
448+
if !detected_subtrees.is_empty() {
449+
log::info!(
450+
"Auto-ignoring {} git subtree path(s): {}",
451+
detected_subtrees.len(),
452+
detected_subtrees.join(", ")
453+
);
454+
}
455+
445456
// Apply CLI overrides for refinement
446457
if args.refine {
447458
config.llm.refinement.enabled = true;
@@ -480,9 +491,11 @@ fn run_analyze(args: AnalyzeArgs) -> Result<(), Box<dyn std::error::Error>> {
480491
return write_output(&empty_output, args.output.as_deref());
481492
}
482493

483-
// Check cache (skip if LLM annotation or refinement requested — those are additive)
484-
let cache_key = cache::compute_cache_key(&diff_result);
485-
if !args.annotate && !args.refine && args.refine_model.is_none() {
494+
// Check cache (skip if LLM annotation or refinement requested — those are additive,
495+
// or if --no-cache was passed). Cache key incorporates ignore patterns so any
496+
// ignore-config change (incl. auto-detected subtrees) invalidates the entry.
497+
let cache_key = cache::compute_cache_key(&diff_result, &config.ignore.paths);
498+
if !args.annotate && !args.refine && args.refine_model.is_none() && !args.no_cache {
486499
if let Some(cached) = cache::load_cached(&workdir, &cache_key) {
487500
return write_output(&cached, args.output.as_deref());
488501
}

crates/diffcore-core/src/cache.rs

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,18 @@ use std::path::{Path, PathBuf};
1414
use crate::git::DiffResult;
1515
use crate::types::AnalysisOutput;
1616

17-
/// Compute a deterministic cache key from a diff result.
17+
/// Compute a deterministic cache key from a diff result and ignore patterns.
1818
///
1919
/// The key is a hex-encoded SHA-256 hash of:
2020
/// - base_sha (or "none")
2121
/// - head_sha (or "none")
2222
/// - sorted file paths joined by newlines
23+
/// - sorted ignore patterns joined by newlines
2324
///
24-
/// This ensures the cache is invalidated when any file is added/removed
25-
/// or when the base/head refs change.
26-
pub fn compute_cache_key(diff_result: &DiffResult) -> String {
25+
/// This ensures the cache is invalidated when any file is added/removed,
26+
/// when the base/head refs change, or when the ignore configuration changes
27+
/// (including auto-detected subtree paths).
28+
pub fn compute_cache_key(diff_result: &DiffResult, ignore_patterns: &[String]) -> String {
2729
let mut hasher = Sha256::new();
2830

2931
hasher.update(diff_result.base_sha.as_deref().unwrap_or("none"));
@@ -38,6 +40,16 @@ pub fn compute_cache_key(diff_result: &DiffResult) -> String {
3840
hasher.update(b"\n");
3941
}
4042

43+
// Separator so an empty patterns list produces a distinct key from one whose
44+
// first pattern happens to start with a path-looking string.
45+
hasher.update(b"\0ignore\0");
46+
let mut patterns: Vec<&str> = ignore_patterns.iter().map(|s| s.as_str()).collect();
47+
patterns.sort();
48+
for pat in &patterns {
49+
hasher.update(pat.as_bytes());
50+
hasher.update(b"\n");
51+
}
52+
4153
hex::encode(hasher.finalize())
4254
}
4355

@@ -250,44 +262,44 @@ mod tests {
250262
#[test]
251263
fn cache_key_deterministic() {
252264
let diff = make_diff_result(Some("abc"), Some("def"), &["a.ts", "b.ts"]);
253-
let key1 = compute_cache_key(&diff);
254-
let key2 = compute_cache_key(&diff);
265+
let key1 = compute_cache_key(&diff, &[]);
266+
let key2 = compute_cache_key(&diff, &[]);
255267
assert_eq!(key1, key2);
256268
}
257269

258270
#[test]
259271
fn cache_key_different_shas() {
260272
let diff1 = make_diff_result(Some("abc"), Some("def"), &["a.ts"]);
261273
let diff2 = make_diff_result(Some("abc"), Some("ghi"), &["a.ts"]);
262-
assert_ne!(compute_cache_key(&diff1), compute_cache_key(&diff2));
274+
assert_ne!(compute_cache_key(&diff1, &[]), compute_cache_key(&diff2, &[]));
263275
}
264276

265277
#[test]
266278
fn cache_key_different_files() {
267279
let diff1 = make_diff_result(Some("abc"), Some("def"), &["a.ts"]);
268280
let diff2 = make_diff_result(Some("abc"), Some("def"), &["a.ts", "b.ts"]);
269-
assert_ne!(compute_cache_key(&diff1), compute_cache_key(&diff2));
281+
assert_ne!(compute_cache_key(&diff1, &[]), compute_cache_key(&diff2, &[]));
270282
}
271283

272284
#[test]
273285
fn cache_key_order_independent() {
274286
let diff1 = make_diff_result(Some("abc"), Some("def"), &["b.ts", "a.ts"]);
275287
let diff2 = make_diff_result(Some("abc"), Some("def"), &["a.ts", "b.ts"]);
276-
assert_eq!(compute_cache_key(&diff1), compute_cache_key(&diff2));
288+
assert_eq!(compute_cache_key(&diff1, &[]), compute_cache_key(&diff2, &[]));
277289
}
278290

279291
#[test]
280292
fn cache_key_none_shas() {
281293
let diff = make_diff_result(None, None, &["a.ts"]);
282-
let key = compute_cache_key(&diff);
294+
let key = compute_cache_key(&diff, &[]);
283295
assert!(!key.is_empty());
284296
assert_eq!(key.len(), 64); // SHA-256 hex length
285297
}
286298

287299
#[test]
288300
fn cache_key_empty_files() {
289301
let diff = make_diff_result(Some("abc"), Some("def"), &[]);
290-
let key = compute_cache_key(&diff);
302+
let key = compute_cache_key(&diff, &[]);
291303
assert_eq!(key.len(), 64);
292304
}
293305

@@ -349,10 +361,32 @@ mod tests {
349361
#[test]
350362
fn cache_key_hex_encoded() {
351363
let diff = make_diff_result(Some("abc"), Some("def"), &["a.ts"]);
352-
let key = compute_cache_key(&diff);
364+
let key = compute_cache_key(&diff, &[]);
353365
assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
354366
}
355367

368+
#[test]
369+
fn cache_key_includes_ignore_patterns() {
370+
let diff = make_diff_result(Some("abc"), Some("def"), &["a.ts"]);
371+
let no_ignores = compute_cache_key(&diff, &[]);
372+
let with_ignores = compute_cache_key(&diff, &["repos/effect/**".to_string()]);
373+
assert_ne!(no_ignores, with_ignores);
374+
}
375+
376+
#[test]
377+
fn cache_key_ignore_pattern_order_independent() {
378+
let diff = make_diff_result(Some("abc"), Some("def"), &["a.ts"]);
379+
let key1 = compute_cache_key(
380+
&diff,
381+
&["repos/effect/**".to_string(), "dist/**".to_string()],
382+
);
383+
let key2 = compute_cache_key(
384+
&diff,
385+
&["dist/**".to_string(), "repos/effect/**".to_string()],
386+
);
387+
assert_eq!(key1, key2);
388+
}
389+
356390
#[test]
357391
fn load_cached_malformed_json_returns_none() {
358392
let tmp = tempfile::tempdir().unwrap();

crates/diffcore-core/src/config.rs

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,11 +88,32 @@ pub struct EntrypointConfig {
8888
}
8989

9090
/// File ignore configuration.
91-
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
91+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9292
pub struct IgnoreConfig {
9393
/// Glob patterns for files to exclude from analysis.
9494
#[serde(default)]
9595
pub paths: Vec<String>,
96+
/// Whether to auto-detect git subtree imports and ignore their paths.
97+
///
98+
/// When true (default), `apply_detected_subtrees` scans the repository's commit
99+
/// history for `git subtree` merge markers and adds the imported paths to
100+
/// `paths` as `<path>/**` globs. Vendored upstream code merged via
101+
/// `git subtree add`/`pull` is filtered out automatically without manual config.
102+
#[serde(default = "default_auto_subtrees")]
103+
pub auto_subtrees: bool,
104+
}
105+
106+
fn default_auto_subtrees() -> bool {
107+
true
108+
}
109+
110+
impl Default for IgnoreConfig {
111+
fn default() -> Self {
112+
Self {
113+
paths: Vec::new(),
114+
auto_subtrees: true,
115+
}
116+
}
96117
}
97118

98119
/// Diff behavior configuration.
@@ -378,6 +399,28 @@ impl DiffcoreConfig {
378399
Ok(())
379400
}
380401

402+
/// Detect git subtree imports and append `<path>/**` globs to the ignore list.
403+
///
404+
/// No-op when `ignore.auto_subtrees` is false. Returns the list of paths that
405+
/// were detected (so callers can surface them in logs/UI); pre-existing
406+
/// ignore patterns are preserved and duplicates are skipped.
407+
pub fn apply_detected_subtrees(&mut self, repo: &git2::Repository) -> Vec<String> {
408+
if !self.ignore.auto_subtrees {
409+
return Vec::new();
410+
}
411+
let detected = match crate::git::detect_subtree_paths(repo) {
412+
Ok(paths) => paths,
413+
Err(_) => return Vec::new(),
414+
};
415+
for path in &detected {
416+
let pattern = format!("{}/**", path);
417+
if !self.ignore.paths.contains(&pattern) {
418+
self.ignore.paths.push(pattern);
419+
}
420+
}
421+
detected
422+
}
423+
381424
/// Check if a file path should be ignored based on configured ignore patterns.
382425
///
383426
/// Patterns are matched against the relative path from the repo root.
@@ -920,6 +963,7 @@ events = ["src/handlers/events/**/*.ts"]
920963
},
921964
ignore: IgnoreConfig {
922965
paths: vec!["**/*.test.ts".to_string()],
966+
auto_subtrees: true,
923967
},
924968
llm: LlmConfig {
925969
provider: Some("anthropic".to_string()),
@@ -1320,4 +1364,114 @@ provider = "openai"
13201364

13211365
std::env::remove_var("DIFFCORE_GLOBAL_CONFIG_DIR");
13221366
}
1367+
1368+
// ── auto_subtrees Tests ──
1369+
1370+
#[test]
1371+
fn auto_subtrees_defaults_true() {
1372+
let config = DiffcoreConfig::default();
1373+
assert!(config.ignore.auto_subtrees);
1374+
}
1375+
1376+
#[test]
1377+
fn auto_subtrees_defaults_true_on_empty_toml() {
1378+
let config = DiffcoreConfig::from_str("").unwrap();
1379+
assert!(config.ignore.auto_subtrees);
1380+
}
1381+
1382+
#[test]
1383+
fn auto_subtrees_can_be_disabled() {
1384+
let toml_str = r#"
1385+
[ignore]
1386+
auto_subtrees = false
1387+
"#;
1388+
let config = DiffcoreConfig::from_str(toml_str).unwrap();
1389+
assert!(!config.ignore.auto_subtrees);
1390+
}
1391+
1392+
#[test]
1393+
fn apply_detected_subtrees_is_noop_when_disabled() {
1394+
let dir = tempfile::tempdir().unwrap();
1395+
let repo = git2::Repository::init(dir.path()).unwrap();
1396+
let mut config = DiffcoreConfig::default();
1397+
config.ignore.auto_subtrees = false;
1398+
config.ignore.paths.push("foo/**".to_string());
1399+
1400+
let detected = config.apply_detected_subtrees(&repo);
1401+
assert!(detected.is_empty());
1402+
assert_eq!(config.ignore.paths, vec!["foo/**".to_string()]);
1403+
}
1404+
1405+
#[test]
1406+
fn apply_detected_subtrees_appends_detected_globs() {
1407+
// Build a repo with a squashed-subtree commit whose path exists on disk.
1408+
let dir = tempfile::tempdir().unwrap();
1409+
let repo = git2::Repository::init(dir.path()).unwrap();
1410+
{
1411+
let mut cfg = repo.config().unwrap();
1412+
cfg.set_str("user.name", "Test").unwrap();
1413+
cfg.set_str("user.email", "test@test.com").unwrap();
1414+
}
1415+
1416+
// initial commit
1417+
let sig = repo.signature().unwrap();
1418+
std::fs::write(dir.path().join("README.md"), "root").unwrap();
1419+
let mut index = repo.index().unwrap();
1420+
index.add_path(std::path::Path::new("README.md")).unwrap();
1421+
index.write().unwrap();
1422+
let tree_oid = index.write_tree().unwrap();
1423+
let tree = repo.find_tree(tree_oid).unwrap();
1424+
repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
1425+
.unwrap();
1426+
1427+
// subtree commit
1428+
std::fs::create_dir_all(dir.path().join("repos/effect")).unwrap();
1429+
std::fs::write(dir.path().join("repos/effect/package.json"), "{}").unwrap();
1430+
let mut index = repo.index().unwrap();
1431+
index
1432+
.add_path(std::path::Path::new("repos/effect/package.json"))
1433+
.unwrap();
1434+
index.write().unwrap();
1435+
let tree_oid = index.write_tree().unwrap();
1436+
let tree = repo.find_tree(tree_oid).unwrap();
1437+
let parent = repo.head().unwrap().peel_to_commit().unwrap();
1438+
repo.commit(
1439+
Some("HEAD"),
1440+
&sig,
1441+
&sig,
1442+
"Squashed 'repos/effect/' content from commit deadbeef",
1443+
&tree,
1444+
&[&parent],
1445+
)
1446+
.unwrap();
1447+
1448+
let mut config = DiffcoreConfig::default();
1449+
let detected = config.apply_detected_subtrees(&repo);
1450+
1451+
assert_eq!(detected, vec!["repos/effect".to_string()]);
1452+
assert!(config.ignore.paths.contains(&"repos/effect/**".to_string()));
1453+
assert!(config.is_ignored("repos/effect/package.json"));
1454+
assert!(!config.is_ignored("apps/api/src/main.ts"));
1455+
}
1456+
1457+
#[test]
1458+
fn apply_detected_subtrees_does_not_duplicate_existing_patterns() {
1459+
let dir = tempfile::tempdir().unwrap();
1460+
let repo = git2::Repository::init(dir.path()).unwrap();
1461+
let mut config = DiffcoreConfig::default();
1462+
// Pre-existing ignore entry for the same subtree.
1463+
config.ignore.paths.push("repos/effect/**".to_string());
1464+
1465+
// Call twice — should be idempotent.
1466+
let _ = config.apply_detected_subtrees(&repo);
1467+
let _ = config.apply_detected_subtrees(&repo);
1468+
1469+
let count = config
1470+
.ignore
1471+
.paths
1472+
.iter()
1473+
.filter(|p| *p == "repos/effect/**")
1474+
.count();
1475+
assert_eq!(count, 1);
1476+
}
13231477
}

0 commit comments

Comments
 (0)