diffctx 1.10.1__tar.gz → 1.10.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. {diffctx-1.10.1 → diffctx-1.10.2}/PKG-INFO +1 -1
  2. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/git.rs +129 -0
  3. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/parsers/markdown.rs +54 -13
  4. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/pipeline.rs +42 -2
  5. {diffctx-1.10.1 → diffctx-1.10.2}/pyproject.toml +1 -1
  6. {diffctx-1.10.1 → diffctx-1.10.2}/src/diffctx/cli.py +26 -5
  7. {diffctx-1.10.1 → diffctx-1.10.2}/src/diffctx/ignore.py +16 -3
  8. {diffctx-1.10.1 → diffctx-1.10.2}/src/diffctx/main.py +31 -10
  9. {diffctx-1.10.1 → diffctx-1.10.2}/src/diffctx/mcp/__main__.py +7 -0
  10. diffctx-1.10.2/src/diffctx/version.py +1 -0
  11. diffctx-1.10.1/src/diffctx/version.py +0 -1
  12. {diffctx-1.10.1 → diffctx-1.10.2}/CHANGELOG.md +0 -0
  13. {diffctx-1.10.1 → diffctx-1.10.2}/LICENSE +0 -0
  14. {diffctx-1.10.1 → diffctx-1.10.2}/README.md +0 -0
  15. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/Cargo.lock +0 -0
  16. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/Cargo.toml +0 -0
  17. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/analytics.rs +0 -0
  18. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/candidate_files.rs +0 -0
  19. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/config/analytics.rs +0 -0
  20. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/config/bm25.rs +0 -0
  21. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/config/budget.rs +0 -0
  22. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/config/category_weights.rs +0 -0
  23. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/config/edge_weights.rs +0 -0
  24. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/config/env_overrides.rs +0 -0
  25. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/config/extensions.rs +0 -0
  26. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/config/filtering.rs +0 -0
  27. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/config/fragmentation.rs +0 -0
  28. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/config/git.rs +0 -0
  29. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/config/graph_filtering.rs +0 -0
  30. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/config/importance.rs +0 -0
  31. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/config/limits.rs +0 -0
  32. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/config/mod.rs +0 -0
  33. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/config/mode.rs +0 -0
  34. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/config/needs.rs +0 -0
  35. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/config/parsers.rs +0 -0
  36. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/config/render.rs +0 -0
  37. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/config/scoring.rs +0 -0
  38. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/config/selection.rs +0 -0
  39. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/config/tokenization.rs +0 -0
  40. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/config/weights.rs +0 -0
  41. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/core.rs +0 -0
  42. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/discovery.rs +0 -0
  43. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/base.rs +0 -0
  44. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/config_edges/build_system.rs +0 -0
  45. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/config_edges/cicd.rs +0 -0
  46. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/config_edges/docker.rs +0 -0
  47. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/config_edges/generic.rs +0 -0
  48. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/config_edges/helm.rs +0 -0
  49. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/config_edges/kubernetes.rs +0 -0
  50. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/config_edges/mod.rs +0 -0
  51. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/document/mod.rs +0 -0
  52. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/history/cochange.rs +0 -0
  53. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/history/mod.rs +0 -0
  54. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/mod.rs +0 -0
  55. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/ansible.rs +0 -0
  56. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/bazel.rs +0 -0
  57. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/c_family.rs +0 -0
  58. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/cargo_edges.rs +0 -0
  59. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/clojure.rs +0 -0
  60. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/css.rs +0 -0
  61. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/dart.rs +0 -0
  62. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/dbt.rs +0 -0
  63. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/dotnet.rs +0 -0
  64. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/elixir.rs +0 -0
  65. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/erlang.rs +0 -0
  66. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/go.rs +0 -0
  67. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/graphql.rs +0 -0
  68. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/haskell.rs +0 -0
  69. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/javascript.rs +0 -0
  70. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/julia.rs +0 -0
  71. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/jvm.rs +0 -0
  72. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/latex.rs +0 -0
  73. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/lua.rs +0 -0
  74. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/mod.rs +0 -0
  75. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/nim.rs +0 -0
  76. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/nix.rs +0 -0
  77. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/ocaml.rs +0 -0
  78. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/openapi.rs +0 -0
  79. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/perl.rs +0 -0
  80. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/php.rs +0 -0
  81. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/prisma.rs +0 -0
  82. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/protobuf.rs +0 -0
  83. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/python.rs +0 -0
  84. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/r_lang.rs +0 -0
  85. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/ruby.rs +0 -0
  86. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/rust_lang.rs +0 -0
  87. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/shell.rs +0 -0
  88. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/sql.rs +0 -0
  89. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/swift.rs +0 -0
  90. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/tags.rs +0 -0
  91. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/terraform.rs +0 -0
  92. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/semantic/zig.rs +0 -0
  93. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/similarity/lexical.rs +0 -0
  94. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/similarity/mod.rs +0 -0
  95. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/structural/containment.rs +0 -0
  96. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/structural/mod.rs +0 -0
  97. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/structural/sibling.rs +0 -0
  98. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/edges/structural/testing.rs +0 -0
  99. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/filtering.rs +0 -0
  100. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/fragmentation.rs +0 -0
  101. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/graph.rs +0 -0
  102. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/graph_export.rs +0 -0
  103. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/interval.rs +0 -0
  104. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/languages.rs +0 -0
  105. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/lib.rs +0 -0
  106. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/main.rs +0 -0
  107. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/memory_pipeline.rs +0 -0
  108. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/mode.rs +0 -0
  109. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/parsers/config_parser.rs +0 -0
  110. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/parsers/generic.rs +0 -0
  111. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/parsers/mod.rs +0 -0
  112. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/parsers/tree_sitter_strategy.rs +0 -0
  113. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/postpass.rs +0 -0
  114. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/ppr.rs +0 -0
  115. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/project_graph.rs +0 -0
  116. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/pybridge.rs +0 -0
  117. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/render.rs +0 -0
  118. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/scoring.rs +0 -0
  119. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/select.rs +0 -0
  120. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/signatures.rs +0 -0
  121. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/stopwords.rs +0 -0
  122. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/test_harness.rs +0 -0
  123. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/tokenizer.rs +0 -0
  124. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/types.rs +0 -0
  125. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/utility/boltzmann.rs +0 -0
  126. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/utility/importance.rs +0 -0
  127. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/utility/mod.rs +0 -0
  128. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/utility/needs.rs +0 -0
  129. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/src/utility/scoring.rs +0 -0
  130. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/tests/common/mod.rs +0 -0
  131. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/tests/fixtures/garbage/garbage_api.py +0 -0
  132. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/tests/fixtures/garbage/garbage_constants.py +0 -0
  133. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/tests/fixtures/garbage/garbage_handlers.py +0 -0
  134. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/tests/fixtures/garbage/garbage_models.py +0 -0
  135. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/tests/fixtures/garbage/garbage_module.js +0 -0
  136. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/tests/fixtures/garbage/garbage_services.py +0 -0
  137. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/tests/fixtures/garbage/garbage_types.py +0 -0
  138. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/tests/fixtures/garbage/garbage_unrelated.yaml +0 -0
  139. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/tests/fixtures/garbage/garbage_utils.py +0 -0
  140. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/tests/fixtures/garbage/garbage_validators.py +0 -0
  141. {diffctx-1.10.1 → diffctx-1.10.2}/diffctx/tests/yaml_cases.rs +0 -0
  142. {diffctx-1.10.1 → diffctx-1.10.2}/rust-toolchain.toml +0 -0
  143. {diffctx-1.10.1 → diffctx-1.10.2}/src/diffctx/__init__.py +0 -0
  144. {diffctx-1.10.1 → diffctx-1.10.2}/src/diffctx/__main__.py +0 -0
  145. {diffctx-1.10.1 → diffctx-1.10.2}/src/diffctx/clipboard.py +0 -0
  146. {diffctx-1.10.1 → diffctx-1.10.2}/src/diffctx/diffctx/__init__.py +0 -0
  147. {diffctx-1.10.1 → diffctx-1.10.2}/src/diffctx/diffctx/graph_analytics.py +0 -0
  148. {diffctx-1.10.1 → diffctx-1.10.2}/src/diffctx/diffctx/graph_export.py +0 -0
  149. {diffctx-1.10.1 → diffctx-1.10.2}/src/diffctx/diffctx/pipeline.py +0 -0
  150. {diffctx-1.10.1 → diffctx-1.10.2}/src/diffctx/diffctx/project_graph.py +0 -0
  151. {diffctx-1.10.1 → diffctx-1.10.2}/src/diffctx/logger.py +0 -0
  152. {diffctx-1.10.1 → diffctx-1.10.2}/src/diffctx/mcp/README.md +0 -0
  153. {diffctx-1.10.1 → diffctx-1.10.2}/src/diffctx/mcp/__init__.py +0 -0
  154. {diffctx-1.10.1 → diffctx-1.10.2}/src/diffctx/mcp/formatting.py +0 -0
  155. {diffctx-1.10.1 → diffctx-1.10.2}/src/diffctx/mcp/security.py +0 -0
  156. {diffctx-1.10.1 → diffctx-1.10.2}/src/diffctx/mcp/server.py +0 -0
  157. {diffctx-1.10.1 → diffctx-1.10.2}/src/diffctx/py.typed +0 -0
  158. {diffctx-1.10.1 → diffctx-1.10.2}/src/diffctx/tokens.py +0 -0
  159. {diffctx-1.10.1 → diffctx-1.10.2}/src/diffctx/tree.py +0 -0
  160. {diffctx-1.10.1 → diffctx-1.10.2}/src/diffctx/writer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diffctx
3
- Version: 1.10.1
3
+ Version: 1.10.2
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -500,6 +500,135 @@ pub fn get_untracked_files(repo_root: &Path) -> Result<Vec<PathBuf>> {
500
500
  .collect())
501
501
  }
502
502
 
503
+ /// Rewrites one `.diffctx/ignore` pattern line to be anchored to the
504
+ /// directory that contains the `.diffctx/` folder (`rel`, repo-root-relative,
505
+ /// "" for the repo root itself). Mirrors `_process_ignore_line` in the
506
+ /// Python tree-mode ignore resolver (`src/diffctx/ignore.py`) so a pattern
507
+ /// declared in `sub/.diffctx/ignore` only ever matches within `sub/`.
508
+ fn anchor_diffctx_ignore_line(line: &str, rel: &str) -> String {
509
+ let (neg, pat) = match line.strip_prefix('!') {
510
+ Some(rest) => (true, rest),
511
+ None => (false, line),
512
+ };
513
+ let pat_no_trailing_slash = pat.trim_end_matches('/');
514
+ let full = if pat_no_trailing_slash.starts_with('/') || pat_no_trailing_slash.contains('/') {
515
+ let anchored = pat.trim_start_matches('/');
516
+ if rel.is_empty() {
517
+ format!("/{anchored}")
518
+ } else {
519
+ format!("/{rel}/{anchored}")
520
+ }
521
+ } else if rel.is_empty() {
522
+ pat.to_string()
523
+ } else {
524
+ format!("{rel}/**/{pat}")
525
+ };
526
+ if neg { format!("!{full}") } else { full }
527
+ }
528
+
529
+ /// Finds every `.diffctx/ignore` file tracked or present in `repo_root`
530
+ /// (any depth) and returns its patterns rewritten to be repo-root-relative,
531
+ /// ready to feed into a combined gitignore-syntax exclude file.
532
+ fn collect_diffctx_ignore_patterns(repo_root: &Path) -> Vec<String> {
533
+ let Ok(files) = run_git_z(
534
+ repo_root,
535
+ &[
536
+ "ls-files",
537
+ "-z",
538
+ "--cached",
539
+ "--others",
540
+ "--exclude-standard",
541
+ "--",
542
+ ":(glob)**/.diffctx/ignore",
543
+ ],
544
+ ) else {
545
+ return Vec::new();
546
+ };
547
+
548
+ let mut patterns = Vec::new();
549
+ for raw in &files {
550
+ let rel_path = unquote_c_style(raw);
551
+ if !rel_path.ends_with(".diffctx/ignore") {
552
+ continue;
553
+ }
554
+ let rel_dir = rel_path
555
+ .strip_suffix(".diffctx/ignore")
556
+ .unwrap_or("")
557
+ .trim_end_matches('/');
558
+ let Ok(content) = std::fs::read_to_string(repo_root.join(&rel_path)) else {
559
+ continue;
560
+ };
561
+ for line in content.lines() {
562
+ let line = line.trim_end();
563
+ if line.is_empty() || line.starts_with('#') {
564
+ continue;
565
+ }
566
+ patterns.push(anchor_diffctx_ignore_line(line, rel_dir));
567
+ }
568
+ }
569
+ patterns
570
+ }
571
+
572
+ /// Returns the subset of `rel_paths` (repo-root-relative) excluded by either
573
+ /// `.gitignore` (via git's own engine, so nesting/negation/`**` are handled
574
+ /// correctly) or `.diffctx/ignore` (patterns anchored per-directory and fed
575
+ /// to git as a temporary `core.excludesFile`, so the same engine evaluates
576
+ /// both mechanisms uniformly). Best-effort: any failure returns an empty set
577
+ /// rather than blocking the diff pipeline on an ignore-resolution problem.
578
+ pub fn find_ignored_paths(repo_root: &Path, rel_paths: &[String]) -> FxHashSet<String> {
579
+ if rel_paths.is_empty() {
580
+ return FxHashSet::default();
581
+ }
582
+
583
+ let diffctx_patterns = collect_diffctx_ignore_patterns(repo_root);
584
+ let temp_excludes = if diffctx_patterns.is_empty() {
585
+ None
586
+ } else {
587
+ let path = std::env::temp_dir().join(format!("diffctx-ignore-{}.tmp", std::process::id()));
588
+ match std::fs::write(&path, diffctx_patterns.join("\n")) {
589
+ Ok(()) => Some(path),
590
+ Err(_) => None,
591
+ }
592
+ };
593
+
594
+ let mut args: Vec<String> = vec!["check-ignore".into(), "--no-index".into()];
595
+ if let Some(ref path) = temp_excludes {
596
+ args.insert(0, format!("core.excludesFile={}", path.display()));
597
+ args.insert(0, "-c".into());
598
+ }
599
+ args.push("--".into());
600
+ args.extend(rel_paths.iter().cloned());
601
+ let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
602
+
603
+ let result = (|| -> Result<FxHashSet<String>> {
604
+ let mut cmd = Command::new("git");
605
+ cmd.arg("-C")
606
+ .arg(repo_root)
607
+ .args(&arg_refs)
608
+ .stdout(Stdio::piped())
609
+ .stderr(Stdio::piped());
610
+ let child = cmd.spawn()?;
611
+ let output = wait_with_timeout(child, Duration::from_secs(git_timeout()), &arg_refs)?;
612
+ // Exit code 1 from `check-ignore` means "none of the given paths are
613
+ // ignored" — not a failure. Any other non-zero code is a real error.
614
+ if !output.status.success() && output.status.code() != Some(1) {
615
+ let stderr = String::from_utf8_lossy(&output.stderr);
616
+ return Err(GitError::CommandFailed(format!(
617
+ "git check-ignore failed: {}",
618
+ stderr.trim()
619
+ )));
620
+ }
621
+ let stdout = String::from_utf8_lossy(&output.stdout);
622
+ Ok(stdout.lines().map(unquote_c_style).collect())
623
+ })();
624
+
625
+ if let Some(path) = temp_excludes {
626
+ let _ = std::fs::remove_file(path);
627
+ }
628
+
629
+ result.unwrap_or_default()
630
+ }
631
+
503
632
  pub struct CatFileBatch {
504
633
  repo_root: PathBuf,
505
634
  child: Option<Child>,
@@ -54,8 +54,8 @@ impl FragmentationStrategy for MarkdownStrategy {
54
54
  let total_lines = lines.len() as u32;
55
55
  let mut fragments: Vec<Fragment> = Vec::new();
56
56
 
57
- for (idx, &(start_line, level, _)) in headings.iter().enumerate() {
58
- let end_line = find_section_end(&headings, idx, level, total_lines);
57
+ for (idx, &(start_line, _level, _)) in headings.iter().enumerate() {
58
+ let end_line = find_section_end(&headings, idx, total_lines);
59
59
  if end_line < start_line {
60
60
  continue;
61
61
  }
@@ -90,16 +90,57 @@ impl FragmentationStrategy for MarkdownStrategy {
90
90
  }
91
91
  }
92
92
 
93
- fn find_section_end(
94
- headings: &[(u32, u32, String)],
95
- idx: usize,
96
- level: u32,
97
- total_lines: u32,
98
- ) -> u32 {
99
- for &(next_line, next_level, _) in &headings[idx + 1..] {
100
- if next_level <= level {
101
- return next_line - 1;
102
- }
93
+ /// A heading's own fragment ends at the very next heading, regardless of
94
+ /// level not at the next heading of the *same or higher* level. The old
95
+ /// same-or-higher rule made a heading's span include every nested
96
+ /// subsection beneath it, so a lone top-level `#` heading (the common case:
97
+ /// one H1 title, many `##`/`###` children) spanned the entire rest of the
98
+ /// document. A one-line change in the H1's own preamble then flagged that
99
+ /// all-encompassing fragment as `role: "changed"`, dumping the whole file
100
+ /// instead of just the touched paragraph (#91).
101
+ fn find_section_end(headings: &[(u32, u32, String)], idx: usize, total_lines: u32) -> u32 {
102
+ match headings.get(idx + 1) {
103
+ Some(&(next_line, _, _)) => next_line - 1,
104
+ None => total_lines,
105
+ }
106
+ }
107
+
108
+ #[cfg(test)]
109
+ mod tests {
110
+ use super::*;
111
+
112
+ /// Regression for #91: a lone H1 title followed only by `##` children
113
+ /// must not produce a single fragment spanning the whole document — the
114
+ /// H1's own fragment is just its direct preamble, up to the first child.
115
+ #[test]
116
+ fn h1_fragment_stops_at_first_child_heading_not_eof() {
117
+ let content =
118
+ "# Title\n\nPreamble line.\n\n## Section One\nBody one.\n\n## Section Two\nBody two.\n";
119
+ let fragments = MarkdownStrategy.fragment(Arc::from("doc.md"), content);
120
+
121
+ let title = fragments
122
+ .iter()
123
+ .find(|f| f.content.starts_with("# Title"))
124
+ .expect("Title fragment present");
125
+ assert_eq!(title.id.start_line, 1);
126
+ assert_eq!(
127
+ title.id.end_line, 4,
128
+ "H1 fragment should stop before '## Section One', not run to EOF"
129
+ );
130
+ }
131
+
132
+ #[test]
133
+ fn sibling_headings_still_each_get_their_own_fragment() {
134
+ let content =
135
+ "## Section One\nBody one.\n\n## Section Two\nBody two.\n### Nested\nNested body.\n";
136
+ let fragments = MarkdownStrategy.fragment(Arc::from("doc.md"), content);
137
+ assert_eq!(fragments.len(), 3);
138
+
139
+ let nested = fragments
140
+ .iter()
141
+ .find(|f| f.content.starts_with("### Nested"))
142
+ .expect("Nested fragment present");
143
+ assert_eq!(nested.id.start_line, 6);
144
+ assert_eq!(nested.id.end_line, 8);
103
145
  }
104
- total_lines
105
146
  }
@@ -131,6 +131,13 @@ pub fn compute_scored_state(
131
131
  return Ok(empty_scored_state(root_dir));
132
132
  }
133
133
 
134
+ let ignored_rel_paths = resolve_ignored_paths(&root_dir, &hunks);
135
+ hunks.retain(|h| !is_ignored_path(&root_dir, Path::new(&*h.path), &ignored_rel_paths));
136
+
137
+ if hunks.is_empty() {
138
+ return Ok(empty_scored_state(root_dir));
139
+ }
140
+
134
141
  let diff_text = git::get_diff_text(&root_dir, diff_range)?;
135
142
 
136
143
  let mut changed_files = git::get_changed_files(&root_dir, diff_range)?;
@@ -152,7 +159,9 @@ pub fn compute_scored_state(
152
159
  .into_iter()
153
160
  .filter(|f| {
154
161
  let resolved = f.canonicalize().unwrap_or_else(|_| f.clone());
155
- !excluded.contains(&resolved) && !is_secret_path(f)
162
+ !excluded.contains(&resolved)
163
+ && !is_secret_path(f)
164
+ && !is_ignored_path(&root_dir, f, &ignored_rel_paths)
156
165
  })
157
166
  .collect();
158
167
 
@@ -494,6 +503,31 @@ fn is_secret_path(path: &Path) -> bool {
494
503
  )
495
504
  }
496
505
 
506
+ fn rel_path_string(root_dir: &Path, path: &Path) -> Option<String> {
507
+ path.strip_prefix(root_dir)
508
+ .ok()
509
+ .map(|p| p.to_string_lossy().replace('\\', "/"))
510
+ }
511
+
512
+ /// Resolves `.gitignore` / `.diffctx/ignore` exclusions (#85) for every path
513
+ /// touched by `hunks`, in one batched git call. diff mode previously only
514
+ /// ever excluded a hardcoded set of secret-like filenames (`is_secret_path`)
515
+ /// — a file a user explicitly excluded via `.diffctx/ignore` still had its
516
+ /// changed content surfaced in full.
517
+ fn resolve_ignored_paths(root_dir: &Path, hunks: &[crate::types::DiffHunk]) -> FxHashSet<String> {
518
+ let rel_paths: Vec<String> = hunks
519
+ .iter()
520
+ .filter_map(|h| rel_path_string(root_dir, Path::new(&*h.path)))
521
+ .collect();
522
+ git::find_ignored_paths(root_dir, &rel_paths)
523
+ }
524
+
525
+ fn is_ignored_path(root_dir: &Path, path: &Path, ignored_rel_paths: &FxHashSet<String>) -> bool {
526
+ rel_path_string(root_dir, path)
527
+ .map(|rel| ignored_rel_paths.contains(&rel))
528
+ .unwrap_or(false)
529
+ }
530
+
497
531
  fn build_diff_context_full(
498
532
  root_dir: &Path,
499
533
  diff_range: Option<&str>,
@@ -513,8 +547,14 @@ fn build_diff_context_full(
513
547
  if hunks.is_empty() {
514
548
  return Ok(empty_output(&root_dir));
515
549
  }
550
+ let ignored_rel_paths = resolve_ignored_paths(&root_dir, &hunks);
551
+ hunks.retain(|h| !is_ignored_path(&root_dir, Path::new(&*h.path), &ignored_rel_paths));
552
+ if hunks.is_empty() {
553
+ return Ok(empty_output(&root_dir));
554
+ }
516
555
  let mut changed_files = git::get_changed_files(&root_dir, diff_range)?;
517
- changed_files.retain(|f| !is_secret_path(f));
556
+ changed_files
557
+ .retain(|f| !is_secret_path(f) && !is_ignored_path(&root_dir, f, &ignored_rel_paths));
518
558
  if changed_files.is_empty() {
519
559
  return Ok(empty_output(&root_dir));
520
560
  }
@@ -5,7 +5,7 @@ requires = [ "maturin>=1.10,<1.15" ]
5
5
 
6
6
  [project]
7
7
  name = "diffctx"
8
- version = "1.10.1"
8
+ version = "1.10.2"
9
9
  description = "Export codebase structure and contents for AI/LLM context"
10
10
  readme = "README.md"
11
11
  keywords = [
@@ -24,17 +24,28 @@ class _Unset:
24
24
  _UNSET: _Unset = _Unset()
25
25
  _DIFF_SENTINEL = "__DIFFCTX_DIFF_BARE__"
26
26
 
27
+ # Set once at the top of parse_args(); read by _exit_error/_warn so every
28
+ # CLI-side validation message is branded like argparse's own native errors
29
+ # (`{prog}: error: ...`) instead of a bare, unbranded "Error: ...". Module
30
+ # state (rather than threading `prog` through every _validate_* signature)
31
+ # keeps this a same-session fix; see #89.
32
+ _current_prog: str = "diffctx"
33
+
27
34
 
28
35
  def _exit_error(message: str) -> NoReturn:
29
- print(f"Error: {message}", file=sys.stderr)
36
+ print(f"{_current_prog}: error: {message}", file=sys.stderr)
30
37
  sys.exit(1)
31
38
 
32
39
 
40
+ def _warn(message: str) -> None:
41
+ print(f"{_current_prog}: warning: {message}", file=sys.stderr)
42
+
43
+
33
44
  def _validate_max_depth(max_depth: int | None) -> None:
34
45
  if max_depth is not None and max_depth < 0:
35
46
  _exit_error(f"--max-depth must be non-negative, got {max_depth}")
36
47
  if max_depth == 0:
37
- print("Warning: --max-depth 0 produces empty tree (root only, no children)", file=sys.stderr)
48
+ _warn("--max-depth 0 produces empty tree (root only, no children)")
38
49
 
39
50
 
40
51
  def _validate_max_file_bytes(max_file_bytes: int, no_file_size_limit: bool) -> int | None:
@@ -196,6 +207,7 @@ class ParsedArgs:
196
207
  graph: GraphArgs | None = None
197
208
  extra_dirs: list[Path] | None = None
198
209
  extra_files: list[Path] | None = None
210
+ no_explicit_paths: bool = False
199
211
 
200
212
 
201
213
  DEFAULT_IGNORES_HELP = """
@@ -230,6 +242,8 @@ Examples:
230
242
  diffctx . --diff HEAD~1 Show context for last commit
231
243
  diffctx . -c Copy output to clipboard
232
244
  diffctx . --no-content Structure only, no file contents
245
+ diffctx graph . Build the project dependency graph (see: diffctx graph --help)
246
+ diffctx graph . --summary Print graph stats (cycles, hotspots, coupling)
233
247
 
234
248
  Output routing:
235
249
  Default: stdout
@@ -303,7 +317,7 @@ def _build_main_parser(prog: str = "diffctx", version: str = __version__) -> arg
303
317
  )
304
318
 
305
319
  parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {version}")
306
- parser.add_argument("paths", nargs="*", default=["."], help="Directories, files, or glob patterns to analyze")
320
+ parser.add_argument("paths", nargs="*", default=[], help="Directories, files, or glob patterns to analyze")
307
321
  parser.add_argument(
308
322
  "-f",
309
323
  "--format",
@@ -360,7 +374,11 @@ def _build_main_parser(prog: str = "diffctx", version: str = __version__) -> arg
360
374
  type=float,
361
375
  default=_UNSET,
362
376
  metavar="F",
363
- help="Minimum relevance to include a fragment (default: 0.12, typical 0-1; lower = more context, >1 keeps only changed code)",
377
+ help=(
378
+ "Minimum relevance to include a fragment (default: 0.12, typical range 0-1; "
379
+ "lower = more surrounding context. E.g. --tau 2 excludes nearly everything but "
380
+ "the changed code itself, since only directly-changed fragments score that high)"
381
+ ),
364
382
  )
365
383
  diff_group.add_argument(
366
384
  "--scoring",
@@ -393,7 +411,7 @@ def _warn_diff_only_flags(args: argparse.Namespace) -> None:
393
411
  used.append("--scoring")
394
412
  if used:
395
413
  flags = ", ".join(used)
396
- print(f"Warning: diff-mode flags ignored without --diff: {flags}", file=sys.stderr)
414
+ _warn(f"diff-mode flags ignored without --diff: {flags}")
397
415
 
398
416
 
399
417
  def _build_graph_parsed_args(args: argparse.Namespace) -> ParsedArgs:
@@ -477,10 +495,13 @@ def _build_tree_parsed_args(args: argparse.Namespace) -> ParsedArgs:
477
495
  full_diff=args.full,
478
496
  extra_dirs=extra_dirs,
479
497
  extra_files=extra_files,
498
+ no_explicit_paths=not args.paths,
480
499
  )
481
500
 
482
501
 
483
502
  def parse_args(argv: list[str] | None = None, *, prog: str = "diffctx", version: str = __version__) -> ParsedArgs:
503
+ global _current_prog
504
+ _current_prog = prog
484
505
  raw_args = sys.argv[1:] if argv is None else argv
485
506
 
486
507
  if raw_args and raw_args[0] == "graph":
@@ -108,9 +108,17 @@ def _process_ignore_line(line: str, rel: str) -> str:
108
108
  return ("!" + full) if neg else full
109
109
 
110
110
 
111
+ _DIFFCTX_IGNORE_RELPATH = f"{DIFFCTX_CONFIG_DIR}/{DIFFCTX_DIR_IGNORE}"
112
+
113
+
111
114
  def _collect_from_git(ignore_files: list[Path], root: Path, out: list[str]) -> None:
112
115
  for ignore_path in ignore_files:
113
- ignore_dir = ignore_path.parent
116
+ # .diffctx/ignore patterns are relative to the directory that
117
+ # *contains* .diffctx/, not to .diffctx/ itself.
118
+ if ignore_path.parent.name == DIFFCTX_CONFIG_DIR and ignore_path.name == DIFFCTX_DIR_IGNORE:
119
+ ignore_dir = ignore_path.parent.parent
120
+ else:
121
+ ignore_dir = ignore_path.parent
114
122
  rel = "" if ignore_dir == root else ignore_dir.relative_to(root).as_posix()
115
123
  for line in read_ignore_file(ignore_path):
116
124
  out.append(_process_ignore_line(line, rel))
@@ -146,9 +154,14 @@ def _aggregate_all_ignore_patterns(root: Path, ignore_filenames: list[str]) -> l
146
154
  def _find_ignore_files_via_git(root: Path, ignore_filenames: list[str]) -> list[Path] | None:
147
155
  import subprocess
148
156
 
157
+ # A bare filename pathspec (e.g. ".gitignore") only matches that exact
158
+ # path relative to `root` — it does NOT find nested occurrences. Use
159
+ # glob-magic pathspecs so nested .gitignore / .diffctx/ignore files are
160
+ # discovered the same way _collect_from_walk's os.walk fallback finds them.
161
+ pathspecs = [f":(glob)**/{name}" for name in ignore_filenames]
149
162
  try:
150
163
  result = subprocess.run(
151
- ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", *ignore_filenames],
164
+ ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", *pathspecs],
152
165
  cwd=root,
153
166
  capture_output=True,
154
167
  text=False,
@@ -315,7 +328,7 @@ def get_ignore_specs(
315
328
  patterns.extend(DEFAULT_IGNORE_PATTERNS)
316
329
 
317
330
  patterns.extend(_collect_parent_ignore_patterns(root_dir, [".gitignore"]))
318
- patterns.extend(_aggregate_all_ignore_patterns(root_dir, [".gitignore"]))
331
+ patterns.extend(_aggregate_all_ignore_patterns(root_dir, [".gitignore", _DIFFCTX_IGNORE_RELPATH]))
319
332
 
320
333
  if custom_ignore_file:
321
334
  patterns.extend(read_ignore_file(custom_ignore_file))
@@ -31,7 +31,7 @@ def _configure_windows_utf8() -> None:
31
31
  pass
32
32
 
33
33
 
34
- def _ensure_git_repo(root_dir: Path) -> None:
34
+ def _ensure_git_repo(root_dir: Path, prog: str) -> None:
35
35
  try:
36
36
  result = subprocess.run(
37
37
  ["git", "rev-parse", "--git-dir"],
@@ -42,18 +42,33 @@ def _ensure_git_repo(root_dir: Path) -> None:
42
42
  )
43
43
  except OSError as exc:
44
44
  print(
45
- f"diffctx: --diff requires git to be installed and on PATH ({exc}); install git or run without --diff.",
45
+ f"{prog}: --diff requires git to be installed and on PATH ({exc}); install git or run without --diff.",
46
46
  file=sys.stderr,
47
47
  )
48
48
  sys.exit(_EXIT_ENVIRONMENT)
49
49
  if result.returncode != 0:
50
50
  print(
51
- f"diffctx: --diff requires a git repository (cwd: {root_dir}); "
51
+ f"{prog}: --diff requires a git repository (cwd: {root_dir}); "
52
52
  "run inside a working tree or pass --diff <range> with a valid git context.",
53
53
  file=sys.stderr,
54
54
  )
55
55
  sys.exit(_EXIT_ENVIRONMENT)
56
56
 
57
+ head_result = subprocess.run(
58
+ ["git", "rev-parse", "--verify", "-q", "HEAD"],
59
+ cwd=str(root_dir),
60
+ stdout=subprocess.DEVNULL,
61
+ stderr=subprocess.DEVNULL,
62
+ check=False,
63
+ )
64
+ if head_result.returncode != 0:
65
+ print(
66
+ f"{prog}: --diff requires at least one commit (no HEAD in this repository yet); "
67
+ "commit first, or run without --diff to map the working tree.",
68
+ file=sys.stderr,
69
+ )
70
+ sys.exit(_EXIT_ENVIRONMENT)
71
+
57
72
 
58
73
  def _diff_result_is_empty(result: dict[str, Any]) -> bool:
59
74
  count = result.get("fragment_count")
@@ -65,21 +80,21 @@ def _diff_result_is_empty(result: dict[str, Any]) -> bool:
65
80
  return False
66
81
 
67
82
 
68
- def _warn_empty_diff_result(result: dict[str, Any]) -> None:
83
+ def _warn_empty_diff_result(result: dict[str, Any], prog: str) -> None:
69
84
  if _diff_result_is_empty(result):
70
85
  print(
71
- "diffctx: diff produced no semantic context "
86
+ f"{prog}: diff produced no semantic context "
72
87
  "(pure deletion, binary-only, or all files exceeded size cap); output empty.",
73
88
  file=sys.stderr,
74
89
  )
75
90
 
76
91
 
77
- def _build_diff_tree(args: ParsedArgs) -> dict[str, Any]:
92
+ def _build_diff_tree(args: ParsedArgs, prog: str) -> dict[str, Any]:
78
93
  from .diffctx import build_diff_context
79
94
 
80
95
  if not args.diff_range:
81
96
  raise RuntimeError("diff_range is required in diff mode")
82
- _ensure_git_repo(args.root_dir)
97
+ _ensure_git_repo(args.root_dir, prog)
83
98
  result = build_diff_context(
84
99
  root_dir=args.root_dir,
85
100
  diff_range=args.diff_range,
@@ -93,7 +108,7 @@ def _build_diff_tree(args: ParsedArgs) -> dict[str, Any]:
93
108
  whitelist_file=args.whitelist_file,
94
109
  scoring_mode=getattr(args, "scoring", "ego"),
95
110
  )
96
- _warn_empty_diff_result(result)
111
+ _warn_empty_diff_result(result, prog)
97
112
  return result
98
113
 
99
114
 
@@ -103,13 +118,19 @@ def _root_display_name(root_dir: Any) -> str:
103
118
 
104
119
 
105
120
  _LARGE_OUTPUT_WARN_BYTES = 10 * 1024 * 1024
121
+ # A bare `diffctx` with no path argument at all is the most common "just try
122
+ # it" first invocation — and the easiest to run somewhere unintended (e.g.
123
+ # /tmp, $HOME). Use a much lower bar for that specific case; an explicit path
124
+ # argument is a stronger signal the user knows what they're pointing at (#87).
125
+ _LARGE_OUTPUT_WARN_BYTES_NO_EXPLICIT_PATH = 1 * 1024 * 1024
106
126
 
107
127
 
108
128
  def _warn_if_output_oversized(output_content: str, args: ParsedArgs) -> None:
109
129
  if args.no_content or args.diff_range:
110
130
  return
131
+ threshold = _LARGE_OUTPUT_WARN_BYTES_NO_EXPLICIT_PATH if args.no_explicit_paths else _LARGE_OUTPUT_WARN_BYTES
111
132
  size_bytes = len(output_content.encode("utf-8"))
112
- if size_bytes < _LARGE_OUTPUT_WARN_BYTES:
133
+ if size_bytes < threshold:
113
134
  return
114
135
  mb = size_bytes / (1024 * 1024)
115
136
  print(
@@ -312,7 +333,7 @@ def _run(argv: list[str] | None = None, *, prog: str = "diffctx", version: str =
312
333
  sys.stdout.write(output_content)
313
334
  return
314
335
 
315
- directory_tree = _build_diff_tree(args) if args.diff_range else _build_standard_tree(args)
336
+ directory_tree = _build_diff_tree(args, prog) if args.diff_range else _build_standard_tree(args)
316
337
 
317
338
  output_content = tree_to_string(directory_tree, args.output_format)
318
339
  if not args.quiet:
@@ -1,9 +1,16 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import argparse
3
4
  import sys
4
5
 
5
6
 
6
7
  def main(prog: str = "diffctx-mcp", extra: str = "diffctx[mcp]") -> None:
8
+ parser = argparse.ArgumentParser(
9
+ prog=prog,
10
+ description="Run the diffctx MCP server (stdio transport) for editor/agent integration.",
11
+ )
12
+ parser.parse_args()
13
+
7
14
  try:
8
15
  from diffctx.mcp.server import run_server
9
16
  except ImportError:
@@ -0,0 +1 @@
1
+ __version__ = "1.10.2"
@@ -1 +0,0 @@
1
- __version__ = "1.10.1"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes