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/plan.rs
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
use anyhow::{Context, Result};
|
|
2
|
+
use serde::Serialize;
|
|
3
|
+
use std::collections::HashSet;
|
|
4
|
+
use std::fs;
|
|
5
|
+
use std::path::{Path, PathBuf};
|
|
6
|
+
|
|
7
|
+
use crate::scan::Format;
|
|
8
|
+
|
|
9
|
+
pub const AGENTS: &str = "AGENTS.md";
|
|
10
|
+
|
|
11
|
+
/// Provenance marker inserted when source content is appended into AGENTS.md.
|
|
12
|
+
pub fn merge_marker(rel: &str) -> String {
|
|
13
|
+
format!("<!-- merged from {rel} by dump-shitty-claude-md -->")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
|
17
|
+
#[serde(rename_all = "kebab-case")]
|
|
18
|
+
pub enum Action {
|
|
19
|
+
/// No AGENTS.md — move source content into AGENTS.md.
|
|
20
|
+
Rename,
|
|
21
|
+
/// Identical (or whitespace-equivalent) — delete the source file.
|
|
22
|
+
DeleteDuplicate,
|
|
23
|
+
/// Every source line already in AGENTS.md — delete the source file.
|
|
24
|
+
DeleteSubsumed,
|
|
25
|
+
/// AGENTS.md ⊂ source — source content replaces AGENTS.md, delete source.
|
|
26
|
+
OverwriteAgents,
|
|
27
|
+
/// Partial overlap — append source into AGENTS.md, delete source.
|
|
28
|
+
Merge,
|
|
29
|
+
/// Unsafe to touch — report only.
|
|
30
|
+
Skip,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
impl Action {
|
|
34
|
+
pub fn label(self) -> &'static str {
|
|
35
|
+
match self {
|
|
36
|
+
Action::Rename => "rename",
|
|
37
|
+
Action::DeleteDuplicate => "dedupe",
|
|
38
|
+
Action::DeleteSubsumed => "dedupe",
|
|
39
|
+
Action::OverwriteAgents => "replace",
|
|
40
|
+
Action::Merge => "merge",
|
|
41
|
+
Action::Skip => "skip",
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
pub fn mutates(self) -> bool {
|
|
46
|
+
!matches!(self, Action::Skip)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Lossless actions — no content merging, nothing to review.
|
|
50
|
+
pub fn is_trivial(self) -> bool {
|
|
51
|
+
matches!(
|
|
52
|
+
self,
|
|
53
|
+
Action::Rename | Action::DeleteDuplicate | Action::DeleteSubsumed
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#[derive(Debug, Serialize)]
|
|
59
|
+
pub struct Plan {
|
|
60
|
+
pub repo: PathBuf,
|
|
61
|
+
/// Source file relative to the repo root (`CLAUDE.md`,
|
|
62
|
+
/// `.github/copilot-instructions.md`, …).
|
|
63
|
+
pub file: PathBuf,
|
|
64
|
+
pub format: Format,
|
|
65
|
+
pub action: Action,
|
|
66
|
+
pub reason: String,
|
|
67
|
+
pub warnings: Vec<String>,
|
|
68
|
+
/// Instruction files inside the repo but not at a migratable root
|
|
69
|
+
/// position (untouched).
|
|
70
|
+
pub nested: Vec<PathBuf>,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/// Classify one repo-root instruction file against the repo's AGENTS.md state.
|
|
74
|
+
/// `source` is the absolute file path; `rel` its path inside the repo.
|
|
75
|
+
pub fn classify(
|
|
76
|
+
repo: &Path,
|
|
77
|
+
source: &Path,
|
|
78
|
+
format: Format,
|
|
79
|
+
nested: Vec<PathBuf>,
|
|
80
|
+
agents_ignored: bool,
|
|
81
|
+
) -> Result<Plan> {
|
|
82
|
+
let rel = source
|
|
83
|
+
.strip_prefix(repo)
|
|
84
|
+
.unwrap_or(source)
|
|
85
|
+
.to_path_buf();
|
|
86
|
+
let rel_str = rel.to_string_lossy().to_string();
|
|
87
|
+
let agents = repo.join(AGENTS);
|
|
88
|
+
let mut warnings = Vec::new();
|
|
89
|
+
if agents_ignored {
|
|
90
|
+
warnings.push(
|
|
91
|
+
"AGENTS.md is gitignored here — migrated content won't be committed".into(),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
macro_rules! skip {
|
|
96
|
+
($reason:expr) => {
|
|
97
|
+
Plan {
|
|
98
|
+
repo: repo.to_path_buf(),
|
|
99
|
+
file: rel.clone(),
|
|
100
|
+
format,
|
|
101
|
+
action: Action::Skip,
|
|
102
|
+
reason: $reason,
|
|
103
|
+
warnings: warnings.clone(),
|
|
104
|
+
nested: nested.clone(),
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let src_meta = source
|
|
110
|
+
.symlink_metadata()
|
|
111
|
+
.with_context(|| format!("{} vanished", source.display()))?;
|
|
112
|
+
|
|
113
|
+
if crate::scan::is_bare_repo(repo) {
|
|
114
|
+
return Ok(skip!("bare repo (no worktree) — left alone".into()));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if src_meta.file_type().is_symlink() {
|
|
118
|
+
return Ok(skip!(format!(
|
|
119
|
+
"{rel_str} is a symlink (dotfiles-managed?) — left alone"
|
|
120
|
+
)));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// FIFOs/sockets would block forever on read — only regular files.
|
|
124
|
+
if !src_meta.file_type().is_file() {
|
|
125
|
+
return Ok(skip!(format!("{rel_str} is not a regular file — left alone")));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Gemini CLI only loads AGENTS.md when configured to (context.fileName in
|
|
129
|
+
// .gemini/settings.json) — silently migrating would orphan the file.
|
|
130
|
+
if format == Format::Gemini && !gemini_reads_agents(repo) {
|
|
131
|
+
return Ok(skip!(
|
|
132
|
+
"Gemini CLI won't read AGENTS.md — add \"context.fileName\": [\"AGENTS.md\",\"GEMINI.md\"] to .gemini/settings.json first"
|
|
133
|
+
.into()
|
|
134
|
+
));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// AGENTS.md is a symlink: identical content → deleting source is still
|
|
138
|
+
// safe; anything needing a write to AGENTS.md would escape the repo.
|
|
139
|
+
let agents_is_symlink = agents
|
|
140
|
+
.symlink_metadata()
|
|
141
|
+
.map(|m| m.file_type().is_symlink())
|
|
142
|
+
.unwrap_or(false);
|
|
143
|
+
|
|
144
|
+
let src_bytes =
|
|
145
|
+
fs::read(source).with_context(|| format!("cannot read {}", source.display()))?;
|
|
146
|
+
|
|
147
|
+
#[cfg(unix)]
|
|
148
|
+
{
|
|
149
|
+
use std::os::unix::fs::MetadataExt;
|
|
150
|
+
if src_meta.nlink() > 1 {
|
|
151
|
+
warnings.push(format!(
|
|
152
|
+
"{rel_str} is hardlinked ({} names) — deleting removes only this name",
|
|
153
|
+
src_meta.nlink()
|
|
154
|
+
));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if !agents.exists() {
|
|
159
|
+
attach_warnings(&src_bytes, repo, format, &mut warnings);
|
|
160
|
+
return Ok(Plan {
|
|
161
|
+
repo: repo.to_path_buf(),
|
|
162
|
+
file: rel,
|
|
163
|
+
format,
|
|
164
|
+
action: Action::Rename,
|
|
165
|
+
reason: format!("no AGENTS.md — rename {rel_str}"),
|
|
166
|
+
warnings,
|
|
167
|
+
nested,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// AGENTS.md present but not a regular file/symlink (fifo, socket…) →
|
|
172
|
+
// reading it would block.
|
|
173
|
+
if let Ok(m) = agents.symlink_metadata() {
|
|
174
|
+
if !m.file_type().is_file() && !m.file_type().is_symlink() {
|
|
175
|
+
return Ok(skip!("AGENTS.md is not a regular file — left alone".into()));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let agents_bytes =
|
|
180
|
+
fs::read(&agents).with_context(|| format!("cannot read {}", agents.display()))?;
|
|
181
|
+
|
|
182
|
+
let (action, reason) = compare(&src_bytes, &agents_bytes, agents_is_symlink, &rel_str);
|
|
183
|
+
attach_warnings(&src_bytes, repo, format, &mut warnings);
|
|
184
|
+
|
|
185
|
+
Ok(Plan {
|
|
186
|
+
repo: repo.to_path_buf(),
|
|
187
|
+
file: rel,
|
|
188
|
+
format,
|
|
189
|
+
action,
|
|
190
|
+
reason,
|
|
191
|
+
warnings,
|
|
192
|
+
nested,
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/// Does `.gemini/settings.json` in this repo allow AGENTS.md as a context
|
|
197
|
+
/// file? `context.fileName` may be a string or an array.
|
|
198
|
+
fn gemini_reads_agents(repo: &Path) -> bool {
|
|
199
|
+
let Ok(text) = fs::read_to_string(repo.join(".gemini/settings.json")) else {
|
|
200
|
+
return false;
|
|
201
|
+
};
|
|
202
|
+
let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) else {
|
|
203
|
+
return false;
|
|
204
|
+
};
|
|
205
|
+
match &json["context"]["fileName"] {
|
|
206
|
+
serde_json::Value::String(s) => s == "AGENTS.md",
|
|
207
|
+
serde_json::Value::Array(a) => a.iter().any(|v| v.as_str() == Some("AGENTS.md")),
|
|
208
|
+
_ => false,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
fn compare(src: &[u8], agents: &[u8], agents_is_symlink: bool, rel: &str) -> (Action, String) {
|
|
213
|
+
if src == agents {
|
|
214
|
+
return (
|
|
215
|
+
Action::DeleteDuplicate,
|
|
216
|
+
format!("identical content → delete {rel}"),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
if normalize(src) == normalize(agents) {
|
|
220
|
+
return (
|
|
221
|
+
Action::DeleteDuplicate,
|
|
222
|
+
format!("whitespace-only differences → delete {rel}"),
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
let src_lines = line_set(src);
|
|
226
|
+
let agents_lines = line_set(agents);
|
|
227
|
+
if src_lines.is_subset(&agents_lines) {
|
|
228
|
+
return (
|
|
229
|
+
Action::DeleteSubsumed,
|
|
230
|
+
format!("content already covered by AGENTS.md → delete {rel}"),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if agents_is_symlink {
|
|
234
|
+
return (
|
|
235
|
+
Action::Skip,
|
|
236
|
+
"AGENTS.md is a symlink and content differs — left alone".into(),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
if agents_lines.is_subset(&src_lines) {
|
|
240
|
+
return (
|
|
241
|
+
Action::OverwriteAgents,
|
|
242
|
+
format!("{rel} covers all of AGENTS.md → replace + delete"),
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
(
|
|
246
|
+
Action::Merge,
|
|
247
|
+
format!("content differs → append {rel} into AGENTS.md"),
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/// Execute a plan inside `dir` (repo root or worktree). Re-reads files.
|
|
252
|
+
/// Hard guard: refuses to touch anything that isn't a git repo root —
|
|
253
|
+
/// instruction files outside git are never removed.
|
|
254
|
+
pub fn apply(dir: &Path, rel: &Path, action: Action) -> Result<()> {
|
|
255
|
+
if dir.join(".git").symlink_metadata().is_err() {
|
|
256
|
+
anyhow::bail!("{} is not a git repo — refusing to modify", dir.display());
|
|
257
|
+
}
|
|
258
|
+
let source = dir.join(rel);
|
|
259
|
+
let agents = dir.join(AGENTS);
|
|
260
|
+
match action {
|
|
261
|
+
// AGENTS.md may have appeared since classification (another source
|
|
262
|
+
// file already migrated) — never clobber it, append instead.
|
|
263
|
+
Action::Rename if !agents.exists() => fs::rename(&source, &agents)?,
|
|
264
|
+
Action::DeleteDuplicate | Action::DeleteSubsumed => fs::remove_file(&source)?,
|
|
265
|
+
Action::OverwriteAgents if !agents.exists() => {
|
|
266
|
+
fs::write(&agents, fs::read(&source)?)?;
|
|
267
|
+
fs::remove_file(&source)?;
|
|
268
|
+
}
|
|
269
|
+
Action::Rename | Action::OverwriteAgents | Action::Merge => {
|
|
270
|
+
let mut merged = fs::read(&agents).unwrap_or_default();
|
|
271
|
+
let src = fs::read(&source)?;
|
|
272
|
+
// Idempotent: a re-run (existing branch, repeated --apply) must not
|
|
273
|
+
// append the same block twice.
|
|
274
|
+
let marker = merge_marker(&rel.to_string_lossy());
|
|
275
|
+
if String::from_utf8_lossy(&merged).contains(&marker)
|
|
276
|
+
&& line_set(&src).is_subset(&line_set(&merged))
|
|
277
|
+
{
|
|
278
|
+
// Already merged and nothing new — just delete the source.
|
|
279
|
+
fs::remove_file(&source)?;
|
|
280
|
+
return Ok(());
|
|
281
|
+
}
|
|
282
|
+
if !merged.ends_with(b"\n") {
|
|
283
|
+
merged.push(b'\n');
|
|
284
|
+
}
|
|
285
|
+
merged.extend_from_slice(format!("\n{marker}\n\n").as_bytes());
|
|
286
|
+
merged.extend_from_slice(&src);
|
|
287
|
+
if !merged.ends_with(b"\n") {
|
|
288
|
+
merged.push(b'\n');
|
|
289
|
+
}
|
|
290
|
+
fs::write(&agents, merged)?;
|
|
291
|
+
fs::remove_file(&source)?;
|
|
292
|
+
}
|
|
293
|
+
Action::Skip => {}
|
|
294
|
+
}
|
|
295
|
+
Ok(())
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/// Whole-file compare after BOM strip, per-line trim + empty-line drop.
|
|
299
|
+
fn normalize(bytes: &[u8]) -> Vec<String> {
|
|
300
|
+
let text = String::from_utf8_lossy(bytes);
|
|
301
|
+
let text = text.strip_prefix('\u{feff}').unwrap_or(text.as_ref());
|
|
302
|
+
text.lines()
|
|
303
|
+
.map(str::trim)
|
|
304
|
+
.filter(|l| !l.is_empty())
|
|
305
|
+
.map(str::to_owned)
|
|
306
|
+
.collect()
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
fn line_set(bytes: &[u8]) -> HashSet<String> {
|
|
310
|
+
normalize(bytes).into_iter().collect()
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
fn attach_warnings(src_bytes: &[u8], repo: &Path, format: Format, warnings: &mut Vec<String>) {
|
|
314
|
+
let text = String::from_utf8_lossy(src_bytes);
|
|
315
|
+
if text.lines().any(|l| l.trim_start().starts_with('@')) {
|
|
316
|
+
warnings.push(
|
|
317
|
+
"uses @import syntax — verify it resolves under AGENTS.md for your agents".into(),
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
if format == Format::Claude {
|
|
321
|
+
if repo.join("CLAUDE.local.md").exists() {
|
|
322
|
+
warnings.push(
|
|
323
|
+
"CLAUDE.local.md also present — Claude-specific local file, not migrated".into(),
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
if repo.join("AGENTS.local.md").exists() {
|
|
327
|
+
warnings.push(
|
|
328
|
+
"AGENTS.local.md present — Claude Code only reads AGENTS.md; fold it in or it is dead content".into(),
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if std::str::from_utf8(src_bytes).is_err() {
|
|
333
|
+
warnings.push("source file is not valid UTF-8 — merged content may need review".into());
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
#[cfg(test)]
|
|
338
|
+
mod tests {
|
|
339
|
+
use super::*;
|
|
340
|
+
|
|
341
|
+
fn tmpdir(tag: &str) -> PathBuf {
|
|
342
|
+
let dir = std::env::temp_dir().join(format!("dscm-{tag}-{}", std::process::id()));
|
|
343
|
+
let _ = fs::remove_dir_all(&dir);
|
|
344
|
+
fs::create_dir_all(&dir).unwrap();
|
|
345
|
+
fs::create_dir(dir.join(".git")).unwrap(); // satisfy the repo guard
|
|
346
|
+
dir
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
fn plan(src: &[u8], agents: Option<&[u8]>, tag: &str) -> Action {
|
|
350
|
+
let dir = tmpdir(tag);
|
|
351
|
+
fs::write(dir.join("CLAUDE.md"), src).unwrap();
|
|
352
|
+
if let Some(a) = agents {
|
|
353
|
+
fs::write(dir.join(AGENTS), a).unwrap();
|
|
354
|
+
}
|
|
355
|
+
let p = classify(&dir, &dir.join("CLAUDE.md"), Format::Claude, vec![], false).unwrap();
|
|
356
|
+
fs::remove_dir_all(&dir).ok();
|
|
357
|
+
p.action
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#[test]
|
|
361
|
+
fn rename_when_no_agents() {
|
|
362
|
+
assert_eq!(plan(b"rules", None, "t1"), Action::Rename);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
#[test]
|
|
366
|
+
fn dedupe_identical() {
|
|
367
|
+
assert_eq!(plan(b"same", Some(b"same"), "t2"), Action::DeleteDuplicate);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
#[test]
|
|
371
|
+
fn dedupe_whitespace() {
|
|
372
|
+
assert_eq!(
|
|
373
|
+
plan(b"a\n\nb\n", Some(b" a\nb\n\n\n"), "t3"),
|
|
374
|
+
Action::DeleteDuplicate
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
#[test]
|
|
379
|
+
fn dedupe_subsumed() {
|
|
380
|
+
assert_eq!(
|
|
381
|
+
plan(b"a\nb", Some(b"header\na\nb\nc"), "t4"),
|
|
382
|
+
Action::DeleteSubsumed
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
#[test]
|
|
387
|
+
fn overwrite_when_agents_subset() {
|
|
388
|
+
assert_eq!(
|
|
389
|
+
plan(b"a\nb\nc", Some(b"a\nb"), "t5"),
|
|
390
|
+
Action::OverwriteAgents
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
#[test]
|
|
395
|
+
fn merge_partial_overlap() {
|
|
396
|
+
assert_eq!(plan(b"a\nx", Some(b"a\ny"), "t6"), Action::Merge);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
#[test]
|
|
400
|
+
fn merge_appends_with_marker() {
|
|
401
|
+
let dir = tmpdir("t7");
|
|
402
|
+
fs::write(dir.join("CLAUDE.md"), b"claude rules\n").unwrap();
|
|
403
|
+
fs::write(dir.join(AGENTS), b"agents rules\n").unwrap();
|
|
404
|
+
apply(&dir, Path::new("CLAUDE.md"), Action::Merge).unwrap();
|
|
405
|
+
let out = fs::read_to_string(dir.join(AGENTS)).unwrap();
|
|
406
|
+
assert!(out.contains("agents rules"));
|
|
407
|
+
assert!(out.contains("merged from CLAUDE.md"));
|
|
408
|
+
assert!(out.contains("claude rules"));
|
|
409
|
+
assert!(!dir.join("CLAUDE.md").exists());
|
|
410
|
+
fs::remove_dir_all(&dir).ok();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
#[test]
|
|
414
|
+
fn rename_moves_content() {
|
|
415
|
+
let dir = tmpdir("t8");
|
|
416
|
+
fs::write(dir.join("CLAUDE.md"), b"keep me\n").unwrap();
|
|
417
|
+
apply(&dir, Path::new("CLAUDE.md"), Action::Rename).unwrap();
|
|
418
|
+
assert_eq!(fs::read(dir.join(AGENTS)).unwrap(), b"keep me\n");
|
|
419
|
+
assert!(!dir.join("CLAUDE.md").exists());
|
|
420
|
+
fs::remove_dir_all(&dir).ok();
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
#[test]
|
|
424
|
+
fn gemini_skipped_without_agents_config() {
|
|
425
|
+
let dir = tmpdir("t9");
|
|
426
|
+
fs::write(dir.join("GEMINI.md"), b"gem rules\n").unwrap();
|
|
427
|
+
let p = classify(&dir, &dir.join("GEMINI.md"), Format::Gemini, vec![], false).unwrap();
|
|
428
|
+
assert_eq!(p.action, Action::Skip);
|
|
429
|
+
assert!(p.reason.contains("context.fileName"));
|
|
430
|
+
fs::remove_dir_all(&dir).ok();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
#[test]
|
|
434
|
+
fn gemini_migrates_when_agents_allowed() {
|
|
435
|
+
let dir = tmpdir("t10");
|
|
436
|
+
fs::create_dir_all(dir.join(".gemini")).unwrap();
|
|
437
|
+
fs::write(
|
|
438
|
+
dir.join(".gemini/settings.json"),
|
|
439
|
+
br#"{"context": {"fileName": ["GEMINI.md", "AGENTS.md"]}}"#,
|
|
440
|
+
)
|
|
441
|
+
.unwrap();
|
|
442
|
+
fs::write(dir.join("GEMINI.md"), b"gem rules\n").unwrap();
|
|
443
|
+
let p = classify(&dir, &dir.join("GEMINI.md"), Format::Gemini, vec![], false).unwrap();
|
|
444
|
+
assert_eq!(p.action, Action::Rename);
|
|
445
|
+
fs::remove_dir_all(&dir).ok();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
#[test]
|
|
449
|
+
fn copilot_source_renames_to_root_agents() {
|
|
450
|
+
let dir = tmpdir("t11");
|
|
451
|
+
fs::create_dir_all(dir.join(".github")).unwrap();
|
|
452
|
+
fs::write(dir.join(".github/copilot-instructions.md"), b"copilot rules\n").unwrap();
|
|
453
|
+
let p = classify(
|
|
454
|
+
&dir,
|
|
455
|
+
&dir.join(".github/copilot-instructions.md"),
|
|
456
|
+
Format::Copilot,
|
|
457
|
+
vec![],
|
|
458
|
+
false,
|
|
459
|
+
)
|
|
460
|
+
.unwrap();
|
|
461
|
+
assert_eq!(p.action, Action::Rename);
|
|
462
|
+
apply(&dir, &p.file, p.action).unwrap();
|
|
463
|
+
assert_eq!(fs::read(dir.join(AGENTS)).unwrap(), b"copilot rules\n");
|
|
464
|
+
assert!(!dir.join(".github/copilot-instructions.md").exists());
|
|
465
|
+
fs::remove_dir_all(&dir).ok();
|
|
466
|
+
}
|
|
467
|
+
}
|