dump-shitty-claude-md 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +1049 -0
- package/Cargo.toml +20 -0
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/bin/darwin-arm64/dump-shitty-claude-md +0 -0
- package/bin/darwin-x64/dump-shitty-claude-md +0 -0
- package/bin/dump-shitty-claude-md.js +26 -0
- package/bin/linux-arm64/dump-shitty-claude-md +0 -0
- package/bin/linux-x64/dump-shitty-claude-md +0 -0
- package/bin/win32-x64/dump-shitty-claude-md.exe +0 -0
- package/package.json +38 -0
- package/src/gitops.rs +336 -0
- package/src/main.rs +550 -0
- package/src/plan.rs +467 -0
- package/src/scan.rs +501 -0
package/src/main.rs
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
mod gitops;
|
|
2
|
+
mod plan;
|
|
3
|
+
mod scan;
|
|
4
|
+
|
|
5
|
+
use clap::Parser;
|
|
6
|
+
use dialoguer::{Confirm, MultiSelect, Select};
|
|
7
|
+
use plan::{Action, Plan};
|
|
8
|
+
use scan::{Format, FoundFile};
|
|
9
|
+
use serde::Serialize;
|
|
10
|
+
use std::collections::{BTreeMap, BTreeSet};
|
|
11
|
+
use std::io::IsTerminal;
|
|
12
|
+
use std::path::PathBuf;
|
|
13
|
+
|
|
14
|
+
#[derive(Parser)]
|
|
15
|
+
#[command(
|
|
16
|
+
name = "dump-shitty-claude-md",
|
|
17
|
+
about = "Consolidate agent instruction files (CLAUDE.md and friends) into AGENTS.md\n\
|
|
18
|
+
across all repos in your home directory. One file serves every agent.",
|
|
19
|
+
version
|
|
20
|
+
)]
|
|
21
|
+
struct Cli {
|
|
22
|
+
/// Directory to scan [default: $HOME]
|
|
23
|
+
path: Option<PathBuf>,
|
|
24
|
+
|
|
25
|
+
/// Apply file changes (default: dry run)
|
|
26
|
+
#[arg(long)]
|
|
27
|
+
apply: bool,
|
|
28
|
+
|
|
29
|
+
/// Apply + commit on a branch + push + open a PR (implies --apply).
|
|
30
|
+
/// The migration lives on the PR branch; your checkout is left untouched.
|
|
31
|
+
#[arg(long)]
|
|
32
|
+
pr: bool,
|
|
33
|
+
|
|
34
|
+
/// Git strategy for --pr instead of prompting
|
|
35
|
+
#[arg(long, value_enum)]
|
|
36
|
+
strategy: Option<gitops::Strategy>,
|
|
37
|
+
|
|
38
|
+
/// Non-interactive: accept recommended defaults (CLAUDE.md only, worktree)
|
|
39
|
+
#[arg(short = 'y', long)]
|
|
40
|
+
yes: bool,
|
|
41
|
+
|
|
42
|
+
/// Extra instruction formats to migrate alongside CLAUDE.md (repeatable,
|
|
43
|
+
/// comma-separated): cursor, windsurf, cline, copilot, gemini, zed,
|
|
44
|
+
/// aider, roo, kilo, goose, warp
|
|
45
|
+
#[arg(long, value_enum, value_delimiter = ',')]
|
|
46
|
+
migrate: Vec<Format>,
|
|
47
|
+
|
|
48
|
+
/// Migrate every supported format (equivalent to picking all in the prompt)
|
|
49
|
+
#[arg(long)]
|
|
50
|
+
all_formats: bool,
|
|
51
|
+
|
|
52
|
+
/// Only lossless actions (rename + dedupe) — merges are report-only
|
|
53
|
+
#[arg(long)]
|
|
54
|
+
trivial: bool,
|
|
55
|
+
|
|
56
|
+
/// Move stray CLAUDE.md files (not inside a git repo) to the Trash.
|
|
57
|
+
/// Without this flag an interactive run asks once; -y alone never trashes.
|
|
58
|
+
#[arg(long)]
|
|
59
|
+
trash_strays: bool,
|
|
60
|
+
|
|
61
|
+
/// Emit the plan as JSON
|
|
62
|
+
#[arg(long)]
|
|
63
|
+
json: bool,
|
|
64
|
+
|
|
65
|
+
/// Extra directory to exclude (repeatable)
|
|
66
|
+
#[arg(long)]
|
|
67
|
+
exclude: Vec<PathBuf>,
|
|
68
|
+
|
|
69
|
+
/// Descend into symlinked directories (default: skip — avoids loops)
|
|
70
|
+
#[arg(long)]
|
|
71
|
+
follow_links: bool,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
#[derive(Serialize)]
|
|
75
|
+
struct Row {
|
|
76
|
+
repo: PathBuf,
|
|
77
|
+
file: PathBuf,
|
|
78
|
+
format: Format,
|
|
79
|
+
action: plan::Action,
|
|
80
|
+
reason: String,
|
|
81
|
+
warnings: Vec<String>,
|
|
82
|
+
nested: Vec<PathBuf>,
|
|
83
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
84
|
+
pr: Option<gitops::PrOutcome>,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
fn home() -> PathBuf {
|
|
88
|
+
dirs::home_dir().unwrap_or_default()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
fn shorten(p: &std::path::Path) -> String {
|
|
92
|
+
let h = home();
|
|
93
|
+
match p.strip_prefix(&h) {
|
|
94
|
+
Ok(rest) => format!("~/{}", rest.display()),
|
|
95
|
+
Err(_) => p.display().to_string(),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// Which formats to migrate. CLAUDE.md is always in scope; the rest are
|
|
100
|
+
/// opt-in via --migrate / --all-formats / the interactive picker.
|
|
101
|
+
fn select_formats(cli: &Cli, found: &[FoundFile]) -> BTreeSet<Format> {
|
|
102
|
+
let mut selected: BTreeSet<Format> = cli.migrate.iter().copied().collect();
|
|
103
|
+
if cli.all_formats {
|
|
104
|
+
selected.extend(Format::OPT_IN.iter().copied());
|
|
105
|
+
}
|
|
106
|
+
selected.insert(Format::Claude);
|
|
107
|
+
|
|
108
|
+
// Interactive picker only when something beyond CLAUDE.md exists and the
|
|
109
|
+
// user didn't pre-answer via flags / -y / --json / non-tty.
|
|
110
|
+
let present: BTreeSet<Format> = found
|
|
111
|
+
.iter()
|
|
112
|
+
.filter(|f| f.is_repo_root_file())
|
|
113
|
+
.map(|f| f.format)
|
|
114
|
+
.collect();
|
|
115
|
+
let extra: Vec<Format> = present
|
|
116
|
+
.iter()
|
|
117
|
+
.copied()
|
|
118
|
+
.filter(|f| *f != Format::Claude)
|
|
119
|
+
.collect();
|
|
120
|
+
let interactive = std::io::stdin().is_terminal()
|
|
121
|
+
&& std::io::stdout().is_terminal()
|
|
122
|
+
&& !cli.yes
|
|
123
|
+
&& !cli.json
|
|
124
|
+
&& cli.migrate.is_empty()
|
|
125
|
+
&& !cli.all_formats;
|
|
126
|
+
if interactive && !extra.is_empty() {
|
|
127
|
+
let labels: Vec<String> = std::iter::once(Format::Claude)
|
|
128
|
+
.chain(extra.iter().copied())
|
|
129
|
+
.map(|f| {
|
|
130
|
+
let n = found
|
|
131
|
+
.iter()
|
|
132
|
+
.filter(|x| x.format == f && x.is_repo_root_file())
|
|
133
|
+
.count();
|
|
134
|
+
format!("{} ({} repo{})", f.label(), n, if n == 1 { "" } else { "s" })
|
|
135
|
+
})
|
|
136
|
+
.collect();
|
|
137
|
+
let defaults: Vec<bool> = labels
|
|
138
|
+
.iter()
|
|
139
|
+
.enumerate()
|
|
140
|
+
.map(|(i, _)| i == 0) // only CLAUDE.md pre-checked
|
|
141
|
+
.collect();
|
|
142
|
+
if let Ok(picks) = MultiSelect::new()
|
|
143
|
+
.with_prompt("Instruction files to consolidate into AGENTS.md")
|
|
144
|
+
.items(&labels)
|
|
145
|
+
.defaults(&defaults)
|
|
146
|
+
.interact()
|
|
147
|
+
{
|
|
148
|
+
let all: Vec<Format> = std::iter::once(Format::Claude)
|
|
149
|
+
.chain(extra.iter().copied())
|
|
150
|
+
.collect();
|
|
151
|
+
selected = picks.into_iter().map(|i| all[i]).collect();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
selected
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/// Group scan hits into per-repo plans + strays (CLAUDE.md outside repos).
|
|
158
|
+
fn build_plans(
|
|
159
|
+
found: Vec<FoundFile>,
|
|
160
|
+
selected: &BTreeSet<Format>,
|
|
161
|
+
ignored_agents: &BTreeSet<PathBuf>,
|
|
162
|
+
) -> (Vec<Plan>, Vec<PathBuf>) {
|
|
163
|
+
let mut roots: BTreeMap<PathBuf, Vec<FoundFile>> = BTreeMap::new();
|
|
164
|
+
let mut strays = Vec::new();
|
|
165
|
+
|
|
166
|
+
for f in found {
|
|
167
|
+
match &f.repo_root {
|
|
168
|
+
Some(r) => roots.entry(r.clone()).or_default().push(f),
|
|
169
|
+
None => strays.push(f.path),
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let home_dir = home();
|
|
174
|
+
let mut plans = Vec::new();
|
|
175
|
+
for (root, files) in roots {
|
|
176
|
+
let nested: Vec<PathBuf> = files
|
|
177
|
+
.iter()
|
|
178
|
+
.filter(|f| !f.is_repo_root_file())
|
|
179
|
+
.map(|f| f.path.clone())
|
|
180
|
+
.collect();
|
|
181
|
+
let mut root_files: Vec<&FoundFile> = files
|
|
182
|
+
.iter()
|
|
183
|
+
.filter(|f| f.is_repo_root_file() && selected.contains(&f.format))
|
|
184
|
+
.collect();
|
|
185
|
+
// CLAUDE.md first (canonical), then other formats by path — so the
|
|
186
|
+
// primary file wins the rename and later sources merge into AGENTS.md.
|
|
187
|
+
root_files.sort_by(|a, b| {
|
|
188
|
+
(a.format != Format::Claude, a.rel()).cmp(&(b.format != Format::Claude, b.rel()))
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
if root_files.is_empty() {
|
|
192
|
+
if !nested.is_empty() {
|
|
193
|
+
plans.push(Plan {
|
|
194
|
+
repo: root.clone(),
|
|
195
|
+
file: PathBuf::new(),
|
|
196
|
+
format: Format::Claude,
|
|
197
|
+
action: Action::Skip,
|
|
198
|
+
reason: "no root instruction file".into(),
|
|
199
|
+
warnings: vec![],
|
|
200
|
+
nested,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let mut agents_touched = false;
|
|
207
|
+
for (i, f) in root_files.iter().enumerate() {
|
|
208
|
+
// $HOME as a repo (bare-dotfiles setups): instruction files there
|
|
209
|
+
// are global-ish — never touch them.
|
|
210
|
+
if root == home_dir {
|
|
211
|
+
plans.push(Plan {
|
|
212
|
+
repo: root.clone(),
|
|
213
|
+
file: f.rel(),
|
|
214
|
+
format: f.format,
|
|
215
|
+
action: Action::Skip,
|
|
216
|
+
reason: "repo root is $HOME — treated as global, skipped".into(),
|
|
217
|
+
warnings: vec![],
|
|
218
|
+
nested: if i == 0 { nested.clone() } else { vec![] },
|
|
219
|
+
});
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
match plan::classify(
|
|
223
|
+
&root,
|
|
224
|
+
&f.path,
|
|
225
|
+
f.format,
|
|
226
|
+
if i == 0 { nested.clone() } else { vec![] },
|
|
227
|
+
ignored_agents.contains(&root),
|
|
228
|
+
) {
|
|
229
|
+
Ok(mut p) => {
|
|
230
|
+
// Several sources may all want to rename/overwrite into a
|
|
231
|
+
// not-yet-existing AGENTS.md — only the first may; the
|
|
232
|
+
// rest must append or earlier content would be clobbered.
|
|
233
|
+
if agents_touched
|
|
234
|
+
&& matches!(p.action, Action::Rename | Action::OverwriteAgents)
|
|
235
|
+
{
|
|
236
|
+
p.action = Action::Merge;
|
|
237
|
+
p.reason = format!(
|
|
238
|
+
"AGENTS.md written by an earlier migration — append {}",
|
|
239
|
+
p.file.display()
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
if matches!(
|
|
243
|
+
p.action,
|
|
244
|
+
Action::Rename | Action::OverwriteAgents | Action::Merge
|
|
245
|
+
) {
|
|
246
|
+
agents_touched = true;
|
|
247
|
+
}
|
|
248
|
+
if p.nested
|
|
249
|
+
.iter()
|
|
250
|
+
.any(|n| n.components().any(|c| c.as_os_str() == ".claude"))
|
|
251
|
+
{
|
|
252
|
+
p.warnings.push(
|
|
253
|
+
"repo also has .claude/CLAUDE.md — still read by Claude Code after migration"
|
|
254
|
+
.into(),
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
plans.push(p);
|
|
258
|
+
}
|
|
259
|
+
Err(e) => plans.push(Plan {
|
|
260
|
+
repo: root.clone(),
|
|
261
|
+
file: f.rel(),
|
|
262
|
+
format: f.format,
|
|
263
|
+
action: Action::Skip,
|
|
264
|
+
reason: format!("{e:#}"),
|
|
265
|
+
warnings: vec![],
|
|
266
|
+
nested: vec![],
|
|
267
|
+
}),
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
(plans, strays)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
fn print_row(repo: &str, file: &str, label: &str, reason: &str, warnings: &[String], nested: &[PathBuf]) {
|
|
275
|
+
let mark = match label {
|
|
276
|
+
"skip" => "!",
|
|
277
|
+
"merge" | "replace" => "~",
|
|
278
|
+
_ => "✓",
|
|
279
|
+
};
|
|
280
|
+
println!("{mark} {label:<8} {repo:<44} {reason}");
|
|
281
|
+
if file != "CLAUDE.md" && !file.is_empty() {
|
|
282
|
+
println!(" {:<10} {:<44} file: {file}", "", "");
|
|
283
|
+
}
|
|
284
|
+
for w in warnings {
|
|
285
|
+
println!(" {:<10} {:<44} ⚠ {w}", "", "");
|
|
286
|
+
}
|
|
287
|
+
for n in nested.iter().take(5) {
|
|
288
|
+
println!(" {:<10} {:<44} nested: {}", "", "", shorten(n));
|
|
289
|
+
}
|
|
290
|
+
if nested.len() > 5 {
|
|
291
|
+
println!(" {:<10} {:<44} … +{} more nested", "", "", nested.len() - 5);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
fn choose_strategy(repo: &std::path::Path, clean: bool) -> Option<gitops::Strategy> {
|
|
296
|
+
let items = if clean {
|
|
297
|
+
vec![
|
|
298
|
+
"worktree (recommended — your checkout is never touched)",
|
|
299
|
+
"in-place (branch in this checkout, restored afterwards)",
|
|
300
|
+
"skip this repo",
|
|
301
|
+
]
|
|
302
|
+
} else {
|
|
303
|
+
vec![
|
|
304
|
+
"worktree (recommended — dirty tree is never touched)",
|
|
305
|
+
"in-place (uncommitted files stay untouched, only migration is committed)",
|
|
306
|
+
"skip this repo",
|
|
307
|
+
]
|
|
308
|
+
};
|
|
309
|
+
let pick = Select::new()
|
|
310
|
+
.with_prompt(format!("{} — strategy?", shorten(repo)))
|
|
311
|
+
.items(&items)
|
|
312
|
+
.default(0)
|
|
313
|
+
.interact()
|
|
314
|
+
.ok()?;
|
|
315
|
+
match pick {
|
|
316
|
+
0 => Some(gitops::Strategy::Worktree),
|
|
317
|
+
1 => Some(gitops::Strategy::InPlace),
|
|
318
|
+
_ => None,
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
fn main() -> anyhow::Result<()> {
|
|
323
|
+
let cli = Cli::parse();
|
|
324
|
+
let root = cli.path.clone().unwrap_or_else(home);
|
|
325
|
+
let root = root.canonicalize().unwrap_or(root);
|
|
326
|
+
|
|
327
|
+
let scan = scan::scan(&root, &cli.exclude, cli.follow_links)?;
|
|
328
|
+
let selected = select_formats(&cli, &scan.found);
|
|
329
|
+
let (plans, strays) = build_plans(scan.found, &selected, &scan.ignored_agents);
|
|
330
|
+
let dry = !cli.apply && !cli.pr;
|
|
331
|
+
|
|
332
|
+
if cli.json {
|
|
333
|
+
let rows: Vec<Row> = plans
|
|
334
|
+
.iter()
|
|
335
|
+
.map(|p| Row {
|
|
336
|
+
repo: p.repo.clone(),
|
|
337
|
+
file: p.file.clone(),
|
|
338
|
+
format: p.format,
|
|
339
|
+
action: p.action,
|
|
340
|
+
reason: p.reason.clone(),
|
|
341
|
+
warnings: p.warnings.clone(),
|
|
342
|
+
nested: p.nested.clone(),
|
|
343
|
+
pr: None,
|
|
344
|
+
})
|
|
345
|
+
.collect();
|
|
346
|
+
println!(
|
|
347
|
+
"{}",
|
|
348
|
+
serde_json::to_string_pretty(&serde_json::json!({
|
|
349
|
+
"plans": rows,
|
|
350
|
+
"strays": strays,
|
|
351
|
+
"variants": scan.variants,
|
|
352
|
+
"protected": scan.protected,
|
|
353
|
+
"rule_dirs": scan.rule_dirs,
|
|
354
|
+
"gitignored": scan.gitignored,
|
|
355
|
+
"ignored_agents": scan.ignored_agents,
|
|
356
|
+
"scan_errors": scan.errors,
|
|
357
|
+
}))?
|
|
358
|
+
);
|
|
359
|
+
return Ok(());
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
println!(
|
|
363
|
+
"{} — {} instruction file{} in repos, {} stray{}",
|
|
364
|
+
if dry { "DRY RUN" } else { "RUN" },
|
|
365
|
+
plans.len(),
|
|
366
|
+
if plans.len() == 1 { "" } else { "s" },
|
|
367
|
+
strays.len(),
|
|
368
|
+
if strays.len() == 1 { "" } else { "s" }
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
for p in &plans {
|
|
372
|
+
print_row(
|
|
373
|
+
&shorten(&p.repo),
|
|
374
|
+
&p.file.to_string_lossy(),
|
|
375
|
+
p.action.label(),
|
|
376
|
+
&p.reason,
|
|
377
|
+
&p.warnings,
|
|
378
|
+
&p.nested,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
for s in &strays {
|
|
382
|
+
println!("! stray {:<44} not inside a git repo", shorten(s));
|
|
383
|
+
}
|
|
384
|
+
for v in &scan.variants {
|
|
385
|
+
println!("! variant {:<44} non-standard spelling — no agent reads this", shorten(v));
|
|
386
|
+
}
|
|
387
|
+
for p in &scan.protected {
|
|
388
|
+
println!("· global {:<44} protected — left alone", shorten(p));
|
|
389
|
+
}
|
|
390
|
+
for g in &scan.gitignored {
|
|
391
|
+
println!("· ignored {:<44} gitignored — personal file, left alone", shorten(g));
|
|
392
|
+
}
|
|
393
|
+
for d in &scan.rule_dirs {
|
|
394
|
+
println!("! rules {:<44} tool-specific rule dir — not migratable", shorten(d));
|
|
395
|
+
}
|
|
396
|
+
for e in &scan.errors {
|
|
397
|
+
eprintln!(" scan error: {e}");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if dry {
|
|
401
|
+
println!(
|
|
402
|
+
"\ndry run — rerun with --apply (files) or --pr (files + branch + PR){}",
|
|
403
|
+
if strays.is_empty() {
|
|
404
|
+
""
|
|
405
|
+
} else {
|
|
406
|
+
" · --trash-strays removes strays"
|
|
407
|
+
}
|
|
408
|
+
);
|
|
409
|
+
return Ok(());
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// --apply / --pr — filter actionable plans, then group by repo so all
|
|
413
|
+
// selected files in a repo migrate in a single commit.
|
|
414
|
+
let mut counts = (0usize, 0usize, 0usize); // done, pr'd, skipped
|
|
415
|
+
let mut failures = 0usize;
|
|
416
|
+
let mut actionable: Vec<&Plan> = Vec::new();
|
|
417
|
+
for p in &plans {
|
|
418
|
+
if !p.action.mutates() {
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if cli.trivial && !p.action.is_trivial() {
|
|
422
|
+
println!("- held {:<44} merge — rerun without --trivial", shorten(&p.repo));
|
|
423
|
+
counts.2 += 1;
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
actionable.push(p);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if !cli.pr {
|
|
430
|
+
for p in actionable {
|
|
431
|
+
match plan::apply(&p.repo, &p.file, p.action) {
|
|
432
|
+
Ok(()) => {
|
|
433
|
+
counts.0 += 1;
|
|
434
|
+
println!("✓ {:<8} {}{}", p.action.label(), shorten(&p.repo), file_suffix(p));
|
|
435
|
+
}
|
|
436
|
+
Err(e) => {
|
|
437
|
+
failures += 1;
|
|
438
|
+
eprintln!("✗ {:<44} {e:#}", shorten(&p.repo));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
} else {
|
|
443
|
+
let mut by_repo: BTreeMap<PathBuf, Vec<&Plan>> = BTreeMap::new();
|
|
444
|
+
for p in actionable {
|
|
445
|
+
by_repo.entry(p.repo.clone()).or_default().push(p);
|
|
446
|
+
}
|
|
447
|
+
for (repo, repo_plans) in by_repo {
|
|
448
|
+
let clean = gitops::is_clean(&repo);
|
|
449
|
+
let strategy = match cli.strategy.or_else(|| {
|
|
450
|
+
if cli.yes || !std::io::stdout().is_terminal() {
|
|
451
|
+
Some(gitops::Strategy::Worktree)
|
|
452
|
+
} else {
|
|
453
|
+
choose_strategy(&repo, clean)
|
|
454
|
+
}
|
|
455
|
+
}) {
|
|
456
|
+
Some(s) => s,
|
|
457
|
+
None => {
|
|
458
|
+
println!("- skipped {}", shorten(&repo));
|
|
459
|
+
counts.2 += 1;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
if strategy == gitops::Strategy::InPlace && !clean {
|
|
464
|
+
println!(
|
|
465
|
+
"! {:<44} dirty tree — in-place refused, use worktree",
|
|
466
|
+
shorten(&repo)
|
|
467
|
+
);
|
|
468
|
+
counts.2 += 1;
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
let outcome = gitops::run(&repo, &repo_plans, strategy);
|
|
473
|
+
if outcome.pr_url.is_some() {
|
|
474
|
+
counts.1 += 1;
|
|
475
|
+
} else {
|
|
476
|
+
counts.0 += 1;
|
|
477
|
+
}
|
|
478
|
+
let mut line = format!("✓ migrated {}", shorten(&repo));
|
|
479
|
+
if outcome.committed {
|
|
480
|
+
line.push_str(&format!(" → {}", outcome.branch.clone().unwrap_or_default()));
|
|
481
|
+
}
|
|
482
|
+
if outcome.pushed {
|
|
483
|
+
line.push_str(" pushed");
|
|
484
|
+
}
|
|
485
|
+
if let Some(u) = &outcome.pr_url {
|
|
486
|
+
line.push_str(&format!(" PR: {u}"));
|
|
487
|
+
}
|
|
488
|
+
println!("{line}");
|
|
489
|
+
for w in &outcome.warnings {
|
|
490
|
+
println!(" ⚠ {w}");
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Strays: never migrated (not repos), optionally trashed.
|
|
496
|
+
let mut trashed = 0usize;
|
|
497
|
+
if !strays.is_empty() {
|
|
498
|
+
let trash = cli.trash_strays
|
|
499
|
+
|| (!cli.yes
|
|
500
|
+
&& std::io::stdout().is_terminal()
|
|
501
|
+
&& Confirm::new()
|
|
502
|
+
.with_prompt(format!(
|
|
503
|
+
"move {} stray CLAUDE.md (not in git repos) to Trash?",
|
|
504
|
+
strays.len()
|
|
505
|
+
))
|
|
506
|
+
.default(false)
|
|
507
|
+
.interact()
|
|
508
|
+
.unwrap_or(false));
|
|
509
|
+
if trash {
|
|
510
|
+
for s in &strays {
|
|
511
|
+
match trash::delete(s) {
|
|
512
|
+
Ok(()) => {
|
|
513
|
+
trashed += 1;
|
|
514
|
+
println!("🗑 stray {} → Trash", shorten(s));
|
|
515
|
+
}
|
|
516
|
+
Err(e) => {
|
|
517
|
+
failures += 1;
|
|
518
|
+
eprintln!("✗ {:<44} trash failed: {e}", shorten(s));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
println!(
|
|
526
|
+
"\ndone: {} migrated, {} PRs, {} skipped{}",
|
|
527
|
+
counts.0,
|
|
528
|
+
counts.1,
|
|
529
|
+
counts.2,
|
|
530
|
+
if trashed > 0 {
|
|
531
|
+
format!(", {trashed} trashed")
|
|
532
|
+
} else {
|
|
533
|
+
String::new()
|
|
534
|
+
}
|
|
535
|
+
);
|
|
536
|
+
if failures > 0 {
|
|
537
|
+
std::process::exit(1);
|
|
538
|
+
}
|
|
539
|
+
Ok(())
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/// `/filename` suffix shown next to a repo when the migrated file isn't
|
|
543
|
+
/// CLAUDE.md itself.
|
|
544
|
+
fn file_suffix(p: &Plan) -> String {
|
|
545
|
+
if p.format == Format::Claude {
|
|
546
|
+
String::new()
|
|
547
|
+
} else {
|
|
548
|
+
format!(" ({})", p.file.display())
|
|
549
|
+
}
|
|
550
|
+
}
|