mirror of
https://github.com/proxysoul/Empryo.git
synced 2026-11-07 06:25:57 -10:00
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:
6 files changed
+250
-7
No files matched your search
+2
-2
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user