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 = root_dir.canonicalize().unwrap_or_else(|e| {
102        tracing::debug!("canonicalize failed for '{}': {}", root_dir.display(), e);
103        root_dir.to_path_buf()
104    });
105
106    if !git::is_git_repo(&root_dir) {
107        anyhow::bail!("'{}' is not a git repository", root_dir.display());
108    }
109    let root_dir = git::find_toplevel(&root_dir).unwrap_or(root_dir);
110    if alpha <= 0.0 || alpha >= 1.0 {
111        anyhow::bail!("alpha must be in (0, 1), got {}", alpha);
112    }
113
114    let mut hunks = git::parse_diff(&root_dir, diff_range)?;
115
116    // Untracked files only matter when the diff includes the live working
117    // tree. `None` and the literal "HEAD" both mean that (the CLI resolves
118    // bare `--diff` to the string "HEAD" before reaching here) - a historical
119    // range like `HEAD~5..HEAD~3` does not include working-tree state.
120    let is_working_tree_diff = matches!(diff_range, None | Some("HEAD"));
121    let mut untracked_files: Vec<PathBuf> = Vec::new();
122    if is_working_tree_diff {
123        if let Ok(files) = git::get_untracked_files(&root_dir) {
124            for f in &files {
125                if let Ok(content) = std::fs::read_to_string(f) {
126                    let line_count = content.lines().count() as u32;
127                    if line_count > 0 {
128                        let path_str: Arc<str> = Arc::from(f.to_string_lossy().as_ref());
129                        hunks.push(crate::types::DiffHunk {
130                            path: path_str,
131                            new_start: 1,
132                            new_len: line_count,
133                            old_start: 0,
134                            old_len: 0,
135                        });
136                    }
137                }
138            }
139            untracked_files = files;
140        }
141    }
142
143    hunks.retain(|h| !is_secret_path(Path::new(&*h.path)));
144
145    if hunks.is_empty() {
146        return Ok(empty_scored_state_with_changes(root_dir, diff_range));
147    }
148
149    let ignored_rel_paths = resolve_ignored_paths(&root_dir, &hunks);
150    hunks.retain(|h| !is_ignored_path(&root_dir, Path::new(&*h.path), &ignored_rel_paths));
151
152    let mut lockfile_display: Vec<String> = hunks
153        .iter()
154        .filter(|h| is_lockfile_path(Path::new(&*h.path)))
155        .filter_map(|h| rel_path_string(&root_dir, Path::new(&*h.path)))
156        .collect();
157    lockfile_display.sort();
158    lockfile_display.dedup();
159    hunks.retain(|h| !is_lockfile_path(Path::new(&*h.path)));
160
161    if hunks.is_empty() {
162        let mut state = empty_scored_state_with_changes(root_dir, diff_range);
163        state.lockfile_changes = lockfile_display;
164        return Ok(state);
165    }
166
167    let diff_text = git::get_diff_text(&root_dir, diff_range)?;
168
169    let mut changed_files = git::get_changed_files(&root_dir, diff_range)?;
170    changed_files.extend(untracked_files);
171    if changed_files.is_empty() {
172        return Ok(empty_scored_state_with_changes(root_dir, diff_range));
173    }
174
175    let deleted_files = git::get_deleted_files(&root_dir, diff_range)?;
176    // Pure-rename old paths are gone from disk and cannot be fragmented; pure-rename new
177    // paths exist on HEAD and must remain candidates so seeds and discovery can find them.
178    let (renamed_old, _pure_rename_new) = git::get_renamed_paths(
179        &root_dir,
180        diff_range,
181        GRAPH_FILTERING.git_rename_similarity_threshold,
182    )?;
183    // Display lists for the output header: deletions and renames produce no
184    // fragments, but silently omitting them misrepresents the diff (a
185    // deletion-only commit used to render as a bare two-line skeleton).
186    let mut deleted_display: Vec<String> = deleted_files
187        .iter()
188        .map(|p| {
189            p.strip_prefix(&root_dir)
190                .unwrap_or(p)
191                .to_string_lossy()
192                .replace('\\', "/")
193        })
194        .collect();
195    deleted_display.sort();
196    let renamed_display = git::get_rename_pairs(&root_dir, diff_range).unwrap_or_default();
197    let excluded: FxHashSet<PathBuf> = deleted_files.into_iter().chain(renamed_old).collect();
198    let changed_files: Vec<PathBuf> = changed_files
199        .into_iter()
200        .filter(|f| {
201            let resolved = f.canonicalize().unwrap_or_else(|_| f.clone());
202            !excluded.contains(&resolved)
203                && !is_secret_path(f)
204                && !is_lockfile_path(f)
205                && !is_ignored_path(&root_dir, f, &ignored_rel_paths)
206        })
207        .collect();
208
209    let (base_rev, head_rev) = diff_range
210        .map(git::split_diff_range)
211        .unwrap_or((None, None));
212    let preferred_revs = build_preferred_revs(base_rev.as_deref(), head_rev.as_deref());
213    let commit_message = head_rev
214        .as_deref()
215        .and_then(|h| git::get_commit_message(&root_dir, h).ok())
216        .and_then(|m| {
217            m.lines()
218                .map(str::trim)
219                .find(|l| !l.is_empty())
220                .map(str::to_string)
221        });
222
223    let t0 = Instant::now();
224
225    let mut seen_frag_ids: FxHashSet<FragmentId> = FxHashSet::default();
226    let mut batch_reader = CatFileBatch::new(&root_dir)?;
227    let mut all_fragments = process_files_for_fragments(
228        &changed_files,
229        &root_dir,
230        &preferred_revs,
231        &mut seen_frag_ids,
232        Some(&mut batch_reader),
233        true,
234    );
235
236    let t_parse_changed = Instant::now();
237
238    let included_set: FxHashSet<PathBuf> = changed_files.iter().cloned().collect();
239    let all_candidate_files = candidate_files::collect_candidate_files(&root_dir, &included_set);
240
241    let t_universe = Instant::now();
242
243    let file_cache = build_file_cache(&all_candidate_files);
244    let mode = scoring_mode;
245    let mut config = PipelineConfig::from_mode(mode);
246    if let Ok(s) = std::env::var("DIFFCTX_OBJECTIVE") {
247        config.objective = crate::mode::ObjectiveMode::from_str(&s);
248    }
249
250    let mut expansion_concepts: FxHashSet<String> =
251        crate::types::extract_identifiers(&diff_text, TOKENIZATION.query_min_identifier_length)
252            .into_iter()
253            .collect();
254
255    if let Some(ref h) = head_rev {
256        if std::env::var("DIFFCTX_NO_COMMIT_SIGNAL").as_deref() != Ok("1") {
257            if let Ok(commit_msg) = git::get_commit_message(&root_dir, h) {
258                for ident in crate::types::extract_identifiers(
259                    &commit_msg,
260                    TOKENIZATION.query_min_identifier_length,
261                ) {
262                    expansion_concepts.insert(ident);
263                }
264            }
265        }
266    }
267
268    let discovery_ctx = DiscoveryContext {
269        root_dir: root_dir.clone(),
270        changed_files: changed_files.clone(),
271        all_candidates: all_candidate_files,
272        diff_text: diff_text.clone(),
273        expansion_concepts,
274        file_cache,
275        token_corpus: std::sync::OnceLock::new(),
276    };
277
278    let discovered_files = create_discovery(&config).discover(&discovery_ctx);
279    let discovered_files: Vec<PathBuf> = discovered_files
280        .into_iter()
281        .map(|p| candidate_files::normalize_path(&p, &root_dir))
282        .collect();
283
284    drop(discovery_ctx);
285
286    let t_discovery = Instant::now();
287
288    all_fragments.extend(process_files_for_fragments(
289        &discovered_files,
290        &root_dir,
291        &preferred_revs,
292        &mut seen_frag_ids,
293        Some(&mut batch_reader),
294        false,
295    ));
296
297    let t_parse_discovered = Instant::now();
298
299    assign_token_counts(&mut all_fragments);
300
301    let core_ids = identify_core_fragments(&hunks, &all_fragments);
302
303    let mut core_excerpts =
304        crate::excerpt::generate_core_excerpts(&all_fragments, &core_ids, &hunks);
305    assign_excerpt_token_counts(&mut core_excerpts);
306
307    let signature_frags = generate_signature_variants(&all_fragments);
308    let mut sig_frags = signature_frags;
309    assign_token_counts(&mut sig_frags);
310    all_fragments.extend(sig_frags);
311
312    let t_tokenization = Instant::now();
313
314    let seed_weights = compute_seed_weights(&hunks, &core_ids, &all_fragments);
315
316    let discovered_path_set: FxHashSet<Arc<str>> = discovered_files
317        .iter()
318        .map(|p| Arc::from(p.to_string_lossy().as_ref()))
319        .collect();
320
321    let strategy: Box<dyn ScoringStrategy> = match config.scoring {
322        ScoringKind::Ego => Box::new(EgoGraphScoring::new(config.ego_depth)),
323        ScoringKind::Ppr => Box::new(PPRScoring::new(
324            config.ppr_alpha,
325            config.low_relevance_filter,
326        )),
327        ScoringKind::Bm25 => Box::new(BM25Scoring),
328    };
329
330    let scoring_result = strategy.score_and_filter(
331        &all_fragments,
332        &core_ids,
333        &hunks,
334        Some(root_dir.as_path()),
335        Some(&seed_weights),
336        Some(&discovered_path_set),
337    );
338
339    let needs = crate::utility::needs::needs_from_diff(&all_fragments, &core_ids, &diff_text);
340
341    let t_done = Instant::now();
342    batch_reader.close();
343
344    let graph_build_ms = scoring_result.graph_build_ms;
345    let heavy_latency_ms = HeavyLatencyMs {
346        parse_changed: t_parse_changed.duration_since(t0).as_secs_f64() * 1000.0,
347        universe_walk: t_universe.duration_since(t_parse_changed).as_secs_f64() * 1000.0,
348        discovery: t_discovery.duration_since(t_universe).as_secs_f64() * 1000.0,
349        parse_discovered: t_parse_discovered.duration_since(t_discovery).as_secs_f64() * 1000.0,
350        tokenization: t_tokenization
351            .duration_since(t_parse_discovered)
352            .as_secs_f64()
353            * 1000.0,
354        graph_build: graph_build_ms,
355        scoring: (t_done.duration_since(t_tokenization).as_secs_f64() * 1000.0 - graph_build_ms)
356            .max(0.0),
357    };
358
359    tracing::debug!(
360        "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",
361        heavy_latency_ms.parse_changed / 1000.0,
362        heavy_latency_ms.universe_walk / 1000.0,
363        heavy_latency_ms.discovery / 1000.0,
364        heavy_latency_ms.parse_discovered / 1000.0,
365        heavy_latency_ms.tokenization / 1000.0,
366        heavy_latency_ms.graph_build / 1000.0,
367        heavy_latency_ms.scoring / 1000.0,
368    );
369
370    Ok(ScoredState {
371        root_dir,
372        config,
373        all_fragments,
374        core_ids,
375        core_excerpts,
376        scoring_result,
377        needs,
378        changed_files,
379        deleted_files: deleted_display,
380        renamed_files: renamed_display,
381        lockfile_changes: lockfile_display,
382        preferred_revs,
383        commit_message,
384        heavy_latency_ms,
385    })
386}
387
388/// Light phase: selection + 3 post-passes + render. Cheap. Re-runnable
389/// against the same `ScoredState` with different (`tau`, `core_budget_fraction`)
390/// to sweep a calibration grid without re-doing the heavy phase.
391///
392/// `core_budget_fraction` is read at the start via `selection().core_budget_fraction`
393/// — set the env var `DIFFCTX_OP_SELECTION_CORE_BUDGET_FRACTION` before
394/// calling to override per-cell.
395pub fn select_with_params(
396    state: &ScoredState,
397    budget_tokens: Option<u32>,
398    tau: f64,
399    no_content: bool,
400) -> DiffContextOutput {
401    let t_start = Instant::now();
402    let effective_budget = budget_tokens.unwrap_or_else(|| {
403        let core_tokens: u32 = state
404            .all_fragments
405            .iter()
406            .filter(|f| state.core_ids.contains(&f.id))
407            .map(|f| f.token_count.min(BUDGET.core_token_cap_per_fragment))
408            .sum();
409        let auto = (core_tokens as f64 * BUDGET.auto_multiplier) as u32;
410        auto.clamp(BUDGET.auto_min, BUDGET.auto_max)
411    });
412
413    let selection_result = match state.config.objective {
414        crate::mode::ObjectiveMode::BoltzmannModular => {
415            let beta = crate::utility::calibrate_beta(
416                &state.scoring_result.filtered_fragments,
417                &state.core_ids,
418                &state.scoring_result.rel_scores,
419                effective_budget,
420                crate::config::selection::boltzmann().calibration_tolerance,
421            );
422            tracing::debug!("diffctx: boltzmann beta calibrated to {:.6e}", beta);
423            crate::utility::boltzmann_select(
424                &state.scoring_result.filtered_fragments,
425                &state.core_ids,
426                &state.scoring_result.rel_scores,
427                effective_budget,
428                beta,
429            )
430        }
431        crate::mode::ObjectiveMode::Submodular => {
432            let file_importance =
433                crate::utility::compute_file_importance(&state.scoring_result.filtered_fragments);
434            crate::select::lazy_greedy_select(
435                state.scoring_result.filtered_fragments.clone(),
436                &state.core_ids,
437                &state.scoring_result.rel_scores,
438                &state.needs,
439                effective_budget,
440                tau,
441                Some(&file_importance),
442                Some(&state.core_excerpts),
443            )
444        }
445    };
446
447    let selection_iters = selection_result.greedy_iters;
448    let stopping_certificate = selection_result.stopping_certificate;
449    let mut selected = selection_result.selected;
450
451    postpass::coherence_post_pass(
452        &mut selected,
453        &state.scoring_result.filtered_fragments,
454        &state.scoring_result.graph,
455        effective_budget,
456    );
457
458    postpass::rescue_nontrivial_context(
459        &mut selected,
460        &state.all_fragments,
461        &state.scoring_result.rel_scores,
462        &state.core_ids,
463        effective_budget,
464    );
465
466    let used: u32 = selected.iter().map(|f| f.token_count).sum();
467    let remaining = effective_budget.saturating_sub(used);
468    let mut batch_reader = match CatFileBatch::new(&state.root_dir) {
469        Ok(r) => Some(r),
470        Err(_) => None,
471    };
472    postpass::ensure_changed_files_represented(
473        &mut selected,
474        &state.all_fragments,
475        &state.changed_files,
476        remaining,
477        &state.root_dir,
478        &state.preferred_revs,
479        batch_reader.as_mut(),
480        &state.core_ids,
481        &state.core_excerpts,
482    );
483    if let Some(mut r) = batch_reader {
484        r.close();
485    }
486
487    let select_ms = t_start.elapsed().as_secs_f64() * 1000.0;
488    let total_ms = state.heavy_latency_ms.parse_changed
489        + state.heavy_latency_ms.universe_walk
490        + state.heavy_latency_ms.discovery
491        + state.heavy_latency_ms.parse_discovered
492        + state.heavy_latency_ms.tokenization
493        + state.heavy_latency_ms.graph_build
494        + state.heavy_latency_ms.scoring
495        + select_ms;
496
497    let cap_stats = state.scoring_result.graph.cap_stats.clone();
498    let change = render::ChangeSummary {
499        commit_message: state.commit_message.clone(),
500        changed_files: state
501            .changed_files
502            .iter()
503            .map(|p| {
504                p.strip_prefix(&state.root_dir)
505                    .unwrap_or(p)
506                    .to_string_lossy()
507                    .replace('\\', "/")
508            })
509            .collect(),
510        deleted_files: state.deleted_files.clone(),
511        renamed_files: state.renamed_files.clone(),
512        lockfile_changes: state.lockfile_changes.clone(),
513    };
514    // An excerpt stands in for a core fragment, so it carries the change and
515    // has to render as `role: "changed"` — otherwise the substitution keeps the
516    // content but still loses the signal it exists to preserve.
517    let mut render_core_ids = state.core_ids.clone();
518    render_core_ids.extend(
519        selected
520            .iter()
521            .filter(|f| f.kind == crate::types::FragmentKind::Excerpt)
522            .map(|f| f.id.clone()),
523    );
524
525    let mut output = render::build_diff_context_output(
526        &state.root_dir,
527        &selected,
528        no_content,
529        &render_core_ids,
530        &state.scoring_result.rel_scores,
531        change,
532    );
533    output.latency = Some(render::LatencyBreakdown {
534        parse_changed_ms: state.heavy_latency_ms.parse_changed,
535        universe_walk_ms: state.heavy_latency_ms.universe_walk,
536        discovery_ms: state.heavy_latency_ms.discovery,
537        parse_discovered_ms: state.heavy_latency_ms.parse_discovered,
538        tokenization_ms: state.heavy_latency_ms.tokenization,
539        graph_build_ms: state.heavy_latency_ms.graph_build,
540        scoring_selection_ms: state.heavy_latency_ms.graph_build
541            + state.heavy_latency_ms.scoring
542            + select_ms,
543        total_ms,
544        scoring_ms: state.heavy_latency_ms.scoring,
545        selection_ms: select_ms,
546        candidate_count: state.scoring_result.filtered_fragments.len(),
547        edge_count: state.scoring_result.graph.edge_count(),
548        greedy_iters: selection_iters,
549        edges_before_cap: cap_stats.edges_before_cap,
550        edges_dropped_by_cap: cap_stats.edges_dropped_by_cap,
551        nodes_capped: cap_stats.nodes_capped,
552        max_out_edges_per_node: cap_stats.max_out_edges_per_node,
553        ppr_truncated: state.scoring_result.ppr_truncated,
554        stopping_certificate,
555        ppr_forward_pushes: state.scoring_result.ppr_forward_pushes,
556        ppr_backward_pushes: state.scoring_result.ppr_backward_pushes,
557        peak_rss_bytes: crate::peak_rss::peak_rss_bytes(),
558        edge_emissions_by_category: cap_stats
559            .emissions_by_category
560            .iter()
561            .map(|&(category, raw, deduped)| (category.as_str(), raw, deduped))
562            .collect(),
563    });
564    output
565}
566
567/// Special path for `--full` mode: bypass scoring entirely, return all
568/// changed-file fragments. Doesn't share the `ScoredState` plumbing.
569/// Private-key and keystore files must never reach LLM-bound diff context, even
570/// when they appear in the diff hunks — such material is never legitimate change
571/// context. Mirrors the Python tree-mode default ignores (`ignore.py`
572/// DEFAULT_IGNORE_PATTERNS). Matches by file name only, so public keys (`*.pub`)
573/// stay visible. `.env` files are intentionally NOT excluded here: a changed
574/// `.env` is legitimate change context (see the `*_env_file_change` cases).
575fn is_secret_path(path: &Path) -> bool {
576    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
577        return false;
578    };
579    if matches!(name, "id_rsa" | "id_dsa" | "id_ecdsa" | "id_ed25519") {
580        return true;
581    }
582    matches!(
583        path.extension().and_then(|e| e.to_str()),
584        Some("pem" | "key" | "pfx" | "p12" | "keystore" | "jks")
585    )
586}
587
588/// Lock files, mirroring the tree-mode `DEFAULT_IGNORE_PATTERNS` list in
589/// `src/diffctx/ignore.py`. Tree mode drops them outright; diff mode cannot,
590/// because a bumped dependency IS part of the change — but rendering the raw
591/// hunks costs thousands of tokens of checksums for a fact that fits on one
592/// line, so the paths are reported and the content is left out (#112).
593fn is_lockfile_path(path: &Path) -> bool {
594    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
595        return false;
596    };
597    matches!(
598        name,
599        "Cargo.lock"
600            | "package-lock.json"
601            | "npm-shrinkwrap.json"
602            | "yarn.lock"
603            | "pnpm-lock.yaml"
604            | "bun.lock"
605            | "bun.lockb"
606            | "deno.lock"
607            | "Pipfile.lock"
608            | "poetry.lock"
609            | "uv.lock"
610            | "pdm.lock"
611            | "composer.lock"
612            | "Gemfile.lock"
613            | "flake.lock"
614            | "go.sum"
615            | "mix.lock"
616            | "packages.lock.json"
617            | "gradle.lockfile"
618            | "Package.resolved"
619            | "cabal.project.freeze"
620    )
621}
622
623fn rel_path_string(root_dir: &Path, path: &Path) -> Option<String> {
624    path.strip_prefix(root_dir)
625        .ok()
626        .map(|p| p.to_string_lossy().replace('\\', "/"))
627}
628
629/// Resolves `.gitignore` / `.diffctx/ignore` exclusions (#85) for every path
630/// touched by `hunks`, in one batched git call. diff mode previously only
631/// ever excluded a hardcoded set of secret-like filenames (`is_secret_path`)
632/// — a file a user explicitly excluded via `.diffctx/ignore` still had its
633/// changed content surfaced in full.
634fn resolve_ignored_paths(root_dir: &Path, hunks: &[crate::types::DiffHunk]) -> FxHashSet<String> {
635    let rel_paths: Vec<String> = hunks
636        .iter()
637        .filter_map(|h| rel_path_string(root_dir, Path::new(&*h.path)))
638        .collect();
639    git::find_ignored_paths(root_dir, &rel_paths)
640}
641
642fn is_ignored_path(root_dir: &Path, path: &Path, ignored_rel_paths: &FxHashSet<String>) -> bool {
643    rel_path_string(root_dir, path)
644        .map(|rel| ignored_rel_paths.contains(&rel))
645        .unwrap_or(false)
646}
647
648fn build_diff_context_full(
649    root_dir: &Path,
650    diff_range: Option<&str>,
651    no_content: bool,
652    timeout: u64,
653) -> Result<DiffContextOutput> {
654    git::set_git_timeout(timeout);
655    let root_dir = root_dir.canonicalize().unwrap_or_else(|e| {
656        tracing::debug!("canonicalize failed for '{}': {}", root_dir.display(), e);
657        root_dir.to_path_buf()
658    });
659    if !git::is_git_repo(&root_dir) {
660        anyhow::bail!("'{}' is not a git repository", root_dir.display());
661    }
662    let root_dir = git::find_toplevel(&root_dir).unwrap_or(root_dir);
663    let mut hunks = git::parse_diff(&root_dir, diff_range)?;
664    hunks.retain(|h| !is_secret_path(Path::new(&*h.path)));
665    if hunks.is_empty() {
666        let (deleted, renamed) = deletion_rename_displays(&root_dir, diff_range);
667        let mut output = empty_output(&root_dir);
668        output.deleted_files = deleted;
669        output.renamed_files = renamed;
670        return Ok(output);
671    }
672    let ignored_rel_paths = resolve_ignored_paths(&root_dir, &hunks);
673    hunks.retain(|h| !is_ignored_path(&root_dir, Path::new(&*h.path), &ignored_rel_paths));
674    if hunks.is_empty() {
675        let (deleted, renamed) = deletion_rename_displays(&root_dir, diff_range);
676        let mut output = empty_output(&root_dir);
677        output.deleted_files = deleted;
678        output.renamed_files = renamed;
679        return Ok(output);
680    }
681    let mut changed_files = git::get_changed_files(&root_dir, diff_range)?;
682    changed_files
683        .retain(|f| !is_secret_path(f) && !is_ignored_path(&root_dir, f, &ignored_rel_paths));
684    if changed_files.is_empty() {
685        let (deleted, renamed) = deletion_rename_displays(&root_dir, diff_range);
686        let mut output = empty_output(&root_dir);
687        output.deleted_files = deleted;
688        output.renamed_files = renamed;
689        return Ok(output);
690    }
691    let (base_rev, head_rev) = diff_range
692        .map(git::split_diff_range)
693        .unwrap_or((None, None));
694    let preferred_revs = build_preferred_revs(base_rev.as_deref(), head_rev.as_deref());
695    let mut seen_frag_ids: FxHashSet<FragmentId> = FxHashSet::default();
696    let mut batch_reader = CatFileBatch::new(&root_dir)?;
697    let mut all_fragments = process_files_for_fragments(
698        &changed_files,
699        &root_dir,
700        &preferred_revs,
701        &mut seen_frag_ids,
702        Some(&mut batch_reader),
703        true,
704    );
705    assign_token_counts(&mut all_fragments);
706    let mut sig_frags = generate_signature_variants(&all_fragments);
707    assign_token_counts(&mut sig_frags);
708    all_fragments.extend(sig_frags);
709    changed_files.sort();
710    let core_ids = identify_core_fragments(&hunks, &all_fragments);
711    let selected = select_full_mode(&all_fragments, &changed_files);
712    batch_reader.close();
713    let commit_message = head_rev
714        .as_deref()
715        .and_then(|h| git::get_commit_message(&root_dir, h).ok())
716        .and_then(|m| {
717            m.lines()
718                .map(str::trim)
719                .find(|l| !l.is_empty())
720                .map(str::to_string)
721        });
722    let mut deleted_display: Vec<String> = git::get_deleted_files(&root_dir, diff_range)
723        .map(|set| {
724            set.iter()
725                .map(|p| {
726                    p.strip_prefix(&root_dir)
727                        .unwrap_or(p)
728                        .to_string_lossy()
729                        .replace('\\', "/")
730                })
731                .collect()
732        })
733        .unwrap_or_default();
734    deleted_display.sort();
735    let change = render::ChangeSummary {
736        commit_message,
737        changed_files: changed_files
738            .iter()
739            .map(|p| {
740                p.strip_prefix(&root_dir)
741                    .unwrap_or(p)
742                    .to_string_lossy()
743                    .replace('\\', "/")
744            })
745            .collect(),
746        deleted_files: deleted_display,
747        renamed_files: git::get_rename_pairs(&root_dir, diff_range).unwrap_or_default(),
748        // `--full` is the escape hatch that promises every fragment of the
749        // changed files, so it keeps lockfile content instead of diverting it.
750        lockfile_changes: Vec::new(),
751    };
752    Ok(render::build_diff_context_output(
753        &root_dir,
754        &selected,
755        no_content,
756        &core_ids,
757        &FxHashMap::default(),
758        change,
759    ))
760}
761
762fn deletion_rename_displays(
763    root_dir: &Path,
764    diff_range: Option<&str>,
765) -> (Vec<String>, Vec<(String, String)>) {
766    let mut deleted: Vec<String> = git::get_deleted_files(root_dir, diff_range)
767        .map(|set| {
768            set.iter()
769                .map(|p| {
770                    p.strip_prefix(root_dir)
771                        .unwrap_or(p)
772                        .to_string_lossy()
773                        .replace('\\', "/")
774                })
775                .collect()
776        })
777        .unwrap_or_default();
778    deleted.sort();
779    let renamed = git::get_rename_pairs(root_dir, diff_range).unwrap_or_default();
780    (deleted, renamed)
781}
782
783fn empty_scored_state_with_changes(root_dir: PathBuf, diff_range: Option<&str>) -> ScoredState {
784    let (deleted, renamed) = deletion_rename_displays(&root_dir, diff_range);
785    let mut state = empty_scored_state(root_dir);
786    state.deleted_files = deleted;
787    state.renamed_files = renamed;
788    state
789}
790
791fn empty_scored_state(root_dir: PathBuf) -> ScoredState {
792    let config = PipelineConfig::from_mode(ScoringMode::Ego);
793    ScoredState {
794        root_dir,
795        config,
796        all_fragments: Vec::new(),
797        core_ids: FxHashSet::default(),
798        core_excerpts: FxHashMap::default(),
799        lockfile_changes: Vec::new(),
800        scoring_result: ScoringResult {
801            rel_scores: FxHashMap::default(),
802            filtered_fragments: Vec::new(),
803            graph: crate::graph::Graph::new(),
804            graph_build_ms: 0.0,
805            ppr_truncated: false,
806            ppr_forward_pushes: 0,
807            ppr_backward_pushes: 0,
808        },
809        needs: Vec::new(),
810        changed_files: Vec::new(),
811        deleted_files: Vec::new(),
812        renamed_files: Vec::new(),
813        preferred_revs: Vec::new(),
814        commit_message: None,
815        heavy_latency_ms: HeavyLatencyMs::default(),
816    }
817}
818
819fn empty_output(root_dir: &Path) -> DiffContextOutput {
820    let resolved = root_dir
821        .canonicalize()
822        .unwrap_or_else(|_| root_dir.to_path_buf());
823    let name = resolved
824        .file_name()
825        .map(|n| n.to_string_lossy().to_string())
826        .unwrap_or_else(|| resolved.to_string_lossy().to_string());
827    DiffContextOutput {
828        name,
829        output_type: "diff_context".to_string(),
830        commit_message: None,
831        changed_files: Vec::new(),
832        deleted_files: Vec::new(),
833        renamed_files: Vec::new(),
834        lockfile_changes: Vec::new(),
835        fragment_count: 0,
836        fragments: Vec::new(),
837        latency: None,
838    }
839}
840
841/// A deletion/rename-only diff has no fragmentable content, but the file
842/// lists themselves ARE the change - emit them instead of a bare skeleton.
843/// pub(crate): the pybridge select_with_params empty branch must emit the
844/// same lists, or MCP/benchmark consumers lose them while the CLI keeps them.
845pub(crate) fn empty_output_from_state(state: &ScoredState) -> DiffContextOutput {
846    let mut output = empty_output(&state.root_dir);
847    output.commit_message = state.commit_message.clone();
848    output.deleted_files = state.deleted_files.clone();
849    output.renamed_files = state.renamed_files.clone();
850    output.lockfile_changes = state.lockfile_changes.clone();
851    output
852}
853
854fn build_preferred_revs(base_rev: Option<&str>, head_rev: Option<&str>) -> Vec<String> {
855    let mut revs = Vec::new();
856    if let Some(h) = head_rev {
857        revs.push(h.to_string());
858    }
859    if let Some(b) = base_rev {
860        if Some(b) != head_rev {
861            revs.push(b.to_string());
862        }
863    }
864    revs
865}
866
867fn create_discovery(config: &PipelineConfig) -> Box<dyn DiscoveryStrategy> {
868    match config.discovery {
869        DiscoveryKind::Ensemble => Box::new(EnsembleDiscovery::new(vec![
870            Box::new(DefaultDiscovery),
871            Box::new(TestFileDiscovery),
872            Box::new(BM25Discovery::new(config.bm25_top_k)),
873        ])),
874        DiscoveryKind::Default => Box::new(DefaultDiscovery),
875    }
876}
877
878fn build_file_cache(candidate_files: &[PathBuf]) -> FxHashMap<PathBuf, String> {
879    // Stream files one at a time to avoid materialising all content before the cap.
880    // Previous par_iter().collect() allocated the full eligible corpus into an
881    // intermediate Vec before truncating — on repos with thousands of files this
882    // caused peak memory far above max_cache_bytes.
883    let mut sorted = candidate_files.to_vec();
884    sorted.sort();
885    let mut cache: FxHashMap<PathBuf, String> = FxHashMap::default();
886    let mut cache_bytes = 0usize;
887    for path in sorted {
888        if cache_bytes > GRAPH_FILTERING.max_cache_bytes {
889            break;
890        }
891        let Ok(meta) = path.metadata() else { continue };
892        if meta.len() as usize > LIMITS.max_file_size {
893            continue;
894        }
895        if let Ok(content) = std::fs::read_to_string(&path) {
896            cache_bytes += content.len();
897            cache.insert(path, content);
898        }
899    }
900    cache
901}
902
903fn assign_token_counts(fragments: &mut [Fragment]) {
904    fragments.par_iter_mut().for_each(|frag| {
905        if frag.token_count == 0 {
906            frag.token_count = count_tokens(&frag.content) + LIMITS.overhead_per_fragment;
907        }
908    });
909}
910
911fn assign_excerpt_token_counts(excerpts: &mut FxHashMap<FragmentId, Fragment>) {
912    for frag in excerpts.values_mut() {
913        if frag.token_count == 0 {
914            frag.token_count = count_tokens(&frag.content) + LIMITS.overhead_per_fragment;
915        }
916    }
917}
918
919fn select_full_mode(all_fragments: &[Fragment], changed_files: &[PathBuf]) -> Vec<Fragment> {
920    let changed_paths: FxHashSet<String> = changed_files
921        .iter()
922        .map(|p| p.to_string_lossy().to_string())
923        .collect();
924    let mut selected: Vec<Fragment> = all_fragments
925        .iter()
926        .filter(|f| changed_paths.contains(f.path()))
927        .cloned()
928        .collect();
929    selected.sort_by(|a, b| {
930        a.path()
931            .cmp(b.path())
932            .then(a.start_line().cmp(&b.start_line()))
933    });
934    selected
935}