From 25e76679c53284171228d78a1104f885154f7c7b Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 9 Aug 2026 15:34:12 -0400 Subject: [PATCH] feat: git ignore ralpi --- README.md | 6 +++ index.ts | 47 ++++++++++++++++ src/utils.ts | 43 +++++++++++++++ tests/commit-range-diff.test.ts | 82 ++++++++++++++++++++++++++++ tests/gitignore-hygiene.test.ts | 96 +++++++++++++++++++++++++++++++++ 5 files changed, 274 insertions(+) create mode 100644 tests/commit-range-diff.test.ts create mode 100644 tests/gitignore-hygiene.test.ts diff --git a/README.md b/README.md index b05e5f8..51d842e 100644 --- a/README.md +++ b/README.md @@ -290,3 +290,9 @@ in parallel mode). .ralpi/prompts/ # Generated prompts (timestamped, for debugging) .ralpi/config.yaml # Project-level config (optional) ``` + +Every `/ralpi run`, `/ralpi resume`, and `/ralpi reset` (plus the auto-resume +on session reload) ensures `.ralpi/` is present in the project's `.gitignore`, +so ralpi's own artifacts never show up as untracked/staged files in the user's +repo. Opt out per command with `--no-gitignore` (e.g. `/ralpi-run README.md +--no-gitignore`). diff --git a/index.ts b/index.ts index d2e13da..47c99fb 100644 --- a/index.ts +++ b/index.ts @@ -34,6 +34,7 @@ import { deleteLoopActive, readLoopActive, findRalpiDir, + ensureRalpiIgnored, listPRDsSorted, countPRDResumeStats, formatDuration, @@ -43,6 +44,39 @@ type ExecutionMode = "parallel" | "sequential"; // ─── Helpers ───────────────────────────────────────────────────────────────── +/** + * Split a `--no-gitignore` opt-out out of the command args (in place). The + * flag controls whether `/ralpi run|resume|reset` auto-adds `.ralpi/` to the + * project's `.gitignore` — it defaults to on so ralpi's own artifacts never + * end up staged in the user's repo. + */ +function stripNoGitignore(args: string[]): boolean { + const i = args.indexOf("--no-gitignore"); + if (i === -1) return false; + args.splice(i, 1); + return true; +} + +/** + * Ensure `.ralpi/` is gitignored in the project (unless opted out), and + * notify once when the guard actually appended the entry. + */ +function ensureIgnoredNote( + projectDir: string, + ctx: ExtensionContext, + noGitignore = false, +): void { + if (noGitignore) return; + if (ensureRalpiIgnored(projectDir)) { + ctx.ui.notify( + "· .ralpi/ added to .gitignore (opt out with --no-gitignore)", + "info", + ); + } +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + /** * Detect if a token looks like a file path rather than a subcommand. * Matches: @path, /path, ./path, ../path, path/to/file, path.md, path.yaml @@ -558,6 +592,10 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void { const loopState = readLoopActive(projectDir); if (!loopState) return; + // The auto-resume path has no CLI flag, so the gitignore guard is + // always on: keep `.ralpi/` out of the user's repo on reload too. + ensureRalpiIgnored(projectDir); + // Load progress state const progressPath = path.join(projectDir, ".ralpi", "progress.json"); @@ -885,6 +923,7 @@ async function handleRun( parentModel?: unknown, parentThinkingLevel?: unknown, ): Promise { + const noGitignore = stripNoGitignore(args); const taskFile = resolveTaskArg(args[0] || "README.md", ctx.cwd); // If targeting a specific task file and there's existing progress for it, @@ -921,6 +960,7 @@ async function handleRun( } const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd; + ensureIgnoredNote(projectDir, ctx, noGitignore); const project = parseTaskFile(taskFile); const config = loadConfig(projectDir); @@ -1200,6 +1240,7 @@ async function handleResume( parentModel?: unknown, parentThinkingLevel?: unknown, ): Promise { + const noGitignore = stripNoGitignore(args); let taskFile: string; let projectDir: string; let prdKey: string | undefined; @@ -1266,6 +1307,8 @@ async function handleResume( return undefined; })(); + ensureIgnoredNote(projectDir, ctx, noGitignore); + await resumeLoop( ctx, taskFile, @@ -1287,6 +1330,7 @@ async function handleReset( ctx: ExtensionContext, args: string[], ): Promise { + const noGitignore = stripNoGitignore(args); let sourcePath: string; let prdKey: string | undefined; let progress: ProgressTracker; @@ -1295,6 +1339,7 @@ async function handleReset( const taskFile = resolveTaskArg(args[0], ctx.cwd); const found = findProgressFile(ctx.cwd, taskFile); const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd; + ensureIgnoredNote(projectDir, ctx, noGitignore); sourcePath = taskFile; prdKey = found?.prdKey; progress = new ProgressTracker(projectDir, taskFile, prdKey); @@ -1309,6 +1354,8 @@ async function handleReset( } const projectDir = path.dirname(path.dirname(found.path)); + ensureIgnoredNote(projectDir, ctx, noGitignore); + // Multiple loops may have progress — let the user select which one to // reset (sorted by most recent first), same as resume. const selected = await selectPRD( diff --git a/src/utils.ts b/src/utils.ts index 275670f..9921be7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -100,6 +100,49 @@ export function deleteLoopActive(projectDir: string): void { } } +// ─── Git Hygiene ──────────────────────────────────────────────────────────── + +const ralpiIgnoreMemo = new Set(); + +/** + * Ensure `.ralpi/` is excluded from the project's `.gitignore` so ralpi's own + * run-state, worktrees, and reviews never show up as tracked/untracked files + * in the user's repo. + * + * Memoized per project dir; only acts inside a git work tree (`.git` may be a + * directory or, in linked worktrees, a file). Creates or appends `.ralpi/` to + * `.gitignore`, best-effort: any failure returns `false` (never throws). + * + * @returns true when the ignore entry was newly added, false otherwise. + */ +export function ensureRalpiIgnored(projectDir: string): boolean { + if (ralpiIgnoreMemo.has(projectDir)) return false; + ralpiIgnoreMemo.add(projectDir); + try { + // Only act inside a git work tree (works for worktrees too: .git is a file). + fs.statSync(path.join(projectDir, ".git")); + const ignorePath = path.join(projectDir, ".gitignore"); + const marker = ".ralpi/"; + let content: string; + try { + content = fs.readFileSync(ignorePath, "utf8"); + } catch { + fs.writeFileSync(ignorePath, `${marker}\n`, "utf8"); + return true; + } + if (content.split(/\r?\n/).some((l) => l.trim() === marker)) return false; + const prefix = content.endsWith("\n") ? "" : "\n"; + fs.appendFileSync( + ignorePath, + `${prefix}# ralpi run-state, worktrees, and reviews\n${marker}\n`, + "utf8", + ); + return true; + } catch { + return false; // not a git work tree, or a best-effort write failed + } +} + /** * Discover the project directory by walking up to find `.ralpi/`. */ diff --git a/tests/commit-range-diff.test.ts b/tests/commit-range-diff.test.ts new file mode 100644 index 0000000..990d2dd --- /dev/null +++ b/tests/commit-range-diff.test.ts @@ -0,0 +1,82 @@ +/** + * Tests for the tri-state commit-range diff (src/utils.ts getCommitRangeDiff): + * a FAILED range computation (invalid/stale base ref, git error) must be a + * distinct `error` signal, never collapsed into a clean `no-changes` — a + * broken base ref must never be silently treated as a verified task. + * + * Uses a real throwaway git repo so the shell-out behavior is exercised. + */ + +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { execSync } from "node:child_process"; +import { getCommitRangeDiff } from "../src/utils"; + +let repoDir: string; + +function sh(cmd: string, cwd: string) { + execSync(cmd, { cwd, stdio: "pipe" }); +} + +beforeAll(() => { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), "ralpi-crd-")); + sh("git init -q", repoDir); + sh("git config user.email test@example.com", repoDir); + sh("git config user.name test", repoDir); + fs.writeFileSync(path.join(repoDir, "a.ts"), "one\n", "utf-8"); + sh("git add -A", repoDir); + sh("git commit -q -m init", repoDir); +}); + +afterAll(() => { + fs.rmSync(repoDir, { recursive: true, force: true }); +}); + +describe("getCommitRangeDiff tri-state", () => { + test("ok: a resolvable base with committed changes yields the diff", () => { + fs.writeFileSync(path.join(repoDir, "a.ts"), "one\ntwo\n", "utf-8"); + sh("git add -A", repoDir); + sh("git commit -q -m change", repoDir); + + const base = execSync("git rev-parse HEAD~1", { + cwd: repoDir, + encoding: "utf-8", + }).trim(); + + const result = getCommitRangeDiff(repoDir, base); + expect(result.kind).toBe("ok"); + if (result.kind === "ok") { + expect(result.diff).toContain("a.ts"); + expect(result.hash.length).toBeGreaterThan(0); + } + }); + + test("error: a fake/unresolvable base ref yields the failure signal, not no-changes", () => { + // 40 hex chars that never existed in this repo. + const fake = "ffffffffffffffffffffffffffffffffffffffff"; + const result = getCommitRangeDiff(repoDir, fake); + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.error).toContain("cannot be resolved"); + } + }); + + test("error: a non-hex base ref is rejected before reaching the shell", () => { + const result = getCommitRangeDiff(repoDir, "HEAD~1; rm -rf /"); + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.error).toContain("invalid or stale base ref"); + } + }); + + test("no-changes: an empty range (base == HEAD) yields the no-changes signal", () => { + const head = execSync("git rev-parse HEAD", { + cwd: repoDir, + encoding: "utf-8", + }).trim(); + const result = getCommitRangeDiff(repoDir, head); + expect(result.kind).toBe("no-changes"); + }); +}); diff --git a/tests/gitignore-hygiene.test.ts b/tests/gitignore-hygiene.test.ts new file mode 100644 index 0000000..a113a4f --- /dev/null +++ b/tests/gitignore-hygiene.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { tempDir } from "./helpers"; +import { ensureRalpiIgnored } from "../src/utils"; + +// ─── Gitignore hygiene: ensureRalpiIgnored ────────────────────────────────── + +describe("ensureRalpiIgnored", () => { + it("creates .gitignore with .ralpi/ when absent in a git work tree", () => { + const { dir, cleanup } = tempDir(); + try { + fs.mkdirSync(path.join(dir, ".git")); + expect(ensureRalpiIgnored(dir)).toBe(true); + const content = fs.readFileSync(path.join(dir, ".gitignore"), "utf8"); + expect(content).toContain(".ralpi/"); + } finally { + cleanup(); + } + }); + + it("appends .ralpi/ to an existing .gitignore without the marker", () => { + const { dir, cleanup } = tempDir(); + try { + fs.mkdirSync(path.join(dir, ".git")); + fs.writeFileSync( + path.join(dir, ".gitignore"), + "node_modules/\n*.log\n", + "utf8", + ); + expect(ensureRalpiIgnored(dir)).toBe(true); + const content = fs.readFileSync(path.join(dir, ".gitignore"), "utf8"); + expect(content).toContain("node_modules/"); + expect(content).toContain(".ralpi/"); + } finally { + cleanup(); + } + }); + + it("leaves a .gitignore with the marker untouched", () => { + const { dir, cleanup } = tempDir(); + try { + fs.mkdirSync(path.join(dir, ".git")); + fs.writeFileSync(path.join(dir, ".gitignore"), ".ralpi/\n", "utf8"); + expect(ensureRalpiIgnored(dir)).toBe(false); + expect(fs.readFileSync(path.join(dir, ".gitignore"), "utf8")).toBe( + ".ralpi/\n", + ); + } finally { + cleanup(); + } + }); + + it("is a no-op outside a git work tree", () => { + const { dir, cleanup } = tempDir(); + try { + expect(ensureRalpiIgnored(dir)).toBe(false); + expect(fs.existsSync(path.join(dir, ".gitignore"))).toBe(false); + } finally { + cleanup(); + } + }); + + it("is memoized per project dir", () => { + const { dir, cleanup } = tempDir(); + try { + fs.mkdirSync(path.join(dir, ".git")); + expect(ensureRalpiIgnored(dir)).toBe(true); + // Second call: same dir already handled → no further work. + expect(ensureRalpiIgnored(dir)).toBe(false); + fs.writeFileSync(path.join(dir, ".gitignore"), "old\n", "utf8"); + expect(ensureRalpiIgnored(dir)).toBe(false); + expect(fs.readFileSync(path.join(dir, ".gitignore"), "utf8")).toBe( + "old\n", + ); + } finally { + cleanup(); + } + }); + + it("works when .git is a file (linked git worktree)", () => { + const { dir, cleanup } = tempDir(); + try { + fs.writeFileSync( + path.join(dir, ".git"), + "gitdir: /some/shared/repo\n", + "utf8", + ); + expect(ensureRalpiIgnored(dir)).toBe(true); + const content = fs.readFileSync(path.join(dir, ".gitignore"), "utf8"); + expect(content).toContain(".ralpi/"); + } finally { + cleanup(); + } + }); +}); \ No newline at end of file