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