feat: auto-add .soulforge to .gitignore in git repos

When .soulforge/ is created in a project, ensure it's listed in .gitignore.
Uses `git check-ignore -q` to respect the full gitignore chain (parent files,
globs, negations) before appending. Creates .gitignore if the repo doesn't
have one. Non-git directories are left alone.

Runs once per cwd per process. Never crashes the app on failure.

Co-Authored-By: SoulForge <[email protected]>
This commit is contained in:
PouiiroandSoulForge committed 2026-04-10 11:37:03 +02:00
1 parent 6a71b5e2cf
commit 5b2f4b6134
6 files changed
+250 -7

No files matched your search

+2 -2
View File
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import type { CustomProviderConfig } from "../core/llm/providers/types.js";
import { ensureSoulforgeDir } from "../core/utils/ensure-soulforge-dir.js";
import type { AppConfig, MCPServerConfig } from "../types";
function mergeProviders(
@@ -163,9 +164,8 @@ export function saveConfig(config: AppConfig): void {
/** Save a partial config to <cwd>/.soulforge/config.json (deep-merge). */
export function saveProjectConfig(cwd: string, patch: Partial<AppConfig>): void {
const dir = join(cwd, ".soulforge");
const dir = ensureSoulforgeDir(cwd);
const file = join(dir, "config.json");
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
let existing: Partial<AppConfig> = {};
try {
+3 -3
View File
@@ -1,8 +1,9 @@
import { Database } from "bun:sqlite";
import { execSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
import { chmodSync, existsSync, readFileSync, statSync } from "node:fs";
import { stat as statAsync } from "node:fs/promises";
import { dirname, extname, join, relative, resolve } from "node:path";
import { ensureSoulforgeDir } from "../utils/ensure-soulforge-dir.js";
import {
computeFragmentHashes,
computeMinHash,
@@ -114,8 +115,7 @@ export class RepoMap {
constructor(cwd: string) {
this.cwd = cwd;
const dbDir = join(cwd, ".soulforge");
if (!existsSync(dbDir)) mkdirSync(dbDir, { recursive: true });
const dbDir = ensureSoulforgeDir(cwd);
const dbPath = join(dbDir, "repomap.db");
this.db = new Database(dbPath);
+2 -2
View File
@@ -1,6 +1,7 @@
import { existsSync, readFileSync, realpathSync, watch, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { basename, join, resolve } from "node:path";
import { ensureSoulforgeDir } from "../utils/ensure-soulforge-dir.js";
/**
* Forbidden file guard — prevents the LLM from reading or editing sensitive files.
@@ -153,8 +154,7 @@ export function addProjectPattern(cwd: string, pattern: string): void {
const existing = loadPatternsFromFile(filePath);
if (!existing.includes(pattern)) {
existing.push(pattern);
const { mkdirSync } = require("node:fs") as typeof import("node:fs");
mkdirSync(join(cwd, ".soulforge"), { recursive: true });
ensureSoulforgeDir(cwd);
writeFileSync(filePath, JSON.stringify({ patterns: existing }, null, 2));
projectPatterns = existing;
}
+4
View File
@@ -12,6 +12,7 @@ import { rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { logBackgroundError } from "../../stores/errors.js";
import type { ChatMessage } from "../../types/index.js";
import { ensureSoulforgeDir } from "../utils/ensure-soulforge-dir.js";
import { getIOClient } from "../workers/io-client.js";
import { rebuildCoreMessages } from "./rebuild.js";
import type { SessionMeta, TabMeta } from "./types.js";
@@ -27,13 +28,16 @@ export interface SessionListEntry {
export class SessionManager {
private dir: string;
private cwd: string;
constructor(cwd: string) {
this.cwd = cwd;
this.dir = join(cwd, ".soulforge", "sessions");
}
private ensureDir(): void {
if (!existsSync(this.dir)) {
ensureSoulforgeDir(this.cwd);
mkdirSync(this.dir, { recursive: true });
}
}
+67
View File
@@ -0,0 +1,67 @@
import { execSync, type SpawnSyncReturns } from "node:child_process";
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const ENTRY = ".soulforge";
/** Track which cwds we've already patched this process to avoid repeated I/O. */
const patched = new Set<string>();
/**
* Ensure `<cwd>/.soulforge/` exists and is listed in `.gitignore`.
*
* - Uses `git check-ignore` to respect all gitignore semantics (parent files, globs, negations).
* - If `.soulforge` is not ignored and the project is a git repo, appends it to `.gitignore`
* (creating the file if needed).
* - Non-git directories are left alone — no `.gitignore` is created.
* - Runs at most once per cwd per process.
*/
export function ensureSoulforgeDir(cwd: string): string {
const dir = join(cwd, ENTRY);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
if (!patched.has(cwd)) {
patched.add(cwd);
try {
ensureGitignore(cwd);
} catch {
// Never let gitignore housekeeping break the app
}
}
return dir;
}
function ensureGitignore(cwd: string): void {
// Single git call — exit 0 = already ignored, exit 1 = not ignored,
// exit 128 = not a git repo (or git not installed).
const status = gitCheckIgnoreStatus(cwd);
if (status !== 1) return; // 0 = ignored, 128+ = not a repo / no git
const gitignorePath = join(cwd, ".gitignore");
if (existsSync(gitignorePath)) {
const content = readFileSync(gitignorePath, "utf-8");
// Match the file's existing line ending style
const eol = content.includes("\r\n") ? "\r\n" : "\n";
const prefix = content.length > 0 && !content.endsWith("\n") ? eol : "";
appendFileSync(gitignorePath, `${prefix}.soulforge${eol}`);
} else {
writeFileSync(gitignorePath, ".soulforge\n");
}
}
/**
* Run `git check-ignore -q .soulforge` and return the exit code.
* 0 = .soulforge is already ignored
* 1 = .soulforge is NOT ignored (git repo exists, needs patching)
* 128 = not a git repo, or git not on PATH
*/
function gitCheckIgnoreStatus(cwd: string): number {
try {
execSync("git check-ignore -q .soulforge", { cwd, stdio: "pipe", timeout: 3000 });
return 0;
} catch (err) {
return (err as SpawnSyncReturns<Buffer>).status ?? 128;
}
}
+172
View File
@@ -0,0 +1,172 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { execSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// The module caches which cwds have been patched, so we need a fresh import
// for each test. We work around this by using unique temp dirs per test.
import { ensureSoulforgeDir } from "../src/core/utils/ensure-soulforge-dir.js";
const BASE = join(tmpdir(), `ensure-sf-test-${Date.now()}`);
let testDir: string;
let testIndex = 0;
beforeEach(() => {
testDir = join(BASE, `t${testIndex++}`);
mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
rmSync(BASE, { recursive: true, force: true });
});
describe("ensureSoulforgeDir", () => {
it("creates .soulforge directory", () => {
ensureSoulforgeDir(testDir);
expect(existsSync(join(testDir, ".soulforge"))).toBe(true);
});
it("returns the .soulforge path", () => {
const result = ensureSoulforgeDir(testDir);
expect(result).toBe(join(testDir, ".soulforge"));
});
it("appends .soulforge to existing .gitignore", () => {
// Make it a git repo so gitignore logic triggers
execSync("git init", { cwd: testDir, stdio: "pipe" });
writeFileSync(join(testDir, ".gitignore"), "node_modules\ndist\n");
ensureSoulforgeDir(testDir);
const content = readFileSync(join(testDir, ".gitignore"), "utf-8");
expect(content).toContain(".soulforge\n");
expect(content).toStartWith("node_modules\n");
});
it("does not duplicate .soulforge in .gitignore", () => {
execSync("git init", { cwd: testDir, stdio: "pipe" });
writeFileSync(join(testDir, ".gitignore"), "node_modules\n.soulforge\n");
ensureSoulforgeDir(testDir);
const content = readFileSync(join(testDir, ".gitignore"), "utf-8");
const count = content.split("\n").filter((l) => l.trim() === ".soulforge").length;
expect(count).toBe(1);
});
it("handles .soulforge/ variant in .gitignore", () => {
execSync("git init", { cwd: testDir, stdio: "pipe" });
writeFileSync(join(testDir, ".gitignore"), ".soulforge/\n");
ensureSoulforgeDir(testDir);
const content = readFileSync(join(testDir, ".gitignore"), "utf-8");
// Should not add a duplicate
expect(content).toBe(".soulforge/\n");
});
it("adds newline before .soulforge if .gitignore doesn't end with one", () => {
execSync("git init", { cwd: testDir, stdio: "pipe" });
writeFileSync(join(testDir, ".gitignore"), "node_modules");
ensureSoulforgeDir(testDir);
const content = readFileSync(join(testDir, ".gitignore"), "utf-8");
expect(content).toBe("node_modules\n.soulforge\n");
});
it("creates .gitignore in a git repo without one", () => {
execSync("git init", { cwd: testDir, stdio: "pipe" });
ensureSoulforgeDir(testDir);
const gitignorePath = join(testDir, ".gitignore");
expect(existsSync(gitignorePath)).toBe(true);
expect(readFileSync(gitignorePath, "utf-8")).toBe(".soulforge\n");
});
it("does not create .gitignore in a non-git directory", () => {
ensureSoulforgeDir(testDir);
expect(existsSync(join(testDir, ".gitignore"))).toBe(false);
});
it("respects wildcard patterns like .* that already cover .soulforge", () => {
execSync("git init", { cwd: testDir, stdio: "pipe" });
writeFileSync(join(testDir, ".gitignore"), ".*\n");
ensureSoulforgeDir(testDir);
const content = readFileSync(join(testDir, ".gitignore"), "utf-8");
// .* already covers .soulforge — should not append
expect(content).toBe(".*\n");
});
it("never crashes the caller even if git is broken", () => {
// ensureSoulforgeDir should still return the dir path
const result = ensureSoulforgeDir(testDir);
expect(result).toBe(join(testDir, ".soulforge"));
expect(existsSync(result)).toBe(true);
});
it("preserves CRLF line endings when appending", () => {
execSync("git init", { cwd: testDir, stdio: "pipe" });
writeFileSync(join(testDir, ".gitignore"), "node_modules\r\ndist\r\n");
ensureSoulforgeDir(testDir);
const raw = readFileSync(join(testDir, ".gitignore"), "utf-8");
// Should use CRLF to match the existing file
expect(raw).toBe("node_modules\r\ndist\r\n.soulforge\r\n");
});
it("preserves CRLF when file doesn't end with newline", () => {
execSync("git init", { cwd: testDir, stdio: "pipe" });
writeFileSync(join(testDir, ".gitignore"), "node_modules\r\ndist");
ensureSoulforgeDir(testDir);
const raw = readFileSync(join(testDir, ".gitignore"), "utf-8");
expect(raw).toBe("node_modules\r\ndist\r\n.soulforge\r\n");
});
it("respects parent .gitignore in a subdirectory repo", () => {
// Create a git repo with .gitignore at root
execSync("git init", { cwd: testDir, stdio: "pipe" });
writeFileSync(join(testDir, ".gitignore"), ".soulforge\n");
// Create a subdirectory (simulating a monorepo package)
const sub = join(testDir, "packages", "app");
mkdirSync(sub, { recursive: true });
ensureSoulforgeDir(sub);
// Should NOT create a .gitignore in the subdirectory — parent already covers it
expect(existsSync(join(sub, ".gitignore"))).toBe(false);
});
it("does not corrupt an empty .gitignore", () => {
execSync("git init", { cwd: testDir, stdio: "pipe" });
writeFileSync(join(testDir, ".gitignore"), "");
ensureSoulforgeDir(testDir);
const content = readFileSync(join(testDir, ".gitignore"), "utf-8");
expect(content).toBe(".soulforge\n");
});
it("handles read-only .gitignore gracefully", () => {
execSync("git init", { cwd: testDir, stdio: "pipe" });
writeFileSync(join(testDir, ".gitignore"), "node_modules\n");
const { chmodSync } = require("node:fs") as typeof import("node:fs");
chmodSync(join(testDir, ".gitignore"), 0o444);
// Should not throw — outer try/catch protects
const result = ensureSoulforgeDir(testDir);
expect(existsSync(result)).toBe(true);
// Restore permissions for cleanup
chmodSync(join(testDir, ".gitignore"), 0o644);
});
});