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, ScoringMode};
22use crate::postpass;
23use crate::render::{self, DiffContextOutput};
24use crate::scoring::{ScoringResult, create_scoring_strategy};
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    /// Changed files withheld by ignore rules (.diffctx/ignore, gitignore).
54    /// Listed so the omission is visible: a reader of the output cannot
55    /// otherwise tell a file the diff never touched from one the tool
56    /// filtered, and #188 documents a reviewer concluding "no tests" from
57    /// exactly that silence.
58    pub ignored_changes: Vec<String>,
59    /// Changed files withheld by `.diffctx/ignore` or the secret-path
60    /// heuristics. Count only: both policies' point is that these paths stay
61    /// out of the artifact (#85, #214), but a bare number still tells the
62    /// reader the output is deliberately incomplete.
63    pub policy_excluded_count: usize,
64    /// Which discovery strategy first surfaced each discovered path.
65    ///
66    /// Read-only telemetry for the universe ceiling (#130): without it, a gold
67    /// file that never reaches the output is indistinguishable between "no
68    /// strategy found it" and "found but outranked", and those need different
69    /// fixes. Empty for the changed files themselves, which are not discovered.
70    pub discovery_source: FxHashMap<Arc<str>, &'static str>,
71    pub preferred_revs: Vec<String>,
72    pub commit_message: Option<String>,
73    pub heavy_latency_ms: HeavyLatencyMs,
74}
75
76#[derive(Default, Clone, Copy)]
77pub struct HeavyLatencyMs {
78    /// Everything before the heavy phase begins: hunk parse, untracked scan,
79    /// ignore resolution, and the `git diff` / `--name-only` / rename calls.
80    /// Outside every timer until #183 — which is why a 182s run reported 5.3s of
81    /// instrumented work with nothing to say where the rest went.
82    pub pre_phase: f64,
83    pub parse_changed: f64,
84    pub universe_walk: f64,
85    pub discovery: f64,
86    pub parse_discovered: f64,
87    pub tokenization: f64,
88    pub graph_build: f64,
89    pub scoring: f64,
90}
91
92pub fn build_diff_context(
93    root_dir: &Path,
94    diff_range: Option<&str>,
95    budget_tokens: Option<u32>,
96    alpha: f64,
97    tau: f64,
98    no_content: bool,
99    full: bool,
100    scoring_mode: ScoringMode,
101    timeout: u64,
102) -> Result<DiffContextOutput> {
103    if full {
104        return build_diff_context_full(root_dir, diff_range, no_content, timeout);
105    }
106    let state = compute_scored_state(root_dir, diff_range, alpha, scoring_mode, timeout)?;
107    if state.all_fragments.is_empty() {
108        return Ok(empty_output_from_state(&state));
109    }
110    Ok(select_with_params(&state, budget_tokens, tau, no_content))
111}
112
113/// `--mode locate` (#126): same heavy phase and the SAME selection as pack
114/// mode, rendered as a ranked navigation list with provenance reasons and no
115/// source bodies.
116pub fn build_diff_context_locate(
117    root_dir: &Path,
118    diff_range: Option<&str>,
119    budget_tokens: Option<u32>,
120    alpha: f64,
121    tau: f64,
122    scoring_mode: ScoringMode,
123    timeout: u64,
124) -> Result<crate::locate::LocateOutput> {
125    let state = compute_scored_state(root_dir, diff_range, alpha, scoring_mode, timeout)?;
126    let outcome = if state.all_fragments.is_empty() {
127        SelectionOutcome {
128            selected: Vec::new(),
129            effective_budget: budget_tokens.unwrap_or(0),
130            selection_iters: 0,
131            stopping_certificate: 0.0,
132            select_ms: 0.0,
133        }
134    } else {
135        run_selection(&state, budget_tokens, tau)
136    };
137    Ok(crate::locate::build_locate(&state, &outcome))
138}
139
140/// Line count for an untracked file, or `None` when it is not readable UTF-8
141/// text — the same rejection `read_to_string` gave, so binaries stay excluded.
142///
143/// Untracked files are scanned before any size filter applies
144/// (`max_changed_file_size` is enforced later, in fragmentation), so a dirty
145/// tree holding one multi-GB log used to allocate all of it here just to reach
146/// `.lines().count()`.
147///
148/// Counted over fixed byte chunks rather than by line. `BufReader::lines()`
149/// bounds nothing on its own: it allocates each line, and a minified bundle or
150/// a single-line JSON dump is one line hundreds of megabytes long — exactly the
151/// shape this was supposed to stop loading. The buffer is the only allocation
152/// that scales.
153///
154/// UTF-8 is validated as it streams, with the incomplete tail of one chunk
155/// carried into the next, so a multi-byte character split across a chunk
156/// boundary is not mistaken for the invalid byte that rejects a binary.
157///
158/// Counting rather than size-gating keeps this bit-identical: an oversized file
159/// still gets the same hunk it always did, and the count matches `str::lines`
160/// (both split on `\n` and neither counts a trailing newline as a line).
161fn count_text_lines(path: &Path) -> Option<u32> {
162    use std::io::Read;
163
164    const CHUNK: usize = 64 * 1024;
165
166    let mut file = std::fs::File::open(path).ok()?;
167    let mut buf = vec![0u8; CHUNK];
168    let mut carry: Vec<u8> = Vec::new();
169    let mut newlines: u32 = 0;
170    let mut last_byte: Option<u8> = None;
171
172    loop {
173        let read = file.read(&mut buf).ok()?;
174        if read == 0 {
175            break;
176        }
177        carry.extend_from_slice(&buf[..read]);
178        let valid_upto = match std::str::from_utf8(&carry) {
179            Ok(_) => carry.len(),
180            // A truncated character at the end of a chunk is not an error yet;
181            // anything else is a binary and rejects the file, as before.
182            Err(e) if e.error_len().is_none() => e.valid_up_to(),
183            Err(_) => return None,
184        };
185        newlines = newlines
186            .saturating_add(carry[..valid_upto].iter().filter(|b| **b == b'\n').count() as u32);
187        if let Some(&b) = carry[..valid_upto].last() {
188            last_byte = Some(b);
189        }
190        carry.drain(..valid_upto);
191    }
192
193    // Trailing bytes that never completed a character mean the file ends
194    // mid-sequence — invalid UTF-8, same verdict as `read_to_string`.
195    if !carry.is_empty() {
196        return None;
197    }
198    // `str::lines` does not count a trailing newline as starting a line, and
199    // counts a final unterminated line as one.
200    Some(match last_byte {
201        None => 0,
202        Some(b'\n') => newlines,
203        Some(_) => newlines.saturating_add(1),
204    })
205}
206
207/// Heavy phase: clone/parse/fragment/discover/tokenize/score. Independent
208/// of `tau`/`core_budget_fraction`. Designed to be computed ONCE per
209/// instance and reused across an arbitrary number of selection cells.
210pub fn compute_scored_state(
211    root_dir: &Path,
212    diff_range: Option<&str>,
213    alpha: f64,
214    scoring_mode: ScoringMode,
215    timeout: u64,
216) -> Result<ScoredState> {
217    let t_entry = Instant::now();
218    git::set_git_timeout(timeout);
219    let deadline = crate::deadline::Deadline::from_timeout_secs(timeout);
220    let root_dir = resolve_repo_root(root_dir)?;
221    if alpha <= 0.0 || alpha >= 1.0 {
222        anyhow::bail!("alpha must be in (0, 1), got {}", alpha);
223    }
224
225    let resolved = git::resolve_duration_range(&root_dir, diff_range)?;
226    let diff_range = resolved.range.as_deref();
227
228    let mut hunks = git::parse_diff(&root_dir, diff_range)?;
229
230    // Untracked files only matter when the diff includes the live working
231    // tree. `None` and the literal "HEAD" both mean that (the CLI resolves
232    // bare `--diff` to the string "HEAD" before reaching here) - a historical
233    // range like `HEAD~5..HEAD~3` does not include working-tree state. A
234    // duration window ends at "now", so it includes it too.
235    let is_working_tree_diff = resolved.from_duration || matches!(diff_range, None | Some("HEAD"));
236    let mut untracked_files: Vec<PathBuf> = Vec::new();
237    if is_working_tree_diff {
238        if let Ok(files) = git::get_untracked_files(&root_dir) {
239            for f in &files {
240                if let Some(line_count) = count_text_lines(f) {
241                    if line_count > 0 {
242                        let path_str: Arc<str> = Arc::from(f.to_string_lossy().as_ref());
243                        hunks.push(crate::types::DiffHunk {
244                            path: path_str,
245                            new_start: 1,
246                            new_len: line_count,
247                            old_start: 0,
248                            old_len: 0,
249                        });
250                    }
251                }
252            }
253            untracked_files = files;
254        }
255    }
256
257    // Secret-classified changed paths are withheld like `.diffctx/ignore`
258    // ones, and with the same disclosure stance: count only, never the path
259    // (#85, #188, #214). Disjoint from the policy set below by construction —
260    // these hunks are gone before resolve_ignored_paths runs.
261    let mut secret_excluded_paths: FxHashSet<String> = FxHashSet::default();
262    hunks.retain(|h| {
263        let p = Path::new(&*h.path);
264        if is_secret_path(p) {
265            if let Some(rel) = rel_path_string(&root_dir, p) {
266                secret_excluded_paths.insert(rel);
267            }
268            false
269        } else {
270            true
271        }
272    });
273
274    if hunks.is_empty() {
275        let mut state = empty_scored_state_with_changes(root_dir, diff_range);
276        state.policy_excluded_count = secret_excluded_paths.len();
277        return Ok(state);
278    }
279
280    let ignored_rel_paths = resolve_ignored_paths(&root_dir, &hunks);
281    // gitignore-excluded changed files are listed by path; `.diffctx/ignore`
282    // is a declared confidentiality policy, so its exclusions surface as a
283    // count only — re-publishing the very paths the user asked to withhold
284    // would undo the policy (#85), while total silence misreads as "the diff
285    // did not touch this" (#188).
286    let mut ignored_display: Vec<String> = Vec::new();
287    // Counted in files, not hunks: the writer says "N changed file(s)
288    // withheld", and a multi-hunk withheld file must not inflate it.
289    let mut policy_excluded_paths: FxHashSet<String> = FxHashSet::default();
290    for h in &hunks {
291        let p = Path::new(&*h.path);
292        let Some(rel) = rel_path_string(&root_dir, p) else {
293            continue;
294        };
295        match ignored_rel_paths.get(&rel) {
296            Some(git::IgnoreSource::Gitignore) => ignored_display.push(rel),
297            Some(git::IgnoreSource::DiffctxPolicy) => {
298                policy_excluded_paths.insert(rel);
299            }
300            None => {}
301        }
302    }
303    let policy_excluded = policy_excluded_paths.len() + secret_excluded_paths.len();
304    ignored_display.sort();
305    ignored_display.dedup();
306    hunks.retain(|h| !is_ignored_path(&root_dir, Path::new(&*h.path), &ignored_rel_paths));
307
308    let mut lockfile_display: Vec<String> = hunks
309        .iter()
310        .filter(|h| is_lockfile_path(Path::new(&*h.path)))
311        .filter_map(|h| rel_path_string(&root_dir, Path::new(&*h.path)))
312        .collect();
313    lockfile_display.sort();
314    lockfile_display.dedup();
315    hunks.retain(|h| !is_lockfile_path(Path::new(&*h.path)));
316
317    if hunks.is_empty() {
318        let mut state = empty_scored_state_with_changes(root_dir, diff_range);
319        state.lockfile_changes = lockfile_display;
320        state.ignored_changes = ignored_display;
321        state.policy_excluded_count = policy_excluded;
322        return Ok(state);
323    }
324
325    let diff_text = git::get_diff_text(&root_dir, diff_range)?;
326
327    let mut changed_files = git::get_changed_files(&root_dir, diff_range)?;
328    changed_files.extend(untracked_files);
329    if changed_files.is_empty() {
330        return Ok(empty_scored_state_with_changes(root_dir, diff_range));
331    }
332
333    let deleted_files = git::get_deleted_files(&root_dir, diff_range)?;
334    // Rename source paths are gone from disk and cannot be fragmented; the
335    // destinations exist on HEAD and stay candidates via the changed set below,
336    // so seeds and discovery still find them.
337    let renamed_old = git::get_renamed_paths(&root_dir, diff_range)?;
338    // Display lists for the output header: deletions and renames produce no
339    // fragments, but silently omitting them misrepresents the diff (a
340    // deletion-only commit used to render as a bare two-line skeleton).
341    let mut deleted_display: Vec<String> = deleted_files
342        .iter()
343        .map(|p| {
344            p.strip_prefix(&root_dir)
345                .unwrap_or(p)
346                .to_string_lossy()
347                .replace('\\', "/")
348        })
349        .collect();
350    deleted_display.sort();
351    let renamed_display = git::get_rename_pairs(&root_dir, diff_range).unwrap_or_default();
352    let excluded: FxHashSet<PathBuf> = deleted_files.into_iter().chain(renamed_old).collect();
353    let changed_files: Vec<PathBuf> = changed_files
354        .into_iter()
355        .filter(|f| {
356            let resolved = f.canonicalize().unwrap_or_else(|_| f.clone());
357            !excluded.contains(&resolved)
358                && !is_secret_path(f)
359                && !is_lockfile_path(f)
360                && !is_ignored_path(&root_dir, f, &ignored_rel_paths)
361        })
362        .collect();
363
364    let (base_rev, head_rev) = diff_range
365        .map(git::split_diff_range)
366        .unwrap_or((None, None));
367    let preferred_revs = build_preferred_revs(base_rev.as_deref(), head_rev.as_deref());
368    let commit_message = head_rev
369        .as_deref()
370        .and_then(|h| git::get_commit_message(&root_dir, h).ok())
371        .and_then(|m| {
372            m.lines()
373                .map(str::trim)
374                .find(|l| !l.is_empty())
375                .map(str::to_string)
376        });
377
378    let t0 = Instant::now();
379    let pre_phase_ms = t0.duration_since(t_entry).as_secs_f64() * 1000.0;
380
381    let mut seen_frag_ids: FxHashSet<FragmentId> = FxHashSet::default();
382    let mut batch_reader = CatFileBatch::new(&root_dir)?;
383    let mut all_fragments = process_files_for_fragments(
384        &changed_files,
385        &root_dir,
386        &preferred_revs,
387        &mut seen_frag_ids,
388        Some(&mut batch_reader),
389        true,
390    );
391
392    let t_parse_changed = Instant::now();
393
394    let included_set: FxHashSet<PathBuf> = changed_files.iter().cloned().collect();
395    let all_candidate_files = candidate_files::collect_candidate_files(&root_dir, &included_set);
396
397    let t_universe = Instant::now();
398
399    let file_cache = build_file_cache(&all_candidate_files);
400    let mode = scoring_mode;
401    let mut config = PipelineConfig::from_mode(mode);
402    if let Ok(s) = std::env::var("DIFFCTX_OBJECTIVE") {
403        config.objective = crate::mode::ObjectiveMode::from_str(&s);
404    }
405
406    let mut expansion_concepts: FxHashSet<String> =
407        crate::types::extract_identifiers(&diff_text, TOKENIZATION.query_min_identifier_length)
408            .into_iter()
409            .collect();
410
411    if let Some(ref h) = head_rev {
412        if std::env::var("DIFFCTX_NO_COMMIT_SIGNAL").as_deref() != Ok("1") {
413            if let Ok(commit_msg) = git::get_commit_message(&root_dir, h) {
414                for ident in crate::types::extract_identifiers(
415                    &commit_msg,
416                    TOKENIZATION.query_min_identifier_length,
417                ) {
418                    expansion_concepts.insert(ident);
419                }
420            }
421        }
422    }
423
424    let discovery_ctx = DiscoveryContext {
425        root_dir: root_dir.clone(),
426        changed_files: changed_files.clone(),
427        all_candidates: all_candidate_files,
428        diff_text: diff_text.clone(),
429        expansion_concepts,
430        file_cache,
431        token_corpus: std::sync::OnceLock::new(),
432    };
433
434    let (discovered_files, discovery_attribution) =
435        create_discovery(&config).discover_attributed(&discovery_ctx);
436    let discovered_files: Vec<PathBuf> = discovered_files
437        .into_iter()
438        .map(|p| candidate_files::normalize_path(&p, &root_dir))
439        .collect();
440    let discovery_source: FxHashMap<Arc<str>, &'static str> = discovery_attribution
441        .into_iter()
442        .map(|(path, source)| {
443            let normalized = candidate_files::normalize_path(&path, &root_dir);
444            (Arc::from(normalized.to_string_lossy().as_ref()), source)
445        })
446        .collect();
447
448    drop(discovery_ctx);
449
450    let t_discovery = Instant::now();
451
452    all_fragments.extend(process_files_for_fragments(
453        &discovered_files,
454        &root_dir,
455        &preferred_revs,
456        &mut seen_frag_ids,
457        Some(&mut batch_reader),
458        false,
459    ));
460
461    let t_parse_discovered = Instant::now();
462
463    assign_token_counts(&mut all_fragments);
464
465    let core_ids = identify_core_fragments(&hunks, &all_fragments);
466
467    let mut core_excerpts =
468        crate::excerpt::generate_core_excerpts(&all_fragments, &core_ids, &hunks);
469    assign_excerpt_token_counts(&mut core_excerpts);
470
471    let signature_frags = generate_signature_variants(&all_fragments);
472    let mut sig_frags = signature_frags;
473    assign_token_counts(&mut sig_frags);
474    all_fragments.extend(sig_frags);
475
476    let t_tokenization = Instant::now();
477
478    let seed_weights = compute_seed_weights(&hunks, &core_ids, &all_fragments);
479
480    let discovered_path_set: FxHashSet<Arc<str>> = discovered_files
481        .iter()
482        .map(|p| Arc::from(p.to_string_lossy().as_ref()))
483        .collect();
484
485    let strategy = create_scoring_strategy(&config);
486
487    let scoring_result = strategy.score_and_filter(
488        &all_fragments,
489        &core_ids,
490        &hunks,
491        Some(root_dir.as_path()),
492        Some(&seed_weights),
493        Some(&discovered_path_set),
494        deadline,
495    );
496
497    let needs = crate::utility::needs::needs_from_diff(&all_fragments, &core_ids, &diff_text);
498
499    let t_done = Instant::now();
500    batch_reader.close();
501
502    let graph_build_ms = scoring_result.graph_build_ms;
503    let heavy_latency_ms = HeavyLatencyMs {
504        pre_phase: pre_phase_ms,
505        parse_changed: t_parse_changed.duration_since(t0).as_secs_f64() * 1000.0,
506        universe_walk: t_universe.duration_since(t_parse_changed).as_secs_f64() * 1000.0,
507        discovery: t_discovery.duration_since(t_universe).as_secs_f64() * 1000.0,
508        parse_discovered: t_parse_discovered.duration_since(t_discovery).as_secs_f64() * 1000.0,
509        tokenization: t_tokenization
510            .duration_since(t_parse_discovered)
511            .as_secs_f64()
512            * 1000.0,
513        graph_build: graph_build_ms,
514        scoring: (t_done.duration_since(t_tokenization).as_secs_f64() * 1000.0 - graph_build_ms)
515            .max(0.0),
516    };
517
518    tracing::debug!(
519        "diffctx heavy: pre_phase {:.3}s, parse_changed {:.3}s, universe {:.3}s, discovery {:.3}s, parse_discovered {:.3}s, tokenization {:.3}s, graph_build {:.3}s, scoring {:.3}s",
520        heavy_latency_ms.pre_phase / 1000.0,
521        heavy_latency_ms.parse_changed / 1000.0,
522        heavy_latency_ms.universe_walk / 1000.0,
523        heavy_latency_ms.discovery / 1000.0,
524        heavy_latency_ms.parse_discovered / 1000.0,
525        heavy_latency_ms.tokenization / 1000.0,
526        heavy_latency_ms.graph_build / 1000.0,
527        heavy_latency_ms.scoring / 1000.0,
528    );
529
530    Ok(ScoredState {
531        root_dir,
532        config,
533        all_fragments,
534        core_ids,
535        core_excerpts,
536        scoring_result,
537        needs,
538        discovery_source,
539        changed_files,
540        deleted_files: deleted_display,
541        renamed_files: renamed_display,
542        lockfile_changes: lockfile_display,
543        ignored_changes: ignored_display,
544        policy_excluded_count: policy_excluded,
545        preferred_revs,
546        commit_message,
547        heavy_latency_ms,
548    })
549}
550
551pub struct SelectionOutcome {
552    pub selected: Vec<Fragment>,
553    pub effective_budget: u32,
554    pub selection_iters: usize,
555    pub stopping_certificate: f64,
556    pub select_ms: f64,
557}
558
559/// Selection + the 3 post-passes, shared verbatim by the pack renderer
560/// (`select_with_params`) and the locate renderer — extracting it is pure
561/// code motion so both modes select identically by construction.
562pub fn run_selection(
563    state: &ScoredState,
564    budget_tokens: Option<u32>,
565    tau: f64,
566) -> SelectionOutcome {
567    let t_start = Instant::now();
568    let effective_budget = budget_tokens.unwrap_or_else(|| {
569        let core_tokens: u32 = state
570            .all_fragments
571            .iter()
572            .filter(|f| state.core_ids.contains(&f.id))
573            .map(|f| f.token_count.min(BUDGET.core_token_cap_per_fragment))
574            .sum();
575        let auto = (core_tokens as f64 * BUDGET.auto_multiplier) as u32;
576        auto.clamp(BUDGET.auto_min, BUDGET.auto_max)
577    });
578
579    let selection_result = match state.config.objective {
580        crate::mode::ObjectiveMode::BoltzmannModular => {
581            let beta = crate::utility::calibrate_beta(
582                &state.scoring_result.filtered_fragments,
583                &state.core_ids,
584                &state.scoring_result.rel_scores,
585                effective_budget,
586                crate::config::selection::boltzmann().calibration_tolerance,
587            );
588            tracing::debug!("diffctx: boltzmann beta calibrated to {:.6e}", beta);
589            crate::utility::boltzmann_select(
590                &state.scoring_result.filtered_fragments,
591                &state.core_ids,
592                &state.scoring_result.rel_scores,
593                effective_budget,
594                beta,
595            )
596        }
597        crate::mode::ObjectiveMode::Submodular => {
598            let file_importance =
599                crate::utility::compute_file_importance(&state.scoring_result.filtered_fragments);
600            crate::select::lazy_greedy_select(
601                state.scoring_result.filtered_fragments.clone(),
602                &state.core_ids,
603                &state.scoring_result.rel_scores,
604                &state.needs,
605                effective_budget,
606                tau,
607                Some(&file_importance),
608                Some(&state.core_excerpts),
609                state.scoring_result.admissible_files.as_ref(),
610            )
611        }
612    };
613
614    let selection_iters = selection_result.greedy_iters;
615    let stopping_certificate = selection_result.stopping_certificate;
616    let mut selected = selection_result.selected;
617
618    postpass::coherence_post_pass(
619        &mut selected,
620        &state.scoring_result.filtered_fragments,
621        &state.scoring_result.graph,
622        effective_budget,
623    );
624
625    postpass::rescue_nontrivial_context(
626        &mut selected,
627        &state.all_fragments,
628        &state.scoring_result.rel_scores,
629        &state.core_ids,
630        effective_budget,
631    );
632
633    let used: u32 = selected.iter().map(|f| f.token_count).sum();
634    let remaining = effective_budget.saturating_sub(used);
635    let mut batch_reader = match CatFileBatch::new(&state.root_dir) {
636        Ok(r) => Some(r),
637        Err(_) => None,
638    };
639    postpass::ensure_changed_files_represented(
640        &mut selected,
641        &state.all_fragments,
642        &state.changed_files,
643        remaining,
644        &state.root_dir,
645        &state.preferred_revs,
646        batch_reader.as_mut(),
647        &state.core_ids,
648        &state.core_excerpts,
649    );
650    if let Some(mut r) = batch_reader {
651        r.close();
652    }
653
654    crate::provenance::maybe_dump(state, &selected);
655
656    let select_ms = t_start.elapsed().as_secs_f64() * 1000.0;
657    SelectionOutcome {
658        selected,
659        effective_budget,
660        selection_iters,
661        stopping_certificate,
662        select_ms,
663    }
664}
665
666/// Light phase: selection + 3 post-passes + render. Cheap. Re-runnable
667/// against the same `ScoredState` with different (`tau`, `core_budget_fraction`)
668/// to sweep a calibration grid without re-doing the heavy phase.
669///
670/// `core_budget_fraction` is read at the start via `selection().core_budget_fraction`
671/// — set the env var `DIFFCTX_OP_SELECTION_CORE_BUDGET_FRACTION` before
672/// calling to override per-cell.
673pub fn select_with_params(
674    state: &ScoredState,
675    budget_tokens: Option<u32>,
676    tau: f64,
677    no_content: bool,
678) -> DiffContextOutput {
679    let outcome = run_selection(state, budget_tokens, tau);
680    let selected = outcome.selected;
681    let selection_iters = outcome.selection_iters;
682    let stopping_certificate = outcome.stopping_certificate;
683    let select_ms = outcome.select_ms;
684
685    let total_ms = state.heavy_latency_ms.pre_phase
686        + state.heavy_latency_ms.parse_changed
687        + state.heavy_latency_ms.universe_walk
688        + state.heavy_latency_ms.discovery
689        + state.heavy_latency_ms.parse_discovered
690        + state.heavy_latency_ms.tokenization
691        + state.heavy_latency_ms.graph_build
692        + state.heavy_latency_ms.scoring
693        + select_ms;
694
695    let cap_stats = state.scoring_result.graph.cap_stats.clone();
696    let change = render::ChangeSummary {
697        commit_message: state.commit_message.clone(),
698        changed_files: state
699            .changed_files
700            .iter()
701            .map(|p| {
702                p.strip_prefix(&state.root_dir)
703                    .unwrap_or(p)
704                    .to_string_lossy()
705                    .replace('\\', "/")
706            })
707            .collect(),
708        deleted_files: state.deleted_files.clone(),
709        renamed_files: state.renamed_files.clone(),
710        lockfile_changes: state.lockfile_changes.clone(),
711        ignored_changes: state.ignored_changes.clone(),
712        policy_excluded_count: state.policy_excluded_count,
713    };
714    // An excerpt stands in for a core fragment, so it carries the change and
715    // has to render as `role: "changed"` — otherwise the substitution keeps the
716    // content but still loses the signal it exists to preserve.
717    let mut render_core_ids = state.core_ids.clone();
718    render_core_ids.extend(
719        selected
720            .iter()
721            .filter(|f| f.kind == crate::types::FragmentKind::Excerpt)
722            .map(|f| f.id.clone()),
723    );
724
725    let mut output = render::build_diff_context_output(
726        &state.root_dir,
727        &selected,
728        no_content,
729        &render_core_ids,
730        &state.scoring_result.rel_scores,
731        change,
732    );
733    tracing::debug!(
734        "diffctx selection: selection {:.3}s (incl. post-passes), total {:.3}s",
735        select_ms / 1000.0,
736        total_ms / 1000.0,
737    );
738    output.latency = Some(render::LatencyBreakdown {
739        pre_phase_ms: state.heavy_latency_ms.pre_phase,
740        parse_changed_ms: state.heavy_latency_ms.parse_changed,
741        universe_walk_ms: state.heavy_latency_ms.universe_walk,
742        discovery_ms: state.heavy_latency_ms.discovery,
743        parse_discovered_ms: state.heavy_latency_ms.parse_discovered,
744        tokenization_ms: state.heavy_latency_ms.tokenization,
745        graph_build_ms: state.heavy_latency_ms.graph_build,
746        scoring_selection_ms: state.heavy_latency_ms.graph_build
747            + state.heavy_latency_ms.scoring
748            + select_ms,
749        total_ms,
750        scoring_ms: state.heavy_latency_ms.scoring,
751        selection_ms: select_ms,
752        candidate_count: state.scoring_result.filtered_fragments.len(),
753        edge_count: state.scoring_result.graph.edge_count(),
754        greedy_iters: selection_iters,
755        edges_before_cap: cap_stats.edges_before_cap,
756        edges_dropped_by_cap: cap_stats.edges_dropped_by_cap,
757        nodes_capped: cap_stats.nodes_capped,
758        max_out_edges_per_node: cap_stats.max_out_edges_per_node,
759        ppr_truncated: state.scoring_result.ppr_truncated,
760        stopping_certificate,
761        ppr_forward_pushes: state.scoring_result.ppr_forward_pushes,
762        ppr_backward_pushes: state.scoring_result.ppr_backward_pushes,
763        peak_rss_bytes: crate::peak_rss::peak_rss_bytes(),
764        edge_emissions_by_category: cap_stats
765            .emissions_by_category
766            .iter()
767            .map(|&(category, raw, deduped)| (category.as_str(), raw, deduped))
768            .collect(),
769    });
770    output
771}
772
773/// Special path for `--full` mode: bypass scoring entirely, return all
774/// changed-file fragments. Doesn't share the `ScoredState` plumbing.
775/// Private-key and keystore files must never reach LLM-bound diff context, even
776/// when they appear in the diff hunks — such material is never legitimate change
777/// context. Mirrors the Python tree-mode default ignores (`ignore.py`
778/// DEFAULT_IGNORE_PATTERNS). Matches by file name only, so public keys (`*.pub`)
779/// stay visible. `.env` files are intentionally NOT excluded here: a changed
780/// `.env` is legitimate change context (see the `*_env_file_change` cases).
781pub(crate) fn is_secret_path(path: &Path) -> bool {
782    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
783        return false;
784    };
785    // Whole-name matches. `_sk` is the sealed-secret half of an SSH key pair
786    // written by `ssh-keygen -O`; `.netrc`/`credentials` carry passwords in
787    // plain text and are the shapes CI images most often leak.
788    if matches!(
789        name,
790        "id_rsa"
791            | "id_dsa"
792            | "id_ecdsa"
793            | "id_ed25519"
794            | "id_ed25519_sk"
795            | "id_ecdsa_sk"
796            | ".netrc"
797            | "_netrc"
798            | "credentials"
799            | ".npmrc"
800            | ".pypirc"
801    ) {
802        return true;
803    }
804    matches!(
805        path.extension().and_then(|e| e.to_str()),
806        // `.ppk` is PuTTY's private key, `.p8` Apple's signing key, `.asc` an
807        // armoured PGP export — all private-key containers the original list
808        // happened not to name.
809        Some("pem" | "key" | "pfx" | "p12" | "keystore" | "jks" | "ppk" | "p8" | "asc")
810    )
811}
812
813/// Lock files, mirroring the tree-mode `DEFAULT_IGNORE_PATTERNS` list in
814/// `src/diffctx/ignore.py`. Tree mode drops them outright; diff mode cannot,
815/// because a bumped dependency IS part of the change — but rendering the raw
816/// hunks costs thousands of tokens of checksums for a fact that fits on one
817/// line, so the paths are reported and the content is left out (#112).
818fn is_lockfile_path(path: &Path) -> bool {
819    let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
820        return false;
821    };
822    matches!(
823        name,
824        "Cargo.lock"
825            | "package-lock.json"
826            | "npm-shrinkwrap.json"
827            | "yarn.lock"
828            | "pnpm-lock.yaml"
829            | "bun.lock"
830            | "bun.lockb"
831            | "deno.lock"
832            | "Pipfile.lock"
833            | "poetry.lock"
834            | "uv.lock"
835            | "pdm.lock"
836            | "composer.lock"
837            | "Gemfile.lock"
838            | "flake.lock"
839            | "go.sum"
840            | "mix.lock"
841            | "packages.lock.json"
842            | "gradle.lockfile"
843            | "Package.resolved"
844            | "cabal.project.freeze"
845    )
846}
847
848pub(crate) fn rel_path_string(root_dir: &Path, path: &Path) -> Option<String> {
849    crate::paths::display_rel(root_dir, path)
850}
851
852/// Resolves `.gitignore` / `.diffctx/ignore` exclusions (#85) for every path
853/// touched by `hunks`, in one batched git call. diff mode previously only
854/// ever excluded a hardcoded set of secret-like filenames (`is_secret_path`)
855/// — a file a user explicitly excluded via `.diffctx/ignore` still had its
856/// changed content surfaced in full.
857fn resolve_ignored_paths(
858    root_dir: &Path,
859    hunks: &[crate::types::DiffHunk],
860) -> rustc_hash::FxHashMap<String, git::IgnoreSource> {
861    let rel_paths: Vec<String> = hunks
862        .iter()
863        .filter_map(|h| rel_path_string(root_dir, Path::new(&*h.path)))
864        .collect();
865    git::find_ignored_paths_with_source(root_dir, &rel_paths)
866}
867
868pub(crate) fn is_ignored_path(
869    root_dir: &Path,
870    path: &Path,
871    ignored_rel_paths: &rustc_hash::FxHashMap<String, git::IgnoreSource>,
872) -> bool {
873    rel_path_string(root_dir, path)
874        .map(|rel| ignored_rel_paths.contains_key(&rel))
875        .unwrap_or(false)
876}
877
878/// The unified diff of `diff_range` as git prints it, minus the file sections
879/// diff mode never discloses: secret-like paths, ignored paths, and lock files
880/// (#112). Additive output for `--with-raw-diff` (#150) — it feeds no
881/// selection state, so selection is bit-identical with and without it.
882pub fn raw_diff_text(root_dir: &Path, diff_range: Option<&str>, timeout: u64) -> Result<String> {
883    git::set_git_timeout(timeout);
884    let root_dir = resolve_repo_root(root_dir)?;
885    let resolved = git::resolve_duration_range(&root_dir, diff_range)?;
886    let diff_text = git::get_diff_text(&root_dir, resolved.range.as_deref())?;
887    Ok(keep_disclosable_sections(&root_dir, &diff_text))
888}
889
890fn keep_disclosable_sections(root_dir: &Path, diff_text: &str) -> String {
891    // Two views over the same text. Analysis runs on terminator-free lines so
892    // the exact header comparisons below keep working; output is emitted from
893    // the raw slices, because the bundle is advertised as git's own patch and
894    // dropping the CR of a CRLF repository yields something `git apply`
895    // rejects.
896    let raw: Vec<&str> = diff_text.split_inclusive('\n').collect();
897    let lines: Vec<&str> = raw
898        .iter()
899        .map(|line| line.trim_end_matches('\n').trim_end_matches('\r'))
900        .collect();
901    let sections = split_diff_sections(root_dir, &lines);
902    let rel_paths: Vec<String> = sections
903        .iter()
904        .filter_map(|(path, _)| path.as_deref())
905        .filter_map(|path| rel_path_string(root_dir, path))
906        .collect();
907    let ignored_rel_paths = git::find_ignored_paths_with_source(root_dir, &rel_paths);
908
909    let mut kept: Vec<&str> = Vec::new();
910    for (path, range) in sections {
911        // A section whose path cannot be resolved inside the repository is
912        // dropped: the bundle must never widen what diff mode is willing to
913        // show, and an unattributable section cannot be policy-checked.
914        let Some(path) = path else {
915            continue;
916        };
917        if is_secret_path(&path)
918            || is_lockfile_path(&path)
919            || is_ignored_path(root_dir, &path, &ignored_rel_paths)
920        {
921            continue;
922        }
923        kept.extend_from_slice(&raw[range]);
924    }
925    if kept.is_empty() {
926        return String::new();
927    }
928    let mut text = kept.concat();
929    if !text.ends_with('\n') {
930        text.push('\n');
931    }
932    text
933}
934
935type DiffSection = (Option<PathBuf>, std::ops::Range<usize>);
936
937fn split_diff_sections(root_dir: &Path, lines: &[&str]) -> Vec<DiffSection> {
938    let starts: Vec<usize> = lines
939        .iter()
940        .enumerate()
941        .filter(|(_, line)| line.starts_with("diff --git "))
942        .map(|(index, _)| index)
943        .collect();
944    starts
945        .iter()
946        .enumerate()
947        .map(|(nth, &start)| {
948            let end = starts.get(nth + 1).copied().unwrap_or(lines.len());
949            (section_path(root_dir, &lines[start..end]), start..end)
950        })
951        .collect()
952}
953
954fn section_path(root_dir: &Path, section: &[&str]) -> Option<PathBuf> {
955    let mut old_path: Option<PathBuf> = None;
956    let mut new_path: Option<PathBuf> = None;
957    for line in section.iter().take_while(|line| !line.starts_with("@@")) {
958        match git::parse_path_line(line, root_dir) {
959            ("new", path) => new_path = path,
960            ("old", path) => old_path = path,
961            _ => {}
962        }
963    }
964    new_path
965        .or(old_path)
966        .or_else(|| pathless_section(root_dir, section))
967}
968
969/// Sections with no `---`/`+++` pair at all: pure renames, binary files,
970/// mode-only changes. A rename states its target outright; the rest carry the
971/// same path on both sides of the `diff --git` header, so only a symmetric
972/// header is attributable — `a/x b/y` without a rename line could equally be
973/// one path containing " b/", and an unattributable section is dropped.
974fn pathless_section(root_dir: &Path, section: &[&str]) -> Option<PathBuf> {
975    if let Some(quoted) = section
976        .iter()
977        .find_map(|line| line.strip_prefix("rename to "))
978    {
979        return git::resolve_in_repo(root_dir, &git::unquote_c_style(quoted.trim()));
980    }
981    let rest = section.first()?.strip_prefix("diff --git ")?;
982    let rel_path = rest.get("a/".len()..rest.find(" b/")?)?;
983    if rest != format!("a/{rel_path} b/{rel_path}") {
984        return None;
985    }
986    git::resolve_in_repo(root_dir, rel_path)
987}
988
989fn resolve_repo_root(root_dir: &Path) -> Result<PathBuf> {
990    let root_dir = root_dir.canonicalize().unwrap_or_else(|e| {
991        tracing::debug!("canonicalize failed for '{}': {}", root_dir.display(), e);
992        root_dir.to_path_buf()
993    });
994    if !git::is_git_repo(&root_dir) {
995        anyhow::bail!("'{}' is not a git repository", root_dir.display());
996    }
997    Ok(git::find_toplevel(&root_dir).unwrap_or(root_dir))
998}
999
1000fn build_diff_context_full(
1001    root_dir: &Path,
1002    diff_range: Option<&str>,
1003    no_content: bool,
1004    timeout: u64,
1005) -> Result<DiffContextOutput> {
1006    // --full runs no scoring/edge phase, so the git subprocess timeout is the
1007    // only ceiling it needs.
1008    git::set_git_timeout(timeout);
1009    let root_dir = resolve_repo_root(root_dir)?;
1010    let resolved = git::resolve_duration_range(&root_dir, diff_range)?;
1011    let diff_range = resolved.range.as_deref();
1012    let mut hunks = git::parse_diff(&root_dir, diff_range)?;
1013    hunks.retain(|h| !is_secret_path(Path::new(&*h.path)));
1014    if hunks.is_empty() {
1015        let (deleted, renamed) = deletion_rename_displays(&root_dir, diff_range);
1016        let mut output = empty_output(&root_dir);
1017        output.deleted_files = deleted;
1018        output.renamed_files = renamed;
1019        return Ok(output);
1020    }
1021    let ignored_rel_paths = resolve_ignored_paths(&root_dir, &hunks);
1022    hunks.retain(|h| !is_ignored_path(&root_dir, Path::new(&*h.path), &ignored_rel_paths));
1023    if hunks.is_empty() {
1024        let (deleted, renamed) = deletion_rename_displays(&root_dir, diff_range);
1025        let mut output = empty_output(&root_dir);
1026        output.deleted_files = deleted;
1027        output.renamed_files = renamed;
1028        return Ok(output);
1029    }
1030    let mut changed_files = git::get_changed_files(&root_dir, diff_range)?;
1031    changed_files
1032        .retain(|f| !is_secret_path(f) && !is_ignored_path(&root_dir, f, &ignored_rel_paths));
1033    if changed_files.is_empty() {
1034        let (deleted, renamed) = deletion_rename_displays(&root_dir, diff_range);
1035        let mut output = empty_output(&root_dir);
1036        output.deleted_files = deleted;
1037        output.renamed_files = renamed;
1038        return Ok(output);
1039    }
1040    let (base_rev, head_rev) = diff_range
1041        .map(git::split_diff_range)
1042        .unwrap_or((None, None));
1043    let preferred_revs = build_preferred_revs(base_rev.as_deref(), head_rev.as_deref());
1044    let mut seen_frag_ids: FxHashSet<FragmentId> = FxHashSet::default();
1045    let mut batch_reader = CatFileBatch::new(&root_dir)?;
1046    let mut all_fragments = process_files_for_fragments(
1047        &changed_files,
1048        &root_dir,
1049        &preferred_revs,
1050        &mut seen_frag_ids,
1051        Some(&mut batch_reader),
1052        true,
1053    );
1054    assign_token_counts(&mut all_fragments);
1055    let mut sig_frags = generate_signature_variants(&all_fragments);
1056    assign_token_counts(&mut sig_frags);
1057    all_fragments.extend(sig_frags);
1058    changed_files.sort();
1059    let core_ids = identify_core_fragments(&hunks, &all_fragments);
1060    let selected = select_full_mode(&all_fragments, &changed_files);
1061    batch_reader.close();
1062    let commit_message = head_rev
1063        .as_deref()
1064        .and_then(|h| git::get_commit_message(&root_dir, h).ok())
1065        .and_then(|m| {
1066            m.lines()
1067                .map(str::trim)
1068                .find(|l| !l.is_empty())
1069                .map(str::to_string)
1070        });
1071    let mut deleted_display: Vec<String> = git::get_deleted_files(&root_dir, diff_range)
1072        .map(|set| {
1073            set.iter()
1074                .map(|p| {
1075                    p.strip_prefix(&root_dir)
1076                        .unwrap_or(p)
1077                        .to_string_lossy()
1078                        .replace('\\', "/")
1079                })
1080                .collect()
1081        })
1082        .unwrap_or_default();
1083    deleted_display.sort();
1084    let change = render::ChangeSummary {
1085        commit_message,
1086        changed_files: changed_files
1087            .iter()
1088            .map(|p| {
1089                p.strip_prefix(&root_dir)
1090                    .unwrap_or(p)
1091                    .to_string_lossy()
1092                    .replace('\\', "/")
1093            })
1094            .collect(),
1095        deleted_files: deleted_display,
1096        renamed_files: git::get_rename_pairs(&root_dir, diff_range).unwrap_or_default(),
1097        // `--full` is the escape hatch that promises every fragment of the
1098        // changed files, so it keeps lockfile content instead of diverting it.
1099        lockfile_changes: Vec::new(),
1100        ignored_changes: Vec::new(),
1101        policy_excluded_count: 0,
1102    };
1103    Ok(render::build_diff_context_output(
1104        &root_dir,
1105        &selected,
1106        no_content,
1107        &core_ids,
1108        &FxHashMap::default(),
1109        change,
1110    ))
1111}
1112
1113fn deletion_rename_displays(
1114    root_dir: &Path,
1115    diff_range: Option<&str>,
1116) -> (Vec<String>, Vec<(String, String)>) {
1117    let mut deleted: Vec<String> = git::get_deleted_files(root_dir, diff_range)
1118        .map(|set| {
1119            set.iter()
1120                .map(|p| {
1121                    p.strip_prefix(root_dir)
1122                        .unwrap_or(p)
1123                        .to_string_lossy()
1124                        .replace('\\', "/")
1125                })
1126                .collect()
1127        })
1128        .unwrap_or_default();
1129    deleted.sort();
1130    let renamed = git::get_rename_pairs(root_dir, diff_range).unwrap_or_default();
1131    (deleted, renamed)
1132}
1133
1134fn empty_scored_state_with_changes(root_dir: PathBuf, diff_range: Option<&str>) -> ScoredState {
1135    let (deleted, renamed) = deletion_rename_displays(&root_dir, diff_range);
1136    let mut state = empty_scored_state(root_dir);
1137    state.deleted_files = deleted;
1138    state.renamed_files = renamed;
1139    state
1140}
1141
1142fn empty_scored_state(root_dir: PathBuf) -> ScoredState {
1143    let config = PipelineConfig::from_mode(ScoringMode::Ego);
1144    ScoredState {
1145        root_dir,
1146        config,
1147        all_fragments: Vec::new(),
1148        core_ids: FxHashSet::default(),
1149        core_excerpts: FxHashMap::default(),
1150        discovery_source: FxHashMap::default(),
1151        lockfile_changes: Vec::new(),
1152        ignored_changes: Vec::new(),
1153        policy_excluded_count: 0,
1154        scoring_result: ScoringResult {
1155            admissible_files: None,
1156            rel_scores: FxHashMap::default(),
1157            filtered_fragments: Vec::new(),
1158            graph: crate::graph::Graph::new(),
1159            graph_build_ms: 0.0,
1160            ppr_truncated: false,
1161            ppr_forward_pushes: 0,
1162            ppr_backward_pushes: 0,
1163        },
1164        needs: Vec::new(),
1165        changed_files: Vec::new(),
1166        deleted_files: Vec::new(),
1167        renamed_files: Vec::new(),
1168        preferred_revs: Vec::new(),
1169        commit_message: None,
1170        heavy_latency_ms: HeavyLatencyMs::default(),
1171    }
1172}
1173
1174fn empty_output(root_dir: &Path) -> DiffContextOutput {
1175    let resolved = root_dir
1176        .canonicalize()
1177        .unwrap_or_else(|_| root_dir.to_path_buf());
1178    let name = resolved
1179        .file_name()
1180        .map(|n| n.to_string_lossy().to_string())
1181        .unwrap_or_else(|| resolved.to_string_lossy().to_string());
1182    DiffContextOutput {
1183        name,
1184        output_type: "diff_context".to_string(),
1185        commit_message: None,
1186        changed_files: Vec::new(),
1187        deleted_files: Vec::new(),
1188        renamed_files: Vec::new(),
1189        lockfile_changes: Vec::new(),
1190        ignored_changes: Vec::new(),
1191        policy_excluded_count: 0,
1192        fragment_count: 0,
1193        fragments: Vec::new(),
1194        latency: None,
1195    }
1196}
1197
1198/// A deletion/rename-only diff has no fragmentable content, but the file
1199/// lists themselves ARE the change - emit them instead of a bare skeleton.
1200/// pub(crate): the pybridge select_with_params empty branch must emit the
1201/// same lists, or MCP/benchmark consumers lose them while the CLI keeps them.
1202pub(crate) fn empty_output_from_state(state: &ScoredState) -> DiffContextOutput {
1203    let mut output = empty_output(&state.root_dir);
1204    output.commit_message = state.commit_message.clone();
1205    output.deleted_files = state.deleted_files.clone();
1206    output.renamed_files = state.renamed_files.clone();
1207    output.lockfile_changes = state.lockfile_changes.clone();
1208    output.ignored_changes = state.ignored_changes.clone();
1209    output.policy_excluded_count = state.policy_excluded_count;
1210    output
1211}
1212
1213fn build_preferred_revs(base_rev: Option<&str>, head_rev: Option<&str>) -> Vec<String> {
1214    let mut revs = Vec::new();
1215    if let Some(h) = head_rev {
1216        revs.push(h.to_string());
1217    }
1218    if let Some(b) = base_rev {
1219        if Some(b) != head_rev {
1220            revs.push(b.to_string());
1221        }
1222    }
1223    revs
1224}
1225
1226fn create_discovery(config: &PipelineConfig) -> Box<dyn DiscoveryStrategy> {
1227    match config.discovery {
1228        DiscoveryKind::Ensemble => Box::new(EnsembleDiscovery::new(vec![
1229            Box::new(DefaultDiscovery),
1230            Box::new(TestFileDiscovery),
1231            Box::new(BM25Discovery::new(config.bm25_top_k)),
1232        ])),
1233        DiscoveryKind::Default => Box::new(DefaultDiscovery),
1234    }
1235}
1236
1237fn build_file_cache(candidate_files: &[PathBuf]) -> FxHashMap<PathBuf, String> {
1238    // Stream files one at a time to avoid materialising all content before the cap.
1239    // Previous par_iter().collect() allocated the full eligible corpus into an
1240    // intermediate Vec before truncating — on repos with thousands of files this
1241    // caused peak memory far above max_cache_bytes.
1242    let mut sorted = candidate_files.to_vec();
1243    sorted.sort();
1244    let mut cache: FxHashMap<PathBuf, String> = FxHashMap::default();
1245    let mut cache_bytes = 0usize;
1246    for path in sorted {
1247        if cache_bytes > GRAPH_FILTERING.max_cache_bytes {
1248            break;
1249        }
1250        let Ok(meta) = path.metadata() else { continue };
1251        if meta.len() as usize > LIMITS.max_file_size {
1252            continue;
1253        }
1254        if let Ok(content) = std::fs::read_to_string(&path) {
1255            cache_bytes += content.len();
1256            cache.insert(path, content);
1257        }
1258    }
1259    cache
1260}
1261
1262fn assign_token_counts(fragments: &mut [Fragment]) {
1263    fragments.par_iter_mut().for_each(|frag| {
1264        if frag.token_count == 0 {
1265            frag.token_count = count_tokens(&frag.content) + LIMITS.overhead_per_fragment;
1266        }
1267    });
1268}
1269
1270fn assign_excerpt_token_counts(excerpts: &mut FxHashMap<FragmentId, Fragment>) {
1271    for frag in excerpts.values_mut() {
1272        if frag.token_count == 0 {
1273            frag.token_count = count_tokens(&frag.content) + LIMITS.overhead_per_fragment;
1274        }
1275    }
1276}
1277
1278fn select_full_mode(all_fragments: &[Fragment], changed_files: &[PathBuf]) -> Vec<Fragment> {
1279    let changed_paths: FxHashSet<String> = changed_files
1280        .iter()
1281        .map(|p| p.to_string_lossy().to_string())
1282        .collect();
1283    let mut selected: Vec<Fragment> = all_fragments
1284        .iter()
1285        .filter(|f| changed_paths.contains(f.path()))
1286        .cloned()
1287        .collect();
1288    selected.sort_by(|a, b| {
1289        a.path()
1290            .cmp(b.path())
1291            .then(a.start_line().cmp(&b.start_line()))
1292    });
1293    selected
1294}
1295
1296#[cfg(test)]
1297mod tests {
1298    use super::*;
1299
1300    /// The raw-diff bundle decides which sections it may disclose by resolving
1301    /// each `diff --git` header against the repo root. That guard used to be a
1302    /// second, independent copy of the one in `git::parse_path_line`, carrying
1303    /// the same lexical-prefix hole; both now share `git::resolve_in_repo`, so
1304    /// this pins that a section naming a path outside the root is dropped
1305    /// rather than bundled.
1306    #[test]
1307    fn a_raw_diff_section_escaping_the_root_resolves_to_nothing() {
1308        let tmp = tempfile::TempDir::new().expect("tempdir");
1309        let base = tmp.path().canonicalize().expect("canonical tempdir");
1310        let root = base.join("repo");
1311        std::fs::create_dir_all(&root).expect("mkdir repo");
1312        std::fs::write(base.join("outside.py"), "secret = 1\n").expect("write outside");
1313        std::fs::write(root.join("inside.py"), "x = 1\n").expect("write inside");
1314
1315        for rel in ["../outside.py", "../missing.py", "sub/../../outside.py"] {
1316            let header = format!("diff --git a/{rel} b/{rel}");
1317            assert!(
1318                section_path(&root, &[header.as_str()]).is_none(),
1319                "escaping section accepted: {rel}"
1320            );
1321        }
1322
1323        let header = "diff --git a/inside.py b/inside.py";
1324        assert!(
1325            section_path(&root, &[header]).is_some_and(|p| p.ends_with("inside.py")),
1326            "an in-repo section was dropped"
1327        );
1328    }
1329}
1330
1331#[cfg(test)]
1332mod secret_path_tests {
1333    use super::is_secret_path;
1334    use std::path::Path;
1335
1336    fn secret(p: &str) -> bool {
1337        is_secret_path(Path::new(p))
1338    }
1339
1340    /// The original list named the four classic SSH key stems and six
1341    /// certificate extensions, which left whole families of private-key
1342    /// container through: PuTTY, Apple signing keys, armoured PGP exports, the
1343    /// hardware-backed SSH variants, and the plain-text credential files CI
1344    /// images leak most often.
1345    #[test]
1346    fn private_key_and_credential_shapes_are_excluded() {
1347        for path in [
1348            "home/.ssh/id_rsa",
1349            "home/.ssh/id_ed25519",
1350            "home/.ssh/id_ed25519_sk",
1351            "home/.ssh/id_ecdsa_sk",
1352            "certs/server.pem",
1353            "certs/server.key",
1354            "certs/bundle.pfx",
1355            "certs/bundle.p12",
1356            "android/release.keystore",
1357            "android/release.jks",
1358            "windows/deploy.ppk",
1359            "apple/AuthKey_ABC123.p8",
1360            "gpg/private.asc",
1361            "home/.netrc",
1362            "home/_netrc",
1363            "aws/credentials",
1364            "home/.npmrc",
1365            "home/.pypirc",
1366        ] {
1367            assert!(secret(path), "not excluded: {path}");
1368        }
1369    }
1370
1371    /// Public halves stay visible — they are not secrets, and a changed
1372    /// `authorized_keys` or `.pub` is legitimate review context.
1373    #[test]
1374    fn public_material_is_not_excluded() {
1375        for path in [
1376            "home/.ssh/id_rsa.pub",
1377            "home/.ssh/id_ed25519.pub",
1378            "home/.ssh/authorized_keys",
1379            "certs/server.crt",
1380        ] {
1381            assert!(!secret(path), "wrongly excluded: {path}");
1382        }
1383    }
1384
1385    /// `.env` is deliberately NOT excluded: a changed `.env` is change context,
1386    /// and corpus cases assert on it. Pinned so widening the list never quietly
1387    /// takes it.
1388    #[test]
1389    fn env_files_remain_visible_by_design() {
1390        assert!(!secret(".env"));
1391        assert!(!secret("config/.env.production"));
1392    }
1393
1394    /// Ordinary source that merely contains a matching word is untouched — the
1395    /// rule is whole-name or extension, never substring.
1396    #[test]
1397    fn ordinary_files_are_untouched() {
1398        for path in [
1399            "src/keyboard.rs",
1400            "src/credentials_form.tsx",
1401            "docs/pemphigus.md",
1402        ] {
1403            assert!(!secret(path), "wrongly excluded: {path}");
1404        }
1405    }
1406}