Files
gitlore/src/core/file_history.rs
Taylor Eernisse 48fbd4bfdb feat(core): add file rename chain resolver with depth-bounded BFS
New module: core::file_history with resolve_rename_chain() that traces
a file path through its rename history in mr_file_changes using
bidirectional BFS (forward: old_path->new_path, backward: new_path->old_path).

Key design decisions:
- Depth-bounded BFS: each queue entry carries its distance from the
  origin, so max_hops correctly limits by graph distance (not by total
  nodes discovered). This matters for branching rename graphs where a
  file was renamed differently in parallel MRs.
- Cycle-safe: visited set prevents infinite loops from circular renames.
- Project-scoped: queries are always scoped to a single project_id.
- Deterministic: output is sorted for stable results.

Tests cover: linear chains (forward/backward), cycles, max_hops=0,
depth-bounded linear chains, branching renames, diamond patterns,
and cross-project isolation (9 tests total).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 10:54:41 -05:00

72 lines
2.4 KiB
Rust

use std::collections::HashSet;
use std::collections::VecDeque;
use rusqlite::Connection;
use super::error::Result;
/// Resolves a file path through its rename history in `mr_file_changes`.
///
/// BFS in both directions: forward (`old_path` -> `new_path`) and backward
/// (`new_path` -> `old_path`). Returns all equivalent paths including the
/// original, sorted for determinism. Cycles are detected via a visited set.
///
/// `max_hops` limits the BFS depth (distance from the starting path).
pub fn resolve_rename_chain(
conn: &Connection,
project_id: i64,
path: &str,
max_hops: usize,
) -> Result<Vec<String>> {
let mut visited: HashSet<String> = HashSet::new();
visited.insert(path.to_string());
if max_hops == 0 {
return Ok(vec![path.to_string()]);
}
let mut queue: VecDeque<(String, usize)> = VecDeque::new();
queue.push_back((path.to_string(), 0));
let forward_sql = "\
SELECT DISTINCT mfc.new_path FROM mr_file_changes mfc \
WHERE mfc.project_id = ?1 AND mfc.old_path = ?2 AND mfc.change_type = 'renamed'";
let backward_sql = "\
SELECT DISTINCT mfc.old_path FROM mr_file_changes mfc \
WHERE mfc.project_id = ?1 AND mfc.new_path = ?2 AND mfc.change_type = 'renamed'";
while let Some((current, depth)) = queue.pop_front() {
if depth >= max_hops {
continue;
}
// Forward: current was the old name -> discover new names
let mut fwd_stmt = conn.prepare_cached(forward_sql)?;
let forward: Vec<String> = fwd_stmt
.query_map(rusqlite::params![project_id, &current], |row| row.get(0))?
.filter_map(std::result::Result::ok)
.collect();
// Backward: current was the new name -> discover old names
let mut bwd_stmt = conn.prepare_cached(backward_sql)?;
let backward: Vec<String> = bwd_stmt
.query_map(rusqlite::params![project_id, &current], |row| row.get(0))?
.filter_map(std::result::Result::ok)
.collect();
for discovered in forward.into_iter().chain(backward) {
if visited.insert(discovered.clone()) {
queue.push_back((discovered, depth + 1));
}
}
}
let mut paths: Vec<String> = visited.into_iter().collect();
paths.sort();
Ok(paths)
}
#[cfg(test)]
#[path = "file_history_tests.rs"]
mod tests;