From a2b709f669db9f2dac9a0e33261b9649a882197d Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Wed, 29 Apr 2026 14:58:14 +0200 Subject: [PATCH] fix(gitignore): write sf runtime patterns to .git/info/exclude, not .gitignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensureGitignore was re-adding `.sf`, `.sf-id`, `.bg-shell/` to the project's .gitignore on every sf run, causing two issues: 1. Working-tree churn — every invocation dirtied .gitignore, forcing a commit just to silence "uncommitted changes" warnings. Pattern flagged by user: "is this the right way with its own every run". 2. False-positive duplicate-add — the literal-string check (`existingLines.has(".sf")`) didn't recognize user-equivalent patterns like `/.sf` (root-only) or `.sf/` (with trailing slash), so an explicit user entry got duplicated by the auto-add on next run. Fix: move sf-specific runtime patterns to `.git/info/exclude` via new `ensureGitInfoExclude()`. That file is per-clone (not committed), so re-writing is invisible to git status. The project's `.gitignore` stays human-curated and sf doesn't opinionate on it. `ensureGitignore()` now calls `ensureGitInfoExclude()` first so callers don't need to update — backwards compatible. Generic OS/IDE/lang patterns (.DS_Store, node_modules/, target/, etc.) stay in BASELINE_PATTERNS for .gitignore since those genuinely belong in version control. Co-Authored-By: Claude Opus 4.7 --- src/resources/extensions/sf/gitignore.ts | 69 ++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/src/resources/extensions/sf/gitignore.ts b/src/resources/extensions/sf/gitignore.ts index 58d831856..8596a07fe 100644 --- a/src/resources/extensions/sf/gitignore.ts +++ b/src/resources/extensions/sf/gitignore.ts @@ -46,11 +46,22 @@ const SF_RUNTIME_PATTERNS = [ ".sf/milestones/**/continue.md", ] as const; +/** + * SF-specific runtime exclusion patterns. These live in .git/info/exclude + * (per-clone, never committed) instead of .gitignore so that: + * - Re-running sf doesn't dirty the working tree on every invocation + * - The project's .gitignore stays human-curated (sf doesn't own it) + * - User-equivalent patterns like `/.sf` (root-only) coexist without + * triggering naive duplicate-add since we don't touch .gitignore at all + * for these. + * + * Migrated out of BASELINE_PATTERNS on 2026-04-29. + */ +const SF_RUNTIME_EXCLUSION_PATTERNS = [".sf", ".sf-id", ".bg-shell/"] as const; + const BASELINE_PATTERNS = [ - // ── SF state directory (symlink to external storage) ── - ".sf", - ".sf-id", - ".bg-shell/", + // SF-specific patterns now live in SF_RUNTIME_EXCLUSION_PATTERNS, applied + // to .git/info/exclude via ensureGitInfoExclude() — see comment above. // ── OS junk ── ".DS_Store", @@ -181,10 +192,60 @@ export function hasGitTrackedGsdFiles(basePath: string): boolean { * is excluded to prevent data loss. Only the `.sf` pattern is affected — * all other baseline patterns are still applied normally. */ +/** + * Write sf-specific runtime exclusion patterns (`.sf`, `.sf-id`, `.bg-shell/`) + * to `.git/info/exclude` — per-clone, never committed, never causes + * working-tree churn. Idempotent: only writes when something is missing. + * + * This replaces the old behavior of appending those patterns to `.gitignore`, + * which caused two pain points: + * 1. Every sf run dirtied the working tree because the literal-string + * duplicate-check (`existingLines.has(".sf")`) didn't recognize + * user-equivalent patterns like `/.sf`. + * 2. sf was opinionating on a file that should be human-curated. + * + * Returns true if the file was modified, false if nothing needed adding or + * the directory isn't a git repo (no `.git/info`). + */ +export function ensureGitInfoExclude(basePath: string): boolean { + const gitInfoDir = join(basePath, ".git", "info"); + if (!existsSync(gitInfoDir)) return false; + + const excludePath = join(gitInfoDir, "exclude"); + const existing = existsSync(excludePath) + ? readFileSync(excludePath, "utf-8") + : ""; + + const existingLines = new Set( + existing + .split("\n") + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith("#")), + ); + const missing = SF_RUNTIME_EXCLUSION_PATTERNS.filter( + (p) => !existingLines.has(p), + ); + if (missing.length === 0) return false; + + const block = [ + "", + "# ── SF runtime exclusion (managed by sf, per-clone) ──", + ...missing, + "", + ].join("\n"); + const prefix = existing && !existing.endsWith("\n") ? "\n" : ""; + writeFileSync(excludePath, existing + prefix + block, "utf-8"); + return true; +} + export function ensureGitignore( basePath: string, options?: { manageGitignore?: boolean }, ): boolean { + // SF-specific runtime patterns go to .git/info/exclude regardless of + // manage_gitignore — it's per-clone and never affects committed files. + ensureGitInfoExclude(basePath); + // If manage_gitignore is explicitly false, do not touch .gitignore at all if (options?.manageGitignore === false) return false;