From 15646ad69920ec6f377d1bcd03dd67e11a1e7f24 Mon Sep 17 00:00:00 2001 From: zawakin Date: Mon, 6 Jul 2026 10:18:53 +0900 Subject: [PATCH] feat: derive mappings from staged git renames with --from-git-renames The dominant agent workflow is 'git mv the files, then rewrite the remaining references'. The rename information already lives in git, yet users were reconstructing FROM=TO pairs by hand. --from-git-renames reads renames staged in the index (git diff --cached --find-renames) and derives old_stem=new_stem mappings from renames that keep their directory and extension. Renames that fit no token mapping (file-to-dir moves, extension changes) are reported as underivable with a reason instead of being silently dropped; conflicting derivations for the same FROM are a hard error naming both paths. Derived mappings merge with --map/--map-file and are recorded under 'derived' in the plan output, plan.json, and rep show. Co-Authored-By: Claude Fable 5 --- src/artifacts.rs | 14 ++++ src/cli.rs | 20 ++++++ src/git/query.rs | 81 +++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 18 +++++- src/planner.rs | 77 ++++++++++++++++++---- src/rename_derive.rs | 141 ++++++++++++++++++++++++++++++++++++++++ src/show.rs | 14 +++- tests/cli.rs | 150 +++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 500 insertions(+), 16 deletions(-) create mode 100644 src/rename_derive.rs diff --git a/src/artifacts.rs b/src/artifacts.rs index 078b4b7..4d80865 100644 --- a/src/artifacts.rs +++ b/src/artifacts.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::error::{RepError, Result}; use crate::path_rename::Rename; +use crate::rename_derive::Underivable; use crate::scope::{Scope, Skip}; use crate::text::Mapping; @@ -59,6 +60,15 @@ pub struct Artifacts { pub skipped: String, } +/// Mappings derived from staged git renames, plus the renames no mapping +/// could be derived from (recorded so they are never silently dropped). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DerivedInfo { + pub from_git_renames: bool, + pub mappings: Vec, + pub underivable: Vec, +} + /// The source of truth for a planned change. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Plan { @@ -69,6 +79,10 @@ pub struct Plan { pub repo: RepoInfo, pub scope: Scope, pub mappings: Vec, + /// Present when `--from-git-renames` contributed mappings. Optional and + /// defaulted so plan.json files written before this field deserialize. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub derived: Option, pub content: ContentPlan, pub paths: PathsPlan, pub skipped: Vec, diff --git a/src/cli.rs b/src/cli.rs index 24cbb5b..a8cbc1e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -108,6 +108,26 @@ You describe the rename as one or more literal mappings with --map FROM=TO \ #[arg(long = "map-file", value_name = "PATH")] map_file: Vec, + /// Derive FROM=TO mappings from renames staged in the git index + /// (e.g. after `git mv old.ts new.ts`) + #[arg( + long = "from-git-renames", + long_help = "Derive FROM=TO mappings from renames staged in the git index (e.g. after \ +`git mv old.ts new.ts`; unstaged renames cannot be detected by git). + +Only a rename that keeps its directory and extension but changes the file stem \ +is derivable; every other staged rename is reported as 'underivable' with a \ +reason, so you can describe it with explicit --map entries. Derived mappings \ +merge with --map/--map-file and are recorded in the plan output under 'derived'. + +Note that staged renames leave the tracked tree dirty, so the plan cannot be \ +applied until they are committed -- and committing moves HEAD, which staleness \ +checks reject. The working recipe: stage renames, run \ +'rep plan --from-git-renames --json' to derive and record the mappings, commit \ +the renames, then re-plan from the recorded mappings and apply." + )] + from_git_renames: bool, + /// Disable content replacement (enabled by default) #[arg(long = "no-content")] no_content: bool, diff --git a/src/git/query.rs b/src/git/query.rs index 1cd8e81..74ad633 100644 --- a/src/git/query.rs +++ b/src/git/query.rs @@ -74,6 +74,59 @@ pub fn tracked_files(root: &Path) -> Result> { Ok(files) } +/// Renames staged in the index relative to HEAD, as `(from, to)` pairs of +/// repo-root-relative paths. +/// +/// Rename detection needs both sides tracked, so callers must stage their +/// renames (e.g. via `git mv`) first — a worktree-only rename is just a +/// deletion plus an untracked file and can never be paired. +pub fn staged_renames(root: &Path) -> Result> { + let output = Command::new("git") + .args([ + "diff", + "--cached", + "--find-renames", + "--name-status", + "--diff-filter=R", + "-z", + ]) + .current_dir(root) + .output() + .map_err(|e| RepError::Git(format!("failed to execute git: {e}")))?; + if !output.status.success() { + return Err(RepError::Git( + String::from_utf8_lossy(&output.stderr).trim().to_string(), + )); + } + parse_rename_records(&output.stdout) +} + +/// Parse `--name-status -z` rename records: NUL-separated +/// `R FROM TO` triples. +fn parse_rename_records(raw: &[u8]) -> Result> { + let mut fields = raw.split(|&b| b == 0).filter(|s| !s.is_empty()); + let mut renames = Vec::new(); + while let Some(status) = fields.next() { + let status = String::from_utf8_lossy(status); + if !status.starts_with('R') { + return Err(RepError::Git(format!( + "unexpected record status '{status}' in rename-filtered diff" + ))); + } + let from = fields.next().ok_or_else(|| { + RepError::Git("truncated rename record: missing FROM path".to_string()) + })?; + let to = fields + .next() + .ok_or_else(|| RepError::Git("truncated rename record: missing TO path".to_string()))?; + renames.push(( + String::from_utf8_lossy(from).into_owned(), + String::from_utf8_lossy(to).into_owned(), + )); + } + Ok(renames) +} + /// Check whether the tracked tree is clean (no staged or unstaged changes). pub fn tracked_tree_clean(root: &Path) -> bool { git_check(root, &["diff", "--quiet"]) && git_check(root, &["diff", "--cached", "--quiet"]) @@ -83,3 +136,31 @@ pub fn tracked_tree_clean(root: &Path) -> bool { pub fn tracked_set(root: &Path) -> Result> { Ok(tracked_files(root)?.into_iter().collect()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rename_records_parse_score_from_to_triples() { + let raw = b"R100\0src/oldname.ts\0src/newname.ts\0R087\0a/x.ts\0b/y.ts\0"; + let renames = parse_rename_records(raw).unwrap(); + assert_eq!( + renames, + vec![ + ("src/oldname.ts".to_string(), "src/newname.ts".to_string()), + ("a/x.ts".to_string(), "b/y.ts".to_string()), + ] + ); + } + + #[test] + fn rename_records_empty_input_is_empty() { + assert!(parse_rename_records(b"").unwrap().is_empty()); + } + + #[test] + fn rename_records_truncated_is_error() { + assert!(parse_rename_records(b"R100\0only-from\0").is_err()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 3c7b893..49c6846 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ pub mod globset; pub mod output; pub mod path_rename; pub mod planner; +pub mod rename_derive; pub mod residual; pub mod scanner; pub mod schema; diff --git a/src/main.rs b/src/main.rs index 8c5f880..605a0b5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ use std::process::ExitCode; use clap::Parser; use clap::error::ErrorKind; +use rep::artifacts::DerivedInfo; use rep::cli::{Cli, Commands}; use rep::error::{RepError, Result}; use rep::planner::PlanOpts; @@ -12,7 +13,7 @@ use rep::residual::ResidualOpts; use rep::scope::ScopeOpts; use rep::show::ShowOpts; use rep::text; -use rep::{applier, output, planner, residual, scanner, show, status}; +use rep::{applier, git, output, planner, rename_derive, residual, scanner, show, status}; fn main() -> ExitCode { let cli = match Cli::try_parse() { @@ -146,6 +147,7 @@ fn dispatch(cli: Cli) -> Result { Commands::Plan { map, map_file, + from_git_renames, no_content, rename_paths, include, @@ -157,9 +159,23 @@ fn dispatch(cli: Cli) -> Result { .map(|s| text::parse_mapping(s)) .collect::>>()?; maps.extend(read_map_files(&map_file)?); + let derived = if from_git_renames { + let root = git::discover_root()?; + let renames = git::staged_renames(&root)?; + let (derived_maps, underivable) = rename_derive::derive(&renames)?; + maps.extend(derived_maps.clone()); + Some(DerivedInfo { + from_git_renames: true, + mappings: derived_maps, + underivable, + }) + } else { + None + }; planner::run( PlanOpts { maps, + derived, content: !no_content, rename_paths, scope: ScopeOpts { diff --git a/src/planner.rs b/src/planner.rs index 3a601a3..e8c93e1 100644 --- a/src/planner.rs +++ b/src/planner.rs @@ -7,8 +7,8 @@ use std::collections::HashMap; use serde::Serialize; use crate::artifacts::{ - self, Artifacts, ContentFile, ContentPlan, PathsPlan, Plan, RepoInfo, STATE_PLANNED, State, - Summary, + self, Artifacts, ContentFile, ContentPlan, DerivedInfo, PathsPlan, Plan, RepoInfo, + STATE_PLANNED, State, Summary, }; use crate::error::Result; use crate::output; @@ -21,6 +21,8 @@ struct PlanOutput { schema_version: String, plan_id: String, state: String, + #[serde(skip_serializing_if = "Option::is_none")] + derived: Option, content: ContentSummary, paths: PathSummary, skipped: usize, @@ -44,6 +46,9 @@ struct PathSummary { /// Options for `rep plan`. pub struct PlanOpts { pub maps: Vec, + /// Set when `--from-git-renames` ran: the derived mappings (already merged + /// into `maps`) and the staged renames nothing could be derived from. + pub derived: Option, pub content: bool, pub rename_paths: bool, pub scope: ScopeOpts, @@ -53,6 +58,13 @@ pub struct PlanOpts { pub fn run(opts: PlanOpts, json: bool) -> Result { let root = git::discover_root()?; scope::reject_rep_dir(&opts.scope)?; + + // `--from-git-renames` alone may legitimately derive nothing (no staged + // renames, or only underivable ones). That is "no matches" (exit 2), not a + // usage error — and the underivable list must still reach the caller. + if opts.maps.is_empty() && opts.derived.is_some() { + return no_op(&opts, json); + } text::validate_mappings(&opts.maps)?; let git_head = git::head(&root)?; @@ -100,18 +112,7 @@ pub fn run(opts: PlanOpts, json: bool) -> Result { // A plan that would change nothing is reported as "no matches" (exit 2) and // no artifacts are written, so agents don't mistake it for real work. if changed_files == 0 && renames.is_empty() { - if json { - output::print_json(&serde_json::json!({ - "schema_version": schema::PLAN, - "state": "none", - "no_op": true, - "content": { "changed_files": 0, "replacements": 0 }, - "paths": { "renames": 0 }, - }))?; - } else { - output::warn("no changes to plan for the given mappings"); - } - return Ok(2); + return no_op(&opts, json); } let plan_id = unique_plan_id(&root); @@ -130,6 +131,7 @@ pub fn run(opts: PlanOpts, json: bool) -> Result { }, scope: scope::Scope::from_opts(&opts.scope), mappings: opts.maps.clone(), + derived: opts.derived.clone(), content: ContentPlan { enabled: opts.content, matched_files: changed_files, @@ -170,6 +172,7 @@ pub fn run(opts: PlanOpts, json: bool) -> Result { schema_version: schema::PLAN.to_string(), plan_id, state: STATE_PLANNED.to_string(), + derived: opts.derived, content: ContentSummary { matched_files: plan.content.matched_files, changed_files: plan.content.changed_files, @@ -193,6 +196,43 @@ pub fn run(opts: PlanOpts, json: bool) -> Result { Ok(0) } +/// Report a plan that would change nothing: "no matches" (exit 2), no +/// artifacts written. When `--from-git-renames` ran, the derived mappings and +/// underivable renames are included so they are never silently dropped. +fn no_op(opts: &PlanOpts, json: bool) -> Result { + if json { + let mut out = serde_json::json!({ + "schema_version": schema::PLAN, + "state": "none", + "no_op": true, + "content": { "changed_files": 0, "replacements": 0 }, + "paths": { "renames": 0 }, + }); + if let Some(derived) = &opts.derived { + out["derived"] = serde_json::to_value(derived)?; + } + output::print_json(&out)?; + } else { + match &opts.derived { + Some(derived) if opts.maps.is_empty() => { + output::warn("no mappings derivable from staged renames"); + for u in &derived.underivable { + println!(" {} -> {} ({})", u.from, u.to, u.reason); + } + if derived.underivable.is_empty() { + output::action( + "stage renames first (git mv OLD NEW), then re-run rep plan --from-git-renames", + ); + } else { + output::action("describe these renames with explicit --map FROM=TO entries"); + } + } + _ => output::warn("no changes to plan for the given mappings"), + } + } + Ok(2) +} + /// Generate a sortable, timestamp-based plan id, adding a numeric suffix if a /// plan with that id already exists (agents may create plans within the same /// second). @@ -225,6 +265,15 @@ fn append_preview(preview: &mut String, path: &str, before: &str, after: &str) { fn print_human(out: &PlanOutput) { output::success(&format!("plan {}", output::bold(&out.plan_id))); + if let Some(derived) = &out.derived { + println!( + " derived from staged git renames: {} mappings", + derived.mappings.len() + ); + for u in &derived.underivable { + println!(" underivable: {} -> {} ({})", u.from, u.to, u.reason); + } + } println!( " content replacements: {} ({} changed files)", out.content.replacements, out.content.changed_files diff --git a/src/rename_derive.rs b/src/rename_derive.rs new file mode 100644 index 0000000..66b88e5 --- /dev/null +++ b/src/rename_derive.rs @@ -0,0 +1,141 @@ +//! Derive literal mappings from git-detected file renames. +//! +//! Only a rename that keeps its directory and extension but changes the file +//! stem yields an unambiguous token mapping (`old_stem=new_stem`). Every other +//! rename is reported as underivable — with a reason — instead of being +//! silently dropped, so the caller can add explicit `--map` entries. (A rename +//! with the same directory, extension, and stem would be the same path, so +//! those two reasons cover everything git can report.) + +use std::collections::HashMap; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::error::{RepError, Result}; +use crate::text::Mapping; + +/// The rename moved the file to a different directory (e.g. a file-to-dir +/// move like `scenes/x.ts -> scenes/x/scene.ts`), so no single token rename +/// describes it. +pub const REASON_DIRECTORY_CHANGED: &str = "directory_changed"; +/// The rename changed the extension, so the stem diff may not be a token +/// rename at all. +pub const REASON_EXTENSION_CHANGED: &str = "extension_changed"; + +/// A staged rename that no literal mapping can be derived from. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Underivable { + pub from: String, + pub to: String, + pub reason: String, +} + +/// Derive `old_stem=new_stem` mappings from `(from, to)` rename pairs. +/// +/// Identical derivations from renames in different directories collapse into +/// one mapping; two renames deriving the same FROM stem with *different* TO +/// stems are a hard error (the mapping set would be ambiguous), reported with +/// both paths so the caller can pass explicit `--map` entries. +pub fn derive(renames: &[(String, String)]) -> Result<(Vec, Vec)> { + let mut mappings: Vec = Vec::new(); + // FROM stem -> (TO stem, the rename's from-path) for dedup and conflicts. + let mut seen: HashMap = HashMap::new(); + let mut underivable = Vec::new(); + + for (from, to) in renames { + let (from_p, to_p) = (Path::new(from), Path::new(to)); + if from_p.parent() != to_p.parent() { + underivable.push(Underivable { + from: from.clone(), + to: to.clone(), + reason: REASON_DIRECTORY_CHANGED.to_string(), + }); + continue; + } + if from_p.extension() != to_p.extension() { + underivable.push(Underivable { + from: from.clone(), + to: to.clone(), + reason: REASON_EXTENSION_CHANGED.to_string(), + }); + continue; + } + let from_stem = from_p.file_stem().unwrap_or_default().to_string_lossy(); + let to_stem = to_p.file_stem().unwrap_or_default().to_string_lossy(); + match seen.get(from_stem.as_ref()) { + Some((prev_to, _)) if *prev_to == to_stem => {} // same derivation again + Some((_, prev_from)) => { + return Err(RepError::InvalidArguments(format!( + "conflicting staged renames both derive FROM '{from_stem}': \ + '{prev_from}' and '{from}'; pass explicit --map entries instead" + ))); + } + None => { + seen.insert(from_stem.to_string(), (to_stem.to_string(), from.clone())); + mappings.push(Mapping { + from: from_stem.to_string(), + to: to_stem.to_string(), + }); + } + } + } + + Ok((mappings, underivable)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn r(from: &str, to: &str) -> (String, String) { + (from.to_string(), to.to_string()) + } + + #[test] + fn same_dir_stem_change_derives_mapping() { + let (maps, und) = derive(&[r("src/01_lofi.ts", "src/lofi.ts")]).unwrap(); + assert_eq!(maps.len(), 1); + assert_eq!(maps[0].from, "01_lofi"); + assert_eq!(maps[0].to, "lofi"); + assert!(und.is_empty()); + } + + #[test] + fn directory_change_is_underivable() { + let (maps, und) = derive(&[r("scenes/38_x.ts", "scenes/x/scene.ts")]).unwrap(); + assert!(maps.is_empty()); + assert_eq!(und.len(), 1); + assert_eq!(und[0].reason, REASON_DIRECTORY_CHANGED); + } + + #[test] + fn extension_change_is_underivable() { + let (maps, und) = derive(&[r("docs/notes.txt", "docs/notes.md")]).unwrap(); + assert!(maps.is_empty()); + assert_eq!(und[0].reason, REASON_EXTENSION_CHANGED); + } + + #[test] + fn identical_derivations_across_dirs_dedupe() { + let (maps, _) = derive(&[r("a/x.ts", "a/y.ts"), r("b/x.ts", "b/y.ts")]).unwrap(); + assert_eq!(maps.len(), 1); + } + + #[test] + fn conflicting_derivations_error_with_both_paths() { + let err = derive(&[r("a/x.ts", "a/y.ts"), r("b/x.ts", "b/z.ts")]).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("a/x.ts"), "message: {msg}"); + assert!(msg.contains("b/x.ts"), "message: {msg}"); + } + + #[test] + fn only_final_extension_defines_the_stem() { + // Rust Path semantics: only the last `.ext` is the extension, so + // `38_x.test` is the stem of `38_x.test.ts`. + let (maps, _) = derive(&[r("a/38_x.test.ts", "a/x.test.ts")]).unwrap(); + assert_eq!(maps[0].from, "38_x.test"); + assert_eq!(maps[0].to, "x.test"); + } +} diff --git a/src/show.rs b/src/show.rs index 2715306..13cb7c3 100644 --- a/src/show.rs +++ b/src/show.rs @@ -6,7 +6,7 @@ use serde::Serialize; -use crate::artifacts::{self, Artifacts, ContentFile, NextStep, RepoInfo}; +use crate::artifacts::{self, Artifacts, ContentFile, DerivedInfo, NextStep, RepoInfo}; use crate::error::Result; use crate::output; use crate::path_rename::Rename; @@ -44,6 +44,8 @@ struct ShowOutput { repo: RepoInfo, scope: Scope, mappings: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + derived: Option, content: ContentSummary, paths: PathsSummary, skipped_count: usize, @@ -91,6 +93,7 @@ pub fn run(opts: ShowOpts, json: bool) -> Result { repo: plan.repo, scope: plan.scope, mappings: plan.mappings, + derived: plan.derived, content: ContentSummary { enabled: plan.content.enabled, matched_files: plan.content.matched_files, @@ -134,6 +137,15 @@ fn print_human(out: &ShowOutput) { for m in &out.mappings { println!(" {} -> {}", m.from, m.to); } + if let Some(derived) = &out.derived { + println!( + " derived from staged git renames: {} mappings", + derived.mappings.len() + ); + for u in &derived.underivable { + println!(" underivable: {} -> {} ({})", u.from, u.to, u.reason); + } + } println!( " content: {} replacements in {} files", out.content.replacements, out.content.changed_files diff --git a/tests/cli.rs b/tests/cli.rs index 138493a..35bcd01 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -818,6 +818,156 @@ fn show_human_smoke() { assert!(res.stdout.contains("oldname -> newname"), "{}", res.stdout); } +/// Repo with a token-bearing file plus a reference in README, for rename +/// derivation tests. +fn setup_rename_example() -> TempDir { + let dir = init_repo(); + write(dir.path(), "src/oldname.ts", "export const x = 1\n"); + write(dir.path(), "README.md", "see src/oldname.ts (oldname)\n"); + git(dir.path(), &["add", "."]); + git(dir.path(), &["commit", "-q", "-m", "init"]); + dir +} + +// a staged same-dir rename derives a stem mapping and plans content rewrites +#[test] +fn from_git_renames_derives_mappings() { + let dir = setup_rename_example(); + git(dir.path(), &["mv", "src/oldname.ts", "src/newname.ts"]); + let res = rep(dir.path(), &["plan", "--from-git-renames", "--json"]); + assert_eq!(res.code, 0, "plan failed: {}", res.stdout); + let json = res.json(); + assert_eq!(json["derived"]["from_git_renames"], true); + assert_eq!(json["derived"]["mappings"][0]["from"], "oldname"); + assert_eq!(json["derived"]["mappings"][0]["to"], "newname"); + assert!(json["content"]["replacements"].as_u64().unwrap() > 0); + // The derived mapping lands in plan.json like a manual one. + let plan_id = json["plan_id"].as_str().unwrap(); + let plan: Value = serde_json::from_str(&read( + dir.path(), + &format!(".rep/plans/{plan_id}/plan.json"), + )) + .unwrap(); + assert!( + plan["mappings"] + .as_array() + .unwrap() + .iter() + .any(|m| m["from"] == "oldname" && m["to"] == "newname") + ); +} + +// a file-to-dir move cannot be expressed as a token mapping and is reported +#[test] +fn from_git_renames_file_to_dir_is_underivable() { + let dir = init_repo(); + write(dir.path(), "scenes/38_x.ts", "scene\n"); + git(dir.path(), &["add", "."]); + git(dir.path(), &["commit", "-q", "-m", "init"]); + std::fs::create_dir_all(dir.path().join("scenes/x")).unwrap(); + git(dir.path(), &["mv", "scenes/38_x.ts", "scenes/x/scene.ts"]); + let res = rep(dir.path(), &["plan", "--from-git-renames", "--json"]); + assert_eq!(res.code, 2, "expected no-op: {}", res.stdout); + let json = res.json(); + assert_eq!(json["no_op"], true); + let und = &json["derived"]["underivable"][0]; + assert_eq!(und["from"], "scenes/38_x.ts"); + assert_eq!(und["reason"], "directory_changed"); +} + +// no staged renames at all is a clean no-op, not an error +#[test] +fn from_git_renames_without_staged_renames_exit_2() { + let dir = setup_rename_example(); + let res = rep(dir.path(), &["plan", "--from-git-renames", "--json"]); + assert_eq!(res.code, 2); + let json = res.json(); + assert_eq!(json["derived"]["mappings"].as_array().unwrap().len(), 0); + assert_eq!(json["derived"]["underivable"].as_array().unwrap().len(), 0); +} + +// a derived FROM duplicating a manual --map FROM hits the usual validation +#[test] +fn from_git_renames_duplicate_with_map_exit_10() { + let dir = setup_rename_example(); + git(dir.path(), &["mv", "src/oldname.ts", "src/newname.ts"]); + let res = rep( + dir.path(), + &[ + "plan", + "--map", + "oldname=other", + "--from-git-renames", + "--json", + ], + ); + assert_eq!(res.code, 10); +} + +// two staged renames deriving different TOs for the same FROM name both paths +#[test] +fn from_git_renames_conflicting_derivations_exit_10() { + let dir = init_repo(); + write(dir.path(), "a/x.ts", "a\n"); + write(dir.path(), "b/x.ts", "b\n"); + git(dir.path(), &["add", "."]); + git(dir.path(), &["commit", "-q", "-m", "init"]); + git(dir.path(), &["mv", "a/x.ts", "a/y.ts"]); + git(dir.path(), &["mv", "b/x.ts", "b/z.ts"]); + let res = rep(dir.path(), &["plan", "--from-git-renames", "--json"]); + assert_eq!(res.code, 10); + let msg = res.json()["error"]["message"].as_str().unwrap().to_string(); + assert!(msg.contains("a/x.ts"), "message: {msg}"); + assert!(msg.contains("b/x.ts"), "message: {msg}"); +} + +// derived mappings merge with unrelated manual --map entries +#[test] +fn from_git_renames_merges_with_manual_maps() { + let dir = init_repo(); + write(dir.path(), "src/oldname.ts", "export const OLDNAME = 1\n"); + write(dir.path(), "README.md", "oldname OLDNAME\n"); + git(dir.path(), &["add", "."]); + git(dir.path(), &["commit", "-q", "-m", "init"]); + git(dir.path(), &["mv", "src/oldname.ts", "src/newname.ts"]); + let res = rep( + dir.path(), + &[ + "plan", + "--map", + "OLDNAME=NEWNAME", + "--from-git-renames", + "--json", + ], + ); + assert_eq!(res.code, 0, "plan failed: {}", res.stdout); + let plan_id = res.json()["plan_id"].as_str().unwrap().to_string(); + let plan: Value = serde_json::from_str(&read( + dir.path(), + &format!(".rep/plans/{plan_id}/plan.json"), + )) + .unwrap(); + let froms: Vec<&str> = plan["mappings"] + .as_array() + .unwrap() + .iter() + .map(|m| m["from"].as_str().unwrap()) + .collect(); + assert_eq!(froms, vec!["OLDNAME", "oldname"]); +} + +// rep show surfaces the derived block recorded in the plan +#[test] +fn show_includes_derived_block() { + let dir = setup_rename_example(); + git(dir.path(), &["mv", "src/oldname.ts", "src/newname.ts"]); + let res = rep(dir.path(), &["plan", "--from-git-renames", "--json"]); + assert_eq!(res.code, 0, "plan failed: {}", res.stdout); + let res = rep(dir.path(), &["show", "--json"]); + assert_eq!(res.code, 0, "show failed: {}", res.stdout); + assert_eq!(res.json()["derived"]["mappings"][0]["from"], "oldname"); +} + // matched_directories reports the token-bearing directory prefix #[test] fn matched_directory_prefix() {