Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/artifacts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<Mapping>,
pub underivable: Vec<Underivable>,
}

/// The source of truth for a planned change.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Plan {
Expand All @@ -69,6 +79,10 @@ pub struct Plan {
pub repo: RepoInfo,
pub scope: Scope,
pub mappings: Vec<Mapping>,
/// 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<DerivedInfo>,
pub content: ContentPlan,
pub paths: PathsPlan,
pub skipped: Vec<Skip>,
Expand Down
20 changes: 20 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

/// 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,
Expand Down
81 changes: 81 additions & 0 deletions src/git/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,59 @@ pub fn tracked_files(root: &Path) -> Result<Vec<String>> {
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<Vec<(String, String)>> {
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<score> FROM TO` triples.
fn parse_rename_records(raw: &[u8]) -> Result<Vec<(String, String)>> {
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"])
Expand All @@ -83,3 +136,31 @@ pub fn tracked_tree_clean(root: &Path) -> bool {
pub fn tracked_set(root: &Path) -> Result<std::collections::HashSet<String>> {
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());
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 17 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ 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;
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() {
Expand Down Expand Up @@ -146,6 +147,7 @@ fn dispatch(cli: Cli) -> Result<i32> {
Commands::Plan {
map,
map_file,
from_git_renames,
no_content,
rename_paths,
include,
Expand All @@ -157,9 +159,23 @@ fn dispatch(cli: Cli) -> Result<i32> {
.map(|s| text::parse_mapping(s))
.collect::<Result<Vec<_>>>()?;
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 {
Expand Down
77 changes: 63 additions & 14 deletions src/planner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,6 +21,8 @@ struct PlanOutput {
schema_version: String,
plan_id: String,
state: String,
#[serde(skip_serializing_if = "Option::is_none")]
derived: Option<DerivedInfo>,
content: ContentSummary,
paths: PathSummary,
skipped: usize,
Expand All @@ -44,6 +46,9 @@ struct PathSummary {
/// Options for `rep plan`.
pub struct PlanOpts {
pub maps: Vec<text::Mapping>,
/// 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<DerivedInfo>,
pub content: bool,
pub rename_paths: bool,
pub scope: ScopeOpts,
Expand All @@ -53,6 +58,13 @@ pub struct PlanOpts {
pub fn run(opts: PlanOpts, json: bool) -> Result<i32> {
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)?;
Expand Down Expand Up @@ -100,18 +112,7 @@ pub fn run(opts: PlanOpts, json: bool) -> Result<i32> {
// 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);
Expand All @@ -130,6 +131,7 @@ pub fn run(opts: PlanOpts, json: bool) -> Result<i32> {
},
scope: scope::Scope::from_opts(&opts.scope),
mappings: opts.maps.clone(),
derived: opts.derived.clone(),
content: ContentPlan {
enabled: opts.content,
matched_files: changed_files,
Expand Down Expand Up @@ -170,6 +172,7 @@ pub fn run(opts: PlanOpts, json: bool) -> Result<i32> {
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,
Expand All @@ -193,6 +196,43 @@ pub fn run(opts: PlanOpts, json: bool) -> Result<i32> {
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<i32> {
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).
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading