fix(gitignore): write sf runtime patterns to .git/info/exclude, not .gitignore

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 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-04-29 14:58:14 +02:00
parent 6031106d93
commit a2b709f669

View file

@ -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;