Skip to main content

_diffctx/
pipeline.rs

1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3use std::time::Instant;
4
5use anyhow::Result;
6use rayon::prelude::*;
7use rustc_hash::{FxHashMap, FxHashSet};
8
9use crate::candidate_files;
10use crate::config::budget::BUDGET;
11use crate::config::graph_filtering::GRAPH_FILTERING;
12use crate::config::limits::LIMITS;
13use crate::config::tokenization::TOKENIZATION;
14use crate::core::{compute_seed_weights, identify_core_fragments};
15use crate::discovery::{
16    BM25Discovery, DefaultDiscovery, DiscoveryContext, DiscoveryStrategy, EnsembleDiscovery,
17    TestFileDiscovery,
18};
19use crate::fragmentation::process_files_for_fragments;
20use crate::git::{self, CatFileBatch};
21use crate::mode::{DiscoveryKind, PipelineConfig, ScoringKind, ScoringMode};
22use crate::postpass;
23use crate::render::{self, DiffContextOutput};
24use crate::scoring::{BM25Scoring, EgoGraphScoring, PPRScoring, ScoringResult, ScoringStrategy};
25use crate::signatures::generate_signature_variants;
26use crate::tokenizer::count_tokens;
27use crate::types::{Fragment, FragmentId};
28use crate::utility::InformationNeed;
29
30/// Per-instance heavy-phase outputs cached for reuse across many
31/// (`tau`, `core_budget_fraction`) selection cells. The selection /
32/// post-pass / render pipeline then runs against this state cheaply.
33///
34/// All fields are owned, no shared external lifetimes; safe to move
35/// into a `pyclass` and hand back to Python.
36pub struct ScoredState {
37    pub root_dir: PathBuf,
38    pub config: PipelineConfig,
39    pub all_fragments: Vec<Fragment>,
40    pub core_ids: FxHashSet<FragmentId>,
41    /// Narrow stand-ins for the core fragments that have no signature variant,
42    /// keyed by the core they replace. Deliberately outside `all_fragments`:
43    /// they are budget fallbacks, not graph nodes or context candidates.
44    pub core_excerpts: FxHashMap<FragmentId, Fragment>,
45    pub scoring_result: ScoringResult,
46    pub needs: Vec<InformationNeed>,
47    pub changed_files: Vec<PathBuf>,
48    pub deleted_files: Vec<String>,
49    pub renamed_files: Vec<(String, String)>,
50    /// Lock files touched by the range, paths only: the dependency bump is
51    /// signal, the checksum churn is not (#112).
52    pub lockfile_changes: Vec<String>,
53    pub preferred_revs: Vec<String>,
54    pub commit_message: Option<String>,
55    pub heavy_latency_ms: HeavyLatencyMs,
56}
57
58#[derive(Default, Clone, Copy)]
59pub struct HeavyLatencyMs {
60    pub parse_changed: f64,
61    pub universe_walk: f64,
62    pub discovery: f64,
63    pub parse_discovered: f64,
64    pub tokenization: f64,
65    pub graph_build: f64,
66    pub scoring: f64,
67}
68
69pub fn build_diff_context(
70    root_dir: &Path,
71    diff_range: Option<&str>,
72    budget_tokens: Option<u32>,
73    alpha: f64,
74    tau: f64,
75    no_content: bool,
76    full: bool,
77    scoring_mode: ScoringMode,
78    timeout: u64,
79) -> Result<DiffContextOutput> {
80    if full {
81        return build_diff_context_full(root_dir, diff_range, no_content, timeout);
82    }
83    let state = compute_scored_state(root_dir, diff_range, alpha, scoring_mode, timeout)?;
84    if state.all_fragments.is_empty() {
85        return Ok(empty_output_from_state(&state));
86    }
87    Ok(select_with_params(&state, budget_tokens, tau, no_content))
88}
89
90/// Heavy phase: clone/parse/fragment/discover/tokenize/score. Independent
91/// of `tau`/`core_budget_fraction`. Designed to be computed ONCE per
92/// instance and reused across an arbitrary number of selection cells.
93pub fn compute_scored_state(
94    root_dir: &Path,
95    diff_range: Option<&str>,
96    alpha: f64,
97    scoring_mode: ScoringMode,
98    timeout: u64,
99) -> Result<ScoredState> {
100    git::set_git_timeout(timeout);
101    let root_dir = resolve_repo_root(root_dir)?;
102    if alpha <= 0.0 || alpha >= 1.0 {
103        anyhow::bail!("alpha must be in (0, 1), got {}", alpha);
104    }
105
106    let mut hunks = git::parse_diff(&root_dir, diff_range)?;
107
108    // Untracked files only matter when the diff includes the live working
109    // tree. `None` and the literal "HEAD" both mean that (the CLI resolves
110    // bare `--diff` to the string "HEAD" before reaching here) - a historical
111    // range like `HEAD~5..HEAD~3` does not include working-tree state.
112    let is_working_tree_diff = matches!(diff_range, None | Some("HEAD"));
113    let mut untracked_files: Vec<PathBuf> = Vec::new();
114    if is_working_tree_diff {
115        if let Ok(files) = git::get_untracked_files(&root_dir) {
116            for f in &files {
117                if let Ok(content) = std::fs::read_to_string(f) {
118                    let line_count = content.lines().count() as u32;
119                    if line_count > 0 {
120                        let path_str: Arc<str> = Arc::from(f.to_string_lossy().as_ref());
121                        hunks.push(crate::types::DiffHunk {
122                            path: path_str,
123                            new_start: 1,
124                            new_len: line_count,
125                            old_start: 0,
126                            old_len: 0,
127                        });
128                    }
129                }
130            }
131            untracked_files = files;
132        }
133    }
134
135    hunks.retain(|h| !is_secret_path(Path::new(&*h.path)));
136
137    if hunks.is_empty() {
138        return Ok(empty_scored_state_with_changes(root_dir, diff_range));
139    }
140
141    let ignored_rel_paths = resolve_ignored_paths(&root_dir, &hunks);
142    hunks.retain(|h| !is_ignored_path(&root_dir, Path::new(&*h.path), &ignored_rel_paths));
143
144    let mut lockfile_display: Vec<String> = hunks
145        .iter()
146        .filter(|h| is_lockfile_path(Path::new(&*h.path)))
147        .filter_map(|h| rel_path_string(&root_dir, Path::new(&*h.path)))
148        .collect();
149    lockfile_display.sort();
150    lockfile_display.dedup();
151    hunks.retain(|h| !is_lockfile_path(Path::new(&*h.path)));
152
153    if hunks.is_empty() {
154        let mut state = empty_scored_state_with_changes(root_dir, diff_range);
155        state.lockfile_changes = lockfile_display;
156        return Ok(state);
157    }
158
159    let diff_text = git::get_diff_text(&root_dir, diff_range)?;
160
161    let mut changed_files = git::get_changed_files(&root_dir, diff_range)?;
162    changed_files.extend(untracked_files);
163    if changed_files.is_empty() {
164        return Ok(empty_scored_state_with_changes(root_dir, diff_range));
165    }
166
167    let deleted_files = git::get_deleted_files(&root_dir, diff_range)?;
168    // Pure-rename old paths are gone from disk and cannot be fragmented; pure-rename new
169    // paths exist on HEAD and must remain candidates so seeds and discovery can find them.
170    let (renamed_old, _pure_rename_new) = git::get_renamed_paths(
171        &root_dir,
172        diff_range,
173        GRAPH_FILTERING.git_rename_similarity_threshold,
174    )?;
175    // Display lists for the output header: deletions and renames produce no
176    // fragments, but silently omitting them misrepresents the diff (a
177    // deletion-only commit used to render as a bare two-line skeleton).
178    let mut deleted_display: Vec<String> = deleted_files
179        .iter()
180        .map(|p| {
181            p.strip_prefix(&root_dir)
182                .unwrap_or(p)
183                .to_string_lossy()
184                .replace('\\', "/")
185        })
186        .collect();
187    deleted_display.sort();
188    let renamed_display = git::get_rename_pairs(&root_dir, diff_range).unwrap_or_default();
189    let excluded: FxHashSet<PathBuf> = deleted_files.into_iter().chain(renamed_old).collect();
190    let changed_files: Vec<PathBuf> = changed_files
191        .into_iter()
192        .filter(|f| {
193            let resolved = f.canonicalize().unwrap_or_else(|_| f.clone());
194            !excluded.contains(&resolved)
195                && !is_secret_path(f)
196                && !is_lockfile_path(f)
197                && !is_ignored_path(&root_dir, f, &ignored_rel_paths)
198        })
199        .collect();
200
201    let (base_rev, head_rev) = diff_range
202        .map(git::split_diff_range)
203        .unwrap_or((None, None));
204    let preferred_revs = build_preferred_revs(base_rev.as_deref(), head_rev.as_deref());
205    let commit_message = head_rev
206        .as_deref()
207        .and_then(|h| git::get_commit_message(&root_dir, h).ok())
208        .and_then(|m| {
209            m.lines()
210                .map(str::trim)
211                .find(|l| !l.is_empty())
212                .map(str::to_string)
213        });
214
215    let t0 = Instant::now();
216
217    let mut seen_frag_ids: FxHashSet<FragmentId> = FxHashSet::default();
218    let mut batch_reader = CatFileBatch::new(&root_dir)?;
219    let mut all_fragments = process_files_for_fragments(
220        &changed_files,
221        &root_dir,
222        &preferred_revs,
223        &mut seen_frag_ids,
224        Some(&mut batch_reader),
225        true,
226    );
227
228    let t_parse_changed = Instant::now();
229
230    let included_set: FxHashSet<PathBuf> = changed_files.iter().cloned().collect();
231    let all_candidate_files = candidate_files::collect_candidate_files(&root_dir, &included_set);
232
233    let t_universe = Instant::now();
234
235    let file_cache = build_file_cache(&all_candidate_files);
236    let mode = scoring_mode;
237    let mut config = PipelineConfig::from_mode(mode);
238    if let Ok(s) = std::env::var("DIFFCTX_OBJECTIVE") {
239        config.objective = crate::mode::ObjectiveMode::from_str(&s);
240    }
241
242    let mut expansion_concepts: FxHashSet<String> =
243        crate::types::extract_identifiers(&diff_text, TOKENIZATION.query_min_identifier_length)
244            .into_iter()
245            .collect();
246
247    if let Some(ref h) = head_rev {
248        if std::env::var("DIFFCTX_NO_COMMIT_SIGNAL").as_deref() != Ok("1") {
249            if let Ok(commit_msg) = git::get_commit_message(&root_dir, h) {
250                for ident in crate::types::extract_identifiers(
251                    &commit_msg,
252                    TOKENIZATION.query_min_identifier_length,
253                ) {
254                    expansion_concepts.insert(ident);
255                }
256            }
257        }
258    }
259
260    let discovery_ctx = DiscoveryContext {
261        root_dir: root_dir.clone(),
262        changed_files: changed_files.clone(),
263        all_candidates: all_candidate_files,
264        diff_text: diff_text.clone(),
265        expansion_concepts,
266        file_cache,
267        token_corpus: std::sync::OnceLock::new(),
268    };
269
270    let discovered_files = create_discovery(&config).discover(&discovery_ctx);
271    let discovered_files: Vec<PathBuf> = discovered_files
272        .into_iter()
273        .map(|p| candidate_files::normalize_path(&p, &root_dir))
274        .collect();
275
276    drop(discovery_ctx);
277
278    let t_discovery = Instant::now();
279
280    all_fragments.extend(process_files_for_fragments(
281        &discovered_files,
282        &root_dir,
283        &preferred_revs,
284        &mut seen_frag_ids,
285        Some(&mut batch_reader),
286        false,
287    ));
288
289    let t_parse_discovered = Instant::now();
290
291    assign_token_counts(&mut all_fragments);
292
293    let core_ids = identify_core_fragments(&hunks, &all_fragments);
294
295    let mut core_excerpts =
296        crate::excerpt::generate_core_excerpts(&all_fragments, &core_ids, &hunks);
297    assign_excerpt_token_counts(&mut core_excerpts);
298
299    let signature_frags = generate_signature_variants(&all_fragments);
300    let mut sig_frags = signature_frags;
301    assign_token_counts(&mut sig_frags);
302    all_fragments.extend(sig_frags);
303
304    let t_tokenization = Instant::now();
305
306    let seed_weights = compute_seed_weights(&hunks, &core_ids, &all_fragments);
307
308    let discovered_path_set: FxHashSet<Arc<str>> = discovered_files
309        .iter()
310        .map(|p| Arc::from(p.to_string_lossy().as_ref()))
311        .collect();
312
313    let strategy: Box<dyn ScoringStrategy> = match config.scoring {
314        ScoringKind::Ego => Box::new(EgoGraphScoring::new(config.ego_depth)),
315        ScoringKind::Ppr => Box::new(PPRScoring::new(
316            config.ppr_alpha,
317            config.low_relevance_filter,
318        )),
319        ScoringKind::Bm25 => Box::new(BM25Scoring),
320    };
321
322    let scoring_result = strategy.score_and_filter(
323        &all_fragments,
324        &core_ids,
325        &hunks,
326        Some(root_dir.as_path()),
327        Some(&seed_weights),
328        Some(&discovered_path_set),
329    );
330
331    let needs = crate::utility::needs::needs_from_diff(&all_fragments, &core_ids, &diff_text);
332
333    let t_done = Instant::now();
334    batch_reader.close();
335
336    let graph_build_ms = scoring_result.graph_build_ms;
337    let heavy_latency_ms = HeavyLatencyMs {
338        parse_changed: t_parse_changed.duration_since(t0).as_secs_f64() * 1000.0,
339        universe_walk: t_universe.duration_since(t_parse_changed).as_secs_f64() * 1000.0,
340        discovery: t_discovery.duration_since(t_universe).as_secs_f64() * 1000.0,
341        parse_discovered: t_parse_discovered.duration_since(t_discovery).as_secs_f64() * 1000.0,
342        tokenization: t_tokenization
343            .duration_since(t_parse_discovered)
344            .as_secs_f64()
345            * 1000.0,
346        graph_build: graph_build_ms,
347        scoring: (t_done.duration_since(t_tokenization).as_secs_f64() * 1000.0 - graph_build_ms)
348            .max(0.0),
349    };
350
351    tracing::debug!(
352        "diffctx heavy: parse_changed {:.3}s, universe {:.3}s, discovery {:.3}s, parse_discovered {:.3}s, tokenization {:.3}s, graph_build {:.3}s, scoring {:.3}s",
353        heavy_latency_ms.parse_changed / 1000.0,
354        heavy_latency_ms.universe_walk / 1000.0,
355        heavy_latency_ms.discovery / 1000.0,
356        heavy_latency_ms.parse_discovered / 1000.0,
357        heavy_latency_ms.tokenization / 1000.0,
358        heavy_latency_ms.graph_build / 1000.0,
359        heavy_latency_ms.scoring / 1000.0,
360    );
361
362    Ok(ScoredState {
363        root_dir,
364        config,
365        all_fragments,
366        core_ids,
367        core_excerpts,
368        scoring_result,
369        needs,
370        changed_files,
371        deleted_files: deleted_display,
372        renamed_files: renamed_display,
373        lockfile_changes: lockfile_display,
374        preferred_revs,
375        commit_message,
376        heavy_latency_ms,
377    })
378}
379
380/// Light phase: selection + 3 post-passes + render. Cheap. Re-runnable
381/// against the same `ScoredState` with different (`tau`, `core_budget_fraction`)
382/// to sweep a calibration grid without re-doing the heavy phase.
383///
384/// `core_budget_fraction` is read at the start via `selection().core_budget_fraction`
385/// — set the env var `DIFFCTX_OP_SELECTION_CORE_BUDGET_FRACTION` before
386/// calling to override per-cell.
387pub fn select_with_params(
388    state: &ScoredState,
389    budget_tokens: Option<u32>,
390    tau: f64,
391    no_content: bool,
392) -> DiffContextOutput {
393    let t_start = Instant::now();
394    let effective_budget = budget_tokens.unwrap_or_else(|| {
395        let core_tokens: u32 = state
396            .all_fragments
397            .iter()
398            .filter(|f| state.core_ids.contains(&f.id))
399            .map(|f| f.token_count.min(BUDGET.core_token_cap_per_fragment))
400            .sum();
401        let auto = (core_tokens as f64 * BUDGET.auto_multiplier) as u32;
402        auto.clamp(BUDGET.auto_min, BUDGET.auto_max)
403    });
404
405    let selection_result = match state.config.objective {
406        crate::mode::ObjectiveMode::BoltzmannModular => {
407            let beta = crate::utility::calibrate_beta(
408                &state.scoring_result.filtered_fragments,
409                &state.core_ids,
410                &state.scoring_result.rel_scores,
411                effective_budget,
412                crate::config::selection::boltzmann().calibration_tolerance,
413            );
414            tracing::debug!("diffctx: boltzmann beta calibrated to {:.6e}", beta);
415            crate::utility::boltzmann_select(
416                &state.scoring_result.filtered_fragments,
417                &state.core_ids,
418                &state.scoring_result.rel_scores,
419                effective_budget,
420                beta,
421            )
422        }
423        crate::mode::ObjectiveMode::Submodular => {
424            let file_importance =
425                crate::utility::compute_file_importance(&state.scoring_result.filtered_fragments);
426            crate::select::lazy_greedy_select(
427                state.scoring_result.filtered_fragments.clone(),
428                &state.core_ids,
429                &state.scoring_result.rel_scores,
430                &state.needs,
431                effective_budget,
432                tau,
433                Some(&file_importance),
434                Some(&state.core_excerpts),
435            )
436        }
437    };
438
439    let selection_iters = selection_result.greedy_iters;
440    let stopping_certificate = selection_result.stopping_certificate;
441    let mut selected = selection_result.selected;
442
443    postpass::coherence_post_pass(
444        &mut selected,
445        &state.scoring_result.filtered_fragments,
446        &state.scoring_result.graph,
447        effective_budget,
448    );
449
450    postpass::rescue_nontrivial_context(
451        &mut selected,
452        &state.all_fragments,
453        &state.scoring_result.rel_scores,
454        &state.core_ids,
455        effective_budget,
456    );
457
458    let used: u32 = selected.iter().map(|f| f.token_count).sum();
459    let remaining = effective_budget.saturating_sub(used);
460    let mut batch_reader = match CatFileBatch::new(&state.root_dir) {
461        Ok(r) => Some(r),
462        Err(_) => None,
463    };
464    postpass::ensure_changed_files_represented(
465        &mut selected,
466        &state.all_fragments,
467        &state.changed_files,
468        remaining,
469        &state.root_dir,
470        &state.preferred_revs,
471        batch_reader.as_mut(),
472        &state.core_ids,
473        &state.core_excerpts,
474    );
475    if let Some(mut r) = batch_reader {
476        r.close();
477    }
478
479    let select_ms = t_start.elapsed().as_secs_f64() * 1000.0;
480    let total_ms = state.heavy_latency_ms.parse_changed
481        + state.heavy_latency_ms.universe_walk
482        + state.heavy_latency_ms.discovery
483        + state.heavy_latency_ms.parse_discovered
484        + state.heavy_latency_ms.tokenization
485        + state.heavy_latency_ms.graph_build
486        + state.heavy_latency_ms.scoring
487        + select_ms;
488
489    let cap_stats = state.scoring_result.graph.cap_stats.clone();
490    let change = render::ChangeSummary {
491        commit_message: state.commit_message.clone(),
492        changed_files: state
493            .changed_files
494            .iter()
495            .map(|p| {
496                p.strip_prefix(&state.root_dir)
497                    .unwrap_or(p)
498                    .to_string_lossy()
499                    .replace('\\', "/")
500            })
501            .collect(),
502        deleted_files: state.deleted_files.clone(),
503        renamed_files: state.renamed_files.clone(),
504        lockfile_changes: state.lockfile_changes.clone(),
505    };
506    // An excerpt stands in for a core fragment, so it carries the change and
507    // has to render as `role: "changed"` — otherwise the substitution keeps the
508    // content but still loses the signal it exists to preserve.
509    let mut render_core_ids = state.core_ids.clone();
510    render_core_ids.extend(
511        selected
512            .iter()
513            .filter(|f| f.kind == crate::types::FragmentKind::Excerpt)
514            .map(|f| f.id.clone()),
515    );
516
517    let mut output = render::build_diff_context_output(
518        &state.root_dir,
519        &selected,
520        no_content,
521        &render_core_ids,
522        &state.scoring_result.rel_scores,
523        change,
524    );
525    output.latency = Some(render::LatencyBreakdown {
526        parse_changed_ms: state.heavy_latency_ms.parse_changed,
527        universe_walk_ms: state.heavy_latency_ms.universe_walk,
528        discovery_ms: state.heavy_latency_ms.discovery,
529        parse_discovered_ms: state.heavy_latency_ms.parse_discovered,
530        tokenization_ms: state.heavy_latency_ms.tokenization,
531        graph_build_ms: state.heavy_latency_ms.graph_build,
532        scoring_selection_ms: state.heavy_latency_ms.graph_build
533            + state.heavy_latency_ms.scoring
534            + select_ms,
535        total_ms,
536        scoring_ms: state.heavy_latency_ms.scoring,
537        selection_ms: select_ms,
538        candidate_count: state.scoring_result.filtered_fragments.len(),
539        edge_count: state.scoring_result.graph.edge_count(),
540        greedy_iters: selection_iters,
541        edges_before_cap: cap_stats.edges_before_cap,
542        edges_dropped_by_cap: cap_stats.edges_dropped_by_cap,
543        nodes_capped: cap_stats.nodes_capped,
544        max_out_edges_per_node: cap_stats.max_out_edges_per_node,
545        ppr_truncated: state.scoring_result.ppr_truncated,
546        stopping_certificate,
547        ppr_forward_pushes: state.scoring_result.ppr_forward_pushes,
548        ppr_backward_pushes: state.scoring_result.ppr_backward_pushes,
549        peak_rss_bytes: crate::peak_rss::peak_rss_bytes(),
550        edge_emissions_by_category: cap_stats
551            .emissions_by_category
552            .iter()
553            .map(|&(category, raw, deduped)| (category.as_str(), raw, deduped))
554            .collect(),
555    });
556    output
557}
558
559/// Special path for `--full` mode: bypass scoring entirely, return all
560/// changed-file fragments. Doesn't share the `ScoredState` plumbing.
561/// Private-key and keystore files must never reach LLM-bound diff context, even
562/// when they appear in the diff hunks — such material is never legitimate change
563/// context. Mirrors the Python tree-mode default ignores (`ignore.py`
564/// DEFAULT_IGNORE_PATTERNS). Matches by file name only, so public keys (`*.pub`)
565/// stay visible. `.env` files are intentionally NOT excluded here: a changed
566/// `.env` is legitimate change context (see the `*_env_file_change` cases).
567pub(crate) fn is_secret_path(path: &Path) -> bool {
568    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
569        return false;
570    };
571    if matches!(name, "id_rsa" | "id_dsa" | "id_ecdsa" | "id_ed25519") {
572        return true;
573    }
574    matches!(
575        path.extension().and_then(|e| e.to_str()),
576        Some("pem" | "key" | "pfx" | "p12" | "keystore" | "jks")
577    )
578}
579
580/// Lock files, mirroring the tree-mode `DEFAULT_IGNORE_PATTERNS` list in
581/// `src/diffctx/ignore.py`. Tree mode drops them outright; diff mode cannot,
582/// because a bumped dependency IS part of the change — but rendering the raw
583/// hunks costs thousands of tokens of checksums for a fact that fits on one
584/// line, so the paths are reported and the content is left out (#112).
585fn is_lockfile_path(path: &Path) -> bool {
586    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
587        return false;
588    };
589    matches!(
590        name,
591        "Cargo.lock"
592            | "package-lock.json"
593            | "npm-shrinkwrap.json"
594            | "yarn.lock"
595            | "pnpm-lock.yaml"
596            | "bun.lock"
597            | "bun.lockb"
598            | "deno.lock"
599            | "Pipfile.lock"
600            | "poetry.lock"
601            | "uv.lock"
602            | "pdm.lock"
603            | "composer.lock"
604            | "Gemfile.lock"
605            | "flake.lock"
606            | "go.sum"
607            | "mix.lock"
608            | "packages.lock.json"
609            | "gradle.lockfile"
610            | "Package.resolved"
611            | "cabal.project.freeze"
612    )
613}
614
615pub(crate) fn rel_path_string(root_dir: &Path, path: &Path) -> Option<String> {
616    path.strip_prefix(root_dir)
617        .ok()
618        .map(|p| p.to_string_lossy().replace('\\', "/"))
619}
620
621/// Resolves `.gitignore` / `.diffctx/ignore` exclusions (#85) for every path
622/// touched by `hunks`, in one batched git call. diff mode previously only
623/// ever excluded a hardcoded set of secret-like filenames (`is_secret_path`)
624/// — a file a user explicitly excluded via `.diffctx/ignore` still had its
625/// changed content surfaced in full.
626fn resolve_ignored_paths(root_dir: &Path, hunks: &[crate::types::DiffHunk]) -> FxHashSet<String> {
627    let rel_paths: Vec<String> = hunks
628        .iter()
629        .filter_map(|h| rel_path_string(root_dir, Path::new(&*h.path)))
630        .collect();
631    git::find_ignored_paths(root_dir, &rel_paths)
632}
633
634pub(crate) fn is_ignored_path(
635    root_dir: &Path,
636    path: &Path,
637    ignored_rel_paths: &FxHashSet<String>,
638) -> bool {
639    rel_path_string(root_dir, path)
640        .map(|rel| ignored_rel_paths.contains(&rel))
641        .unwrap_or(false)
642}
643
644/// The unified diff of `diff_range` as git prints it, minus the file sections
645/// diff mode never discloses: secret-like paths, ignored paths, and lock files
646/// (#112). Additive output for `--with-raw-diff` (#150) — it feeds no
647/// selection state, so selection is bit-identical with and without it.
648pub fn raw_diff_text(root_dir: &Path, diff_range: Option<&str>, timeout: u64) -> Result<String> {
649    git::set_git_timeout(timeout);
650    let root_dir = resolve_repo_root(root_dir)?;
651    let diff_text = git::get_diff_text(&root_dir, diff_range)?;
652    Ok(keep_disclosable_sections(&root_dir, &diff_text))
653}
654
655fn keep_disclosable_sections(root_dir: &Path, diff_text: &str) -> String {
656    // Two views over the same text. Analysis runs on terminator-free lines so
657    // the exact header comparisons below keep working; output is emitted from
658    // the raw slices, because the bundle is advertised as git's own patch and
659    // dropping the CR of a CRLF repository yields something `git apply`
660    // rejects.
661    let raw: Vec<&str> = diff_text.split_inclusive('\n').collect();
662    let lines: Vec<&str> = raw
663        .iter()
664        .map(|line| line.trim_end_matches('\n').trim_end_matches('\r'))
665        .collect();
666    let sections = split_diff_sections(root_dir, &lines);
667    let rel_paths: Vec<String> = sections
668        .iter()
669        .filter_map(|(path, _)| path.as_deref())
670        .filter_map(|path| rel_path_string(root_dir, path))
671        .collect();
672    let ignored_rel_paths = git::find_ignored_paths(root_dir, &rel_paths);
673
674    let mut kept: Vec<&str> = Vec::new();
675    for (path, range) in sections {
676        // A section whose path cannot be resolved inside the repository is
677        // dropped: the bundle must never widen what diff mode is willing to
678        // show, and an unattributable section cannot be policy-checked.
679        let Some(path) = path else {
680            continue;
681        };
682        if is_secret_path(&path)
683            || is_lockfile_path(&path)
684            || is_ignored_path(root_dir, &path, &ignored_rel_paths)
685        {
686            continue;
687        }
688        kept.extend_from_slice(&raw[range]);
689    }
690    if kept.is_empty() {
691        return String::new();
692    }
693    let mut text = kept.concat();
694    if !text.ends_with('\n') {
695        text.push('\n');
696    }
697    text
698}
699
700type DiffSection = (Option<PathBuf>, std::ops::Range<usize>);
701
702fn split_diff_sections(root_dir: &Path, lines: &[&str]) -> Vec<DiffSection> {
703    let starts: Vec<usize> = lines
704        .iter()
705        .enumerate()
706        .filter(|(_, line)| line.starts_with("diff --git "))
707        .map(|(index, _)| index)
708        .collect();
709    starts
710        .iter()
711        .enumerate()
712        .map(|(nth, &start)| {
713            let end = starts.get(nth + 1).copied().unwrap_or(lines.len());
714            (section_path(root_dir, &lines[start..end]), start..end)
715        })
716        .collect()
717}
718
719fn section_path(root_dir: &Path, section: &[&str]) -> Option<PathBuf> {
720    let mut old_path: Option<PathBuf> = None;
721    let mut new_path: Option<PathBuf> = None;
722    for line in section.iter().take_while(|line| !line.starts_with("@@")) {
723        match git::parse_path_line(line, root_dir) {
724            ("new", path) => new_path = path,
725            ("old", path) => old_path = path,
726            _ => {}
727        }
728    }
729    new_path
730        .or(old_path)
731        .or_else(|| pathless_section(root_dir, section))
732}
733
734/// Sections with no `---`/`+++` pair at all: pure renames, binary files,
735/// mode-only changes. A rename states its target outright; the rest carry the
736/// same path on both sides of the `diff --git` header, so only a symmetric
737/// header is attributable — `a/x b/y` without a rename line could equally be
738/// one path containing " b/", and an unattributable section is dropped.
739fn pathless_section(root_dir: &Path, section: &[&str]) -> Option<PathBuf> {
740    if let Some(quoted) = section
741        .iter()
742        .find_map(|line| line.strip_prefix("rename to "))
743    {
744        return contained_path(root_dir, &git::unquote_c_style(quoted.trim()));
745    }
746    let rest = section.first()?.strip_prefix("diff --git ")?;
747    let rel_path = rest.get("a/".len()..rest.find(" b/")?)?;
748    if rest != format!("a/{rel_path} b/{rel_path}") {
749        return None;
750    }
751    contained_path(root_dir, rel_path)
752}
753
754fn contained_path(root_dir: &Path, rel_path: &str) -> Option<PathBuf> {
755    let joined = root_dir.join(rel_path);
756    let resolved = joined.canonicalize().unwrap_or_else(|_| joined.clone());
757    let resolved_root = root_dir
758        .canonicalize()
759        .unwrap_or_else(|_| root_dir.to_path_buf());
760    resolved.starts_with(&resolved_root).then_some(joined)
761}
762
763fn resolve_repo_root(root_dir: &Path) -> Result<PathBuf> {
764    let root_dir = root_dir.canonicalize().unwrap_or_else(|e| {
765        tracing::debug!("canonicalize failed for '{}': {}", root_dir.display(), e);
766        root_dir.to_path_buf()
767    });
768    if !git::is_git_repo(&root_dir) {
769        anyhow::bail!("'{}' is not a git repository", root_dir.display());
770    }
771    Ok(git::find_toplevel(&root_dir).unwrap_or(root_dir))
772}
773
774fn build_diff_context_full(
775    root_dir: &Path,
776    diff_range: Option<&str>,
777    no_content: bool,
778    timeout: u64,
779) -> Result<DiffContextOutput> {
780    git::set_git_timeout(timeout);
781    let root_dir = resolve_repo_root(root_dir)?;
782    let mut hunks = git::parse_diff(&root_dir, diff_range)?;
783    hunks.retain(|h| !is_secret_path(Path::new(&*h.path)));
784    if hunks.is_empty() {
785        let (deleted, renamed) = deletion_rename_displays(&root_dir, diff_range);
786        let mut output = empty_output(&root_dir);
787        output.deleted_files = deleted;
788        output.renamed_files = renamed;
789        return Ok(output);
790    }
791    let ignored_rel_paths = resolve_ignored_paths(&root_dir, &hunks);
792    hunks.retain(|h| !is_ignored_path(&root_dir, Path::new(&*h.path), &ignored_rel_paths));
793    if hunks.is_empty() {
794        let (deleted, renamed) = deletion_rename_displays(&root_dir, diff_range);
795        let mut output = empty_output(&root_dir);
796        output.deleted_files = deleted;
797        output.renamed_files = renamed;
798        return Ok(output);
799    }
800    let mut changed_files = git::get_changed_files(&root_dir, diff_range)?;
801    changed_files
802        .retain(|f| !is_secret_path(f) && !is_ignored_path(&root_dir, f, &ignored_rel_paths));
803    if changed_files.is_empty() {
804        let (deleted, renamed) = deletion_rename_displays(&root_dir, diff_range);
805        let mut output = empty_output(&root_dir);
806        output.deleted_files = deleted;
807        output.renamed_files = renamed;
808        return Ok(output);
809    }
810    let (base_rev, head_rev) = diff_range
811        .map(git::split_diff_range)
812        .unwrap_or((None, None));
813    let preferred_revs = build_preferred_revs(base_rev.as_deref(), head_rev.as_deref());
814    let mut seen_frag_ids: FxHashSet<FragmentId> = FxHashSet::default();
815    let mut batch_reader = CatFileBatch::new(&root_dir)?;
816    let mut all_fragments = process_files_for_fragments(
817        &changed_files,
818        &root_dir,
819        &preferred_revs,
820        &mut seen_frag_ids,
821        Some(&mut batch_reader),
822        true,
823    );
824    assign_token_counts(&mut all_fragments);
825    let mut sig_frags = generate_signature_variants(&all_fragments);
826    assign_token_counts(&mut sig_frags);
827    all_fragments.extend(sig_frags);
828    changed_files.sort();
829    let core_ids = identify_core_fragments(&hunks, &all_fragments);
830    let selected = select_full_mode(&all_fragments, &changed_files);
831    batch_reader.close();
832    let commit_message = head_rev
833        .as_deref()
834        .and_then(|h| git::get_commit_message(&root_dir, h).ok())
835        .and_then(|m| {
836            m.lines()
837                .map(str::trim)
838                .find(|l| !l.is_empty())
839                .map(str::to_string)
840        });
841    let mut deleted_display: Vec<String> = git::get_deleted_files(&root_dir, diff_range)
842        .map(|set| {
843            set.iter()
844                .map(|p| {
845                    p.strip_prefix(&root_dir)
846                        .unwrap_or(p)
847                        .to_string_lossy()
848                        .replace('\\', "/")
849                })
850                .collect()
851        })
852        .unwrap_or_default();
853    deleted_display.sort();
854    let change = render::ChangeSummary {
855        commit_message,
856        changed_files: changed_files
857            .iter()
858            .map(|p| {
859                p.strip_prefix(&root_dir)
860                    .unwrap_or(p)
861                    .to_string_lossy()
862                    .replace('\\', "/")
863            })
864            .collect(),
865        deleted_files: deleted_display,
866        renamed_files: git::get_rename_pairs(&root_dir, diff_range).unwrap_or_default(),
867        // `--full` is the escape hatch that promises every fragment of the
868        // changed files, so it keeps lockfile content instead of diverting it.
869        lockfile_changes: Vec::new(),
870    };
871    Ok(render::build_diff_context_output(
872        &root_dir,
873        &selected,
874        no_content,
875        &core_ids,
876        &FxHashMap::default(),
877        change,
878    ))
879}
880
881fn deletion_rename_displays(
882    root_dir: &Path,
883    diff_range: Option<&str>,
884) -> (Vec<String>, Vec<(String, String)>) {
885    let mut deleted: Vec<String> = git::get_deleted_files(root_dir, diff_range)
886        .map(|set| {
887            set.iter()
888                .map(|p| {
889                    p.strip_prefix(root_dir)
890                        .unwrap_or(p)
891                        .to_string_lossy()
892                        .replace('\\', "/")
893                })
894                .collect()
895        })
896        .unwrap_or_default();
897    deleted.sort();
898    let renamed = git::get_rename_pairs(root_dir, diff_range).unwrap_or_default();
899    (deleted, renamed)
900}
901
902fn empty_scored_state_with_changes(root_dir: PathBuf, diff_range: Option<&str>) -> ScoredState {
903    let (deleted, renamed) = deletion_rename_displays(&root_dir, diff_range);
904    let mut state = empty_scored_state(root_dir);
905    state.deleted_files = deleted;
906    state.renamed_files = renamed;
907    state
908}
909
910fn empty_scored_state(root_dir: PathBuf) -> ScoredState {
911    let config = PipelineConfig::from_mode(ScoringMode::Ego);
912    ScoredState {
913        root_dir,
914        config,
915        all_fragments: Vec::new(),
916        core_ids: FxHashSet::default(),
917        core_excerpts: FxHashMap::default(),
918        lockfile_changes: Vec::new(),
919        scoring_result: ScoringResult {
920            rel_scores: FxHashMap::default(),
921            filtered_fragments: Vec::new(),
922            graph: crate::graph::Graph::new(),
923            graph_build_ms: 0.0,
924            ppr_truncated: false,
925            ppr_forward_pushes: 0,
926            ppr_backward_pushes: 0,
927        },
928        needs: Vec::new(),
929        changed_files: Vec::new(),
930        deleted_files: Vec::new(),
931        renamed_files: Vec::new(),
932        preferred_revs: Vec::new(),
933        commit_message: None,
934        heavy_latency_ms: HeavyLatencyMs::default(),
935    }
936}
937
938fn empty_output(root_dir: &Path) -> DiffContextOutput {
939    let resolved = root_dir
940        .canonicalize()
941        .unwrap_or_else(|_| root_dir.to_path_buf());
942    let name = resolved
943        .file_name()
944        .map(|n| n.to_string_lossy().to_string())
945        .unwrap_or_else(|| resolved.to_string_lossy().to_string());
946    DiffContextOutput {
947        name,
948        output_type: "diff_context".to_string(),
949        commit_message: None,
950        changed_files: Vec::new(),
951        deleted_files: Vec::new(),
952        renamed_files: Vec::new(),
953        lockfile_changes: Vec::new(),
954        fragment_count: 0,
955        fragments: Vec::new(),
956        latency: None,
957    }
958}
959
960/// A deletion/rename-only diff has no fragmentable content, but the file
961/// lists themselves ARE the change - emit them instead of a bare skeleton.
962/// pub(crate): the pybridge select_with_params empty branch must emit the
963/// same lists, or MCP/benchmark consumers lose them while the CLI keeps them.
964pub(crate) fn empty_output_from_state(state: &ScoredState) -> DiffContextOutput {
965    let mut output = empty_output(&state.root_dir);
966    output.commit_message = state.commit_message.clone();
967    output.deleted_files = state.deleted_files.clone();
968    output.renamed_files = state.renamed_files.clone();
969    output.lockfile_changes = state.lockfile_changes.clone();
970    output
971}
972
973fn build_preferred_revs(base_rev: Option<&str>, head_rev: Option<&str>) -> Vec<String> {
974    let mut revs = Vec::new();
975    if let Some(h) = head_rev {
976        revs.push(h.to_string());
977    }
978    if let Some(b) = base_rev {
979        if Some(b) != head_rev {
980            revs.push(b.to_string());
981        }
982    }
983    revs
984}
985
986fn create_discovery(config: &PipelineConfig) -> Box<dyn DiscoveryStrategy> {
987    match config.discovery {
988        DiscoveryKind::Ensemble => Box::new(EnsembleDiscovery::new(vec![
989            Box::new(DefaultDiscovery),
990            Box::new(TestFileDiscovery),
991            Box::new(BM25Discovery::new(config.bm25_top_k)),
992        ])),
993        DiscoveryKind::Default => Box::new(DefaultDiscovery),
994    }
995}
996
997fn build_file_cache(candidate_files: &[PathBuf]) -> FxHashMap<PathBuf, String> {
998    // Stream files one at a time to avoid materialising all content before the cap.
999    // Previous par_iter().collect() allocated the full eligible corpus into an
1000    // intermediate Vec before truncating — on repos with thousands of files this
1001    // caused peak memory far above max_cache_bytes.
1002    let mut sorted = candidate_files.to_vec();
1003    sorted.sort();
1004    let mut cache: FxHashMap<PathBuf, String> = FxHashMap::default();
1005    let mut cache_bytes = 0usize;
1006    for path in sorted {
1007        if cache_bytes > GRAPH_FILTERING.max_cache_bytes {
1008            break;
1009        }
1010        let Ok(meta) = path.metadata() else { continue };
1011        if meta.len() as usize > LIMITS.max_file_size {
1012            continue;
1013        }
1014        if let Ok(content) = std::fs::read_to_string(&path) {
1015            cache_bytes += content.len();
1016            cache.insert(path, content);
1017        }
1018    }
1019    cache
1020}
1021
1022fn assign_token_counts(fragments: &mut [Fragment]) {
1023    fragments.par_iter_mut().for_each(|frag| {
1024        if frag.token_count == 0 {
1025            frag.token_count = count_tokens(&frag.content) + LIMITS.overhead_per_fragment;
1026        }
1027    });
1028}
1029
1030fn assign_excerpt_token_counts(excerpts: &mut FxHashMap<FragmentId, Fragment>) {
1031    for frag in excerpts.values_mut() {
1032        if frag.token_count == 0 {
1033            frag.token_count = count_tokens(&frag.content) + LIMITS.overhead_per_fragment;
1034        }
1035    }
1036}
1037
1038fn select_full_mode(all_fragments: &[Fragment], changed_files: &[PathBuf]) -> Vec<Fragment> {
1039    let changed_paths: FxHashSet<String> = changed_files
1040        .iter()
1041        .map(|p| p.to_string_lossy().to_string())
1042        .collect();
1043    let mut selected: Vec<Fragment> = all_fragments
1044        .iter()
1045        .filter(|f| changed_paths.contains(f.path()))
1046        .cloned()
1047        .collect();
1048    selected.sort_by(|a, b| {
1049        a.path()
1050            .cmp(b.path())
1051            .then(a.start_line().cmp(&b.start_line()))
1052    });
1053    selected
1054}