Skip to content

Commit ee2fc16

Browse files
feat(mcp-setup): gitignore settings.local.json for Claude target
- settings.local.json holds machine-local MCP state that should not be committed; silently omitting it from .gitignore causes accidental credential/config leaks across team repos - accepts both bare and leading-slash anchored forms to stay idempotent against repos that already ignore the file - creates .gitignore when absent rather than failing, matching the zero-friction setup goal of run_setup
1 parent 69d1b04 commit ee2fc16

1 file changed

Lines changed: 107 additions & 0 deletions

File tree

‎src/mcp_setup.rs‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ pub fn run_setup(repo_root: &Path, target: Target, endpoint_url: &str) -> Vec<Fi
120120
Target::Claude => vec![
121121
write_claude_mcp_json(repo_root, endpoint_url),
122122
write_claude_settings_local(repo_root),
123+
ensure_gitignored(repo_root, ".claude/settings.local.json"),
123124
write_prompt_file(repo_root, "CLAUDE.md"),
124125
],
125126
Target::Codex => vec![
@@ -286,6 +287,67 @@ fn write_claude_settings_local(repo_root: &Path) -> FileAction {
286287
commit_json(&path, REL, &root, existed, changed)
287288
}
288289

290+
// ─── .gitignore ────────────────────────────────────────────────────────────
291+
292+
/// Ensure `entry` is listed in the repo's `.gitignore`, appending it if absent
293+
/// and creating the file if it doesn't exist.
294+
///
295+
/// `settings.local.json` holds machine-local MCP-enablement state, so it should
296+
/// not be committed. Matching is line-exact against the trimmed entry (so a
297+
/// pre-existing `.claude/settings.local.json` line — with or without a leading
298+
/// `/` — is respected and not duplicated); we never rewrite existing lines.
299+
fn ensure_gitignored(repo_root: &Path, entry: &str) -> FileAction {
300+
const REL: &str = ".gitignore";
301+
let path = match safe_join(repo_root, REL) {
302+
Ok(p) => p,
303+
Err(e) => return error_action(REL, e),
304+
};
305+
let existed = path.exists();
306+
let current = match fs::read_to_string(&path) {
307+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
308+
Err(e) => return error_action(REL, format!("read: {e}")),
309+
Ok(t) => t,
310+
};
311+
312+
// Already ignored? Accept the bare path or a leading-slash anchored form.
313+
let anchored = format!("/{entry}");
314+
let already = current.lines().any(|l| {
315+
let l = l.trim();
316+
l == entry || l == anchored
317+
});
318+
if already {
319+
return FileAction {
320+
file: rel_label(REL),
321+
status: if existed {
322+
FileStatus::Unchanged
323+
} else {
324+
FileStatus::Created
325+
},
326+
detail: None,
327+
};
328+
}
329+
330+
let mut out = current.clone();
331+
if !out.is_empty() && !out.ends_with('\n') {
332+
out.push('\n');
333+
}
334+
out.push_str(entry);
335+
out.push('\n');
336+
337+
match atomic_write(&path, &out) {
338+
Ok(()) => FileAction {
339+
file: rel_label(REL),
340+
status: if existed {
341+
FileStatus::Updated
342+
} else {
343+
FileStatus::Created
344+
},
345+
detail: None,
346+
},
347+
Err(e) => error_action(REL, e),
348+
}
349+
}
350+
289351
// ─── Opencode: opencode.json ───────────────────────────────────────────────
290352

291353
/// Merge `mcp.codebase-retrieval = { type:"remote", url, enabled:true }` into
@@ -610,6 +672,51 @@ mod tests {
610672
assert_eq!(read(&dir, ".mcp.json"), bad);
611673
}
612674

675+
#[test]
676+
fn claude_creates_gitignore_with_settings_local() {
677+
let dir = TempDir::new().unwrap();
678+
let actions = run_setup(dir.path(), Target::Claude, URL);
679+
assert_eq!(status_of(&actions, ".gitignore"), FileStatus::Created);
680+
let gi = read(&dir, ".gitignore");
681+
assert!(gi.contains(".claude/settings.local.json"));
682+
}
683+
684+
#[test]
685+
fn claude_appends_gitignore_preserving_existing() {
686+
let dir = TempDir::new().unwrap();
687+
let pre = "node_modules/\ntarget/\n";
688+
fs::write(dir.path().join(".gitignore"), pre).unwrap();
689+
690+
let actions = run_setup(dir.path(), Target::Claude, URL);
691+
assert_eq!(status_of(&actions, ".gitignore"), FileStatus::Updated);
692+
let gi = read(&dir, ".gitignore");
693+
assert!(gi.contains("node_modules/")); // existing kept
694+
assert!(gi.contains("target/"));
695+
assert!(gi.contains(".claude/settings.local.json"));
696+
}
697+
698+
#[test]
699+
fn claude_gitignore_idempotent_and_respects_anchored_form() {
700+
let dir = TempDir::new().unwrap();
701+
// Pre-existing leading-slash anchored entry must not be duplicated.
702+
fs::write(
703+
dir.path().join(".gitignore"),
704+
"/.claude/settings.local.json\n",
705+
)
706+
.unwrap();
707+
let actions = run_setup(dir.path(), Target::Claude, URL);
708+
assert_eq!(status_of(&actions, ".gitignore"), FileStatus::Unchanged);
709+
let gi = read(&dir, ".gitignore");
710+
assert_eq!(gi.matches("settings.local.json").count(), 1);
711+
}
712+
713+
#[test]
714+
fn non_claude_targets_do_not_touch_gitignore() {
715+
let dir = TempDir::new().unwrap();
716+
run_setup(dir.path(), Target::Codex, URL);
717+
assert!(!dir.path().join(".gitignore").exists());
718+
}
719+
613720
// ── Codex ───────────────────────────────────────────────────────────
614721

615722
#[test]

0 commit comments

Comments
 (0)