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>
72 lines
2.4 KiB
Rust
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, ¤t], |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, ¤t], |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;
|