Skip to content

Commit ac96716

Browse files
committed
Add peas mv command for renaming ticket IDs
Usage: peas mv <old-id> <new-suffix> Example: peas mv peas-abc12 xyz99 -> peas-xyz99 Features: - Renames ticket ID (prefix stays, only suffix changes) - Renames the file accordingly - Updates all parent and blocking references - Updates .undo file if it references the old ID Validation (blocked by default, --force overrides): - Suffix length must match configured id_length - All-digits suffix blocked in random mode - Non-digits suffix blocked in sequential mode
1 parent 7e0f1bf commit ac96716

File tree

5 files changed

+230
-0
lines changed

5 files changed

+230
-0
lines changed
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
+++
2+
id = "peas-c2n5w"
3+
title = "Add peas mv command for renaming ticket IDs"
4+
type = "feature"
5+
status = "completed"
6+
priority = "normal"
7+
created = "2026-02-03T00:04:04.344550500Z"
8+
updated = "2026-02-03T00:07:04.123416200Z"
9+
+++
10+
11+
Add a command to rename/re-ID tickets:
12+
13+
```
14+
peas mv <old-id> <new-suffix>
15+
```
16+
17+
Example: `peas mv peas-4988 4988a` → renames to `peas-4988a`
18+
19+
The command must:
20+
- Rename the ID (prefix stays, only suffix changes)
21+
- Rename the file
22+
- Update all references (parent, blocking)
23+
24+
Validation (blocked by default, --force overrides):
25+
- Length mismatch: new suffix ≠ configured id_length
26+
- Mode mismatch: all-digits in random mode, non-digits in sequential mode

src/cli/commands.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,22 @@ pub enum Commands {
311311
fix: bool,
312312
},
313313

314+
/// Rename a ticket ID (change the suffix, keep the prefix)
315+
///
316+
/// Example: `peas mv peas-abc12 xyz99` renames to peas-xyz99
317+
#[command(name = "mv")]
318+
Mv {
319+
/// The ticket ID to rename
320+
id: String,
321+
322+
/// The new suffix (without prefix)
323+
new_suffix: String,
324+
325+
/// Force rename even if suffix length or mode doesn't match config
326+
#[arg(long)]
327+
force: bool,
328+
},
329+
314330
/// Import from a beans project
315331
#[command(name = "import-beans")]
316332
ImportBeans {

src/cli/handlers/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ mod list;
1212
mod memory;
1313
mod migrate;
1414
mod mutate;
15+
mod mv;
1516
mod prime;
1617
mod query;
1718
mod roadmap;
@@ -39,6 +40,7 @@ pub use list::{ListParams, handle_list};
3940
pub use memory::handle_memory;
4041
pub use migrate::handle_migrate;
4142
pub use mutate::handle_mutate;
43+
pub use mv::handle_mv;
4244
pub use prime::handle_prime;
4345
pub use query::handle_query;
4446
pub use roadmap::handle_roadmap;

src/cli/handlers/mv.rs

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
use super::CommandContext;
2+
use crate::config::{DATA_DIR, IdMode};
3+
use anyhow::{Context, Result, bail};
4+
use colored::Colorize;
5+
6+
pub fn handle_mv(ctx: &CommandContext, id: String, new_suffix: String, force: bool) -> Result<()> {
7+
let prefix = &ctx.config.peas.prefix;
8+
let id_length = ctx.config.peas.id_length;
9+
let id_mode = ctx.config.peas.id_mode;
10+
11+
// Validate source ticket exists
12+
let pea = ctx
13+
.repo
14+
.get(&id)
15+
.with_context(|| format!("Ticket not found: {}", id))?;
16+
17+
// Build the new ID
18+
let new_id = format!("{}{}", prefix, new_suffix);
19+
20+
// Check if new ID already exists
21+
if ctx.repo.get(&new_id).is_ok() {
22+
bail!("Ticket with ID {} already exists", new_id);
23+
}
24+
25+
// Validate suffix length
26+
if new_suffix.len() != id_length && !force {
27+
bail!(
28+
"Suffix length {} does not match configured id_length {}. Use --force to override.",
29+
new_suffix.len(),
30+
id_length
31+
);
32+
}
33+
34+
// Validate ID mode
35+
let is_all_digits = new_suffix.chars().all(|c| c.is_ascii_digit());
36+
match id_mode {
37+
IdMode::Random if is_all_digits && !force => {
38+
bail!(
39+
"Suffix '{}' is all digits but id_mode is 'random'. Use --force to override.",
40+
new_suffix
41+
);
42+
}
43+
IdMode::Sequential if !is_all_digits && !force => {
44+
bail!(
45+
"Suffix '{}' contains non-digits but id_mode is 'sequential'. Use --force to override.",
46+
new_suffix
47+
);
48+
}
49+
_ => {}
50+
}
51+
52+
// Show warnings if force was used
53+
if force {
54+
if new_suffix.len() != id_length {
55+
eprintln!(
56+
"{}: Suffix length {} differs from configured id_length {}",
57+
"warning".yellow().bold(),
58+
new_suffix.len(),
59+
id_length
60+
);
61+
}
62+
match id_mode {
63+
IdMode::Random if is_all_digits => {
64+
eprintln!(
65+
"{}: Suffix '{}' is all digits but id_mode is 'random'",
66+
"warning".yellow().bold(),
67+
new_suffix
68+
);
69+
}
70+
IdMode::Sequential if !is_all_digits => {
71+
eprintln!(
72+
"{}: Suffix '{}' contains non-digits but id_mode is 'sequential'",
73+
"warning".yellow().bold(),
74+
new_suffix
75+
);
76+
}
77+
_ => {}
78+
}
79+
}
80+
81+
println!("Renaming {} → {}", id, new_id);
82+
83+
// Find all tickets that reference this ID
84+
let all_peas = ctx.repo.list()?;
85+
let mut updated_parents = 0;
86+
let mut updated_blocking = 0;
87+
88+
let data_dir = ctx.root.join(DATA_DIR);
89+
90+
// Update references in other tickets
91+
for other_pea in &all_peas {
92+
if other_pea.id == id {
93+
continue; // Skip the ticket we're renaming
94+
}
95+
96+
let mut needs_update = false;
97+
let mut updated_pea = other_pea.clone();
98+
99+
// Check parent reference
100+
if updated_pea.parent.as_ref() == Some(&id) {
101+
updated_pea.parent = Some(new_id.clone());
102+
needs_update = true;
103+
updated_parents += 1;
104+
}
105+
106+
// Check blocking references
107+
if updated_pea.blocking.contains(&id) {
108+
updated_pea.blocking = updated_pea
109+
.blocking
110+
.iter()
111+
.map(|b| if b == &id { new_id.clone() } else { b.clone() })
112+
.collect();
113+
needs_update = true;
114+
updated_blocking += 1;
115+
}
116+
117+
if needs_update {
118+
ctx.repo.update(&mut updated_pea)?;
119+
}
120+
}
121+
122+
// Now rename the ticket itself
123+
let mut renamed_pea = pea.clone();
124+
renamed_pea.id = new_id.clone();
125+
126+
// Get old and new file paths
127+
let old_filename = format!(
128+
"{}--{}.md",
129+
id,
130+
slug::slugify(&pea.title)
131+
.chars()
132+
.take(50)
133+
.collect::<String>()
134+
);
135+
let new_filename = format!(
136+
"{}--{}.md",
137+
new_id,
138+
slug::slugify(&pea.title)
139+
.chars()
140+
.take(50)
141+
.collect::<String>()
142+
);
143+
144+
let old_path = data_dir.join(&old_filename);
145+
let new_path = data_dir.join(&new_filename);
146+
147+
// Write the updated ticket content to the new file
148+
let content = crate::storage::render_markdown_with_format(
149+
&renamed_pea,
150+
ctx.config.peas.frontmatter_format(),
151+
)?;
152+
std::fs::write(&new_path, content)?;
153+
154+
// Remove the old file
155+
if old_path.exists() {
156+
std::fs::remove_file(&old_path)?;
157+
}
158+
159+
// Update the .undo file if it references the old ID
160+
let undo_path = data_dir.join(".undo");
161+
if undo_path.exists() {
162+
let undo_content = std::fs::read_to_string(&undo_path)?;
163+
if undo_content.contains(&id) {
164+
let updated_undo = undo_content.replace(&id, &new_id);
165+
// Also update file paths in undo
166+
let updated_undo = updated_undo.replace(&old_filename, &new_filename);
167+
std::fs::write(&undo_path, updated_undo)?;
168+
println!(" Updated .undo file");
169+
}
170+
}
171+
172+
println!("{} Renamed {} → {}", "✓".green(), id, new_id);
173+
if updated_parents > 0 {
174+
println!(" Updated {} parent reference(s)", updated_parents);
175+
}
176+
if updated_blocking > 0 {
177+
println!(" Updated {} blocking reference(s)", updated_blocking);
178+
}
179+
180+
Ok(())
181+
}

src/main.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,11 @@ fn main() -> Result<()> {
135135
Commands::Memory { action } => peas::cli::handlers::handle_memory(&ctx, action),
136136
Commands::Asset { action } => peas::cli::handlers::handle_asset(&ctx, action),
137137
Commands::Undo { json } => peas::cli::handlers::handle_undo(&ctx, json),
138+
Commands::Mv {
139+
id,
140+
new_suffix,
141+
force,
142+
} => peas::cli::handlers::handle_mv(&ctx, id, new_suffix, force),
138143
}
139144
}
140145
}

0 commit comments

Comments
 (0)