feat(path): rename-aware ambiguity resolution for suffix probe

When a bare filename like 'operators.ts' matches multiple full paths,
check if they are the same file connected by renames (via BFS on
mr_file_changes). If so, auto-resolve to the newest path instead of
erroring. Also wires path resolution into file-history and trace
commands so bare filenames work everywhere.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-17 15:05:47 -05:00
parent 171260a772
commit 714c8c2623
7 changed files with 632 additions and 100 deletions

View File

@@ -1,6 +1,7 @@
use rusqlite::Connection;
use super::error::{LoreError, Result};
use super::file_history::resolve_rename_chain;
// ─── SQL Helpers ─────────────────────────────────────────────────────────────
@@ -149,6 +150,16 @@ pub fn build_path_query(
is_prefix: false,
}),
SuffixResult::Ambiguous(candidates) => {
// Check if all candidates are the same file connected by renames.
// resolve_rename_chain requires a concrete project_id.
if let Some(pid) = project_id
&& let Some(resolved) = try_resolve_rename_ambiguity(conn, pid, &candidates)?
{
return Ok(PathQuery {
value: resolved,
is_prefix: false,
});
}
let list = candidates
.iter()
.map(|p| format!(" {p}"))
@@ -239,6 +250,59 @@ pub fn suffix_probe(
}
}
/// Maximum rename hops when resolving ambiguity.
const AMBIGUITY_MAX_RENAME_HOPS: usize = 10;
/// When suffix probe returns multiple paths, check if they are all the same file
/// connected by renames. If so, return the "newest" path (the leaf of the chain
/// that is never renamed away from). Returns `None` if truly ambiguous.
fn try_resolve_rename_ambiguity(
conn: &Connection,
project_id: i64,
candidates: &[String],
) -> Result<Option<String>> {
// BFS from the first candidate to discover the full rename chain.
let chain = resolve_rename_chain(conn, project_id, &candidates[0], AMBIGUITY_MAX_RENAME_HOPS)?;
// If any candidate is NOT in the chain, these are genuinely different files.
if !candidates.iter().all(|c| chain.contains(c)) {
return Ok(None);
}
// All candidates are the same file. Find the "newest" path: the one that
// appears as new_path in a rename but is never old_path in a subsequent rename
// (within the chain). This is the leaf of the rename DAG.
let placeholders: Vec<String> = (0..chain.len()).map(|i| format!("?{}", i + 2)).collect();
let in_clause = placeholders.join(", ");
// Find paths that are old_path in a rename where new_path is also in the chain.
let sql = format!(
"SELECT DISTINCT old_path FROM mr_file_changes \
WHERE project_id = ?1 \
AND change_type = 'renamed' \
AND old_path IN ({in_clause}) \
AND new_path IN ({in_clause})"
);
let mut stmt = conn.prepare(&sql)?;
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
params.push(Box::new(project_id));
for p in &chain {
params.push(Box::new(p.clone()));
}
let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let old_paths: Vec<String> = stmt
.query_map(param_refs.as_slice(), |row| row.get(0))?
.filter_map(std::result::Result::ok)
.collect();
// The newest path is a candidate that is NOT an old_path in any intra-chain rename.
let newest = candidates.iter().find(|c| !old_paths.contains(c));
Ok(newest.cloned().or_else(|| Some(candidates[0].clone())))
}
#[cfg(test)]
#[path = "path_resolver_tests.rs"]
mod tests;

View File

@@ -288,3 +288,80 @@ fn test_exact_match_preferred_over_suffix() {
assert_eq!(pq.value, "README.md");
assert!(!pq.is_prefix);
}
fn seed_rename(conn: &Connection, mr_id: i64, project_id: i64, old_path: &str, new_path: &str) {
conn.execute(
"INSERT INTO mr_file_changes (merge_request_id, project_id, old_path, new_path, change_type)
VALUES (?1, ?2, ?3, ?4, 'renamed')",
rusqlite::params![mr_id, project_id, old_path, new_path],
)
.unwrap();
}
// ─── rename-aware ambiguity resolution ──────────────────────────────────────
#[test]
fn test_ambiguity_resolved_by_rename_chain() {
let conn = setup_test_db();
seed_project(&conn, 1);
seed_mr(&conn, 1, 1);
seed_mr(&conn, 2, 1);
// File was at src/old/operators.ts, then renamed to src/new/operators.ts
seed_file_change(&conn, 1, 1, "src/old/operators.ts");
seed_rename(&conn, 2, 1, "src/old/operators.ts", "src/new/operators.ts");
// Bare "operators.ts" matches both paths via suffix probe, but they're
// connected by a rename — should auto-resolve to the newest path.
let pq = build_path_query(&conn, "operators.ts", Some(1)).unwrap();
assert_eq!(pq.value, "src/new/operators.ts");
assert!(!pq.is_prefix);
}
#[test]
fn test_ambiguity_not_resolved_when_genuinely_different_files() {
let conn = setup_test_db();
seed_project(&conn, 1);
seed_mr(&conn, 1, 1);
// Two genuinely different files with the same name (no rename connecting them)
seed_file_change(&conn, 1, 1, "src/utils/helpers.ts");
seed_file_change(&conn, 1, 1, "tests/utils/helpers.ts");
let err = build_path_query(&conn, "helpers.ts", Some(1)).unwrap_err();
assert!(err.to_string().contains("matches multiple paths"));
}
#[test]
fn test_ambiguity_rename_chain_with_three_hops() {
let conn = setup_test_db();
seed_project(&conn, 1);
seed_mr(&conn, 1, 1);
seed_mr(&conn, 2, 1);
seed_mr(&conn, 3, 1);
// File named "config.ts" moved twice: lib/ -> src/ -> src/core/
seed_file_change(&conn, 1, 1, "lib/config.ts");
seed_rename(&conn, 2, 1, "lib/config.ts", "src/config.ts");
seed_rename(&conn, 3, 1, "src/config.ts", "src/core/config.ts");
// "config.ts" matches lib/config.ts, src/config.ts, src/core/config.ts via suffix
let pq = build_path_query(&conn, "config.ts", Some(1)).unwrap();
assert_eq!(pq.value, "src/core/config.ts");
assert!(!pq.is_prefix);
}
#[test]
fn test_ambiguity_rename_without_project_id_stays_ambiguous() {
let conn = setup_test_db();
seed_project(&conn, 1);
seed_mr(&conn, 1, 1);
seed_mr(&conn, 2, 1);
seed_file_change(&conn, 1, 1, "src/old/utils.ts");
seed_rename(&conn, 2, 1, "src/old/utils.ts", "src/new/utils.ts");
// Without project_id, rename resolution is skipped → stays ambiguous
let err = build_path_query(&conn, "utils.ts", None).unwrap_err();
assert!(err.to_string().contains("matches multiple paths"));
}

View File

@@ -39,6 +39,7 @@ use lore::core::dependent_queue::release_all_locked_jobs;
use lore::core::error::{LoreError, RobotErrorOutput};
use lore::core::logging;
use lore::core::metrics::MetricsLayer;
use lore::core::path_resolver::{build_path_query, normalize_repo_path};
use lore::core::paths::{get_config_path, get_db_path, get_log_dir};
use lore::core::project::resolve_project;
use lore::core::shutdown::ShutdownSignal;
@@ -1874,9 +1875,27 @@ fn handle_file_history(
.effective_project(args.project.as_deref())
.map(String::from);
let normalized = normalize_repo_path(&args.path);
// Resolve bare filenames before querying (same path resolution as trace/who)
let db_path_tmp = get_db_path(config.storage.db_path.as_deref());
let conn_tmp = create_connection(&db_path_tmp)?;
let project_id_tmp = project
.as_deref()
.map(|p| resolve_project(&conn_tmp, p))
.transpose()?;
let pq = build_path_query(&conn_tmp, &normalized, project_id_tmp)?;
let resolved_path = if pq.is_prefix {
// Directory prefix — file-history is file-oriented, pass the raw path.
// Don't use pq.value which contains LIKE-escaped metacharacters.
normalized.trim_end_matches('/').to_string()
} else {
pq.value
};
let result = run_file_history(
&config,
&args.path,
&resolved_path,
project.as_deref(),
args.no_follow_renames,
args.merged,
@@ -1901,7 +1920,8 @@ fn handle_trace(
let start = std::time::Instant::now();
let config = Config::load(config_override)?;
let (path, line_requested) = parse_trace_path(&args.path);
let (raw_path, line_requested) = parse_trace_path(&args.path);
let normalized = normalize_repo_path(&raw_path);
if line_requested.is_some() && !robot_mode {
eprintln!(
@@ -1920,6 +1940,16 @@ fn handle_trace(
.map(|p| resolve_project(&conn, p))
.transpose()?;
// Resolve bare filenames (e.g. "operators.ts" -> "src/utils/operators.ts")
let pq = build_path_query(&conn, &normalized, project_id)?;
let path = if pq.is_prefix {
// Directory prefix — trace is file-oriented, pass the raw path.
// Don't use pq.value which contains LIKE-escaped metacharacters.
normalized.trim_end_matches('/').to_string()
} else {
pq.value
};
let result = run_trace(
&conn,
project_id,