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;