@@ -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/\n target/\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