feat: git ignore ralpi

This commit is contained in:
2026-08-09 15:34:12 -04:00
parent d31fca3cb3
commit 25e76679c5
5 changed files with 274 additions and 0 deletions

View File

@@ -290,3 +290,9 @@ in parallel mode).
.ralpi/prompts/ # Generated prompts (timestamped, for debugging) .ralpi/prompts/ # Generated prompts (timestamped, for debugging)
.ralpi/config.yaml # Project-level config (optional) .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`).

View File

@@ -34,6 +34,7 @@ import {
deleteLoopActive, deleteLoopActive,
readLoopActive, readLoopActive,
findRalpiDir, findRalpiDir,
ensureRalpiIgnored,
listPRDsSorted, listPRDsSorted,
countPRDResumeStats, countPRDResumeStats,
formatDuration, formatDuration,
@@ -43,6 +44,39 @@ type ExecutionMode = "parallel" | "sequential";
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── 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. * 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 * 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); const loopState = readLoopActive(projectDir);
if (!loopState) return; 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 // Load progress state
const progressPath = path.join(projectDir, ".ralpi", "progress.json"); const progressPath = path.join(projectDir, ".ralpi", "progress.json");
@@ -885,6 +923,7 @@ async function handleRun(
parentModel?: unknown, parentModel?: unknown,
parentThinkingLevel?: unknown, parentThinkingLevel?: unknown,
): Promise<void> { ): Promise<void> {
const noGitignore = stripNoGitignore(args);
const taskFile = resolveTaskArg(args[0] || "README.md", ctx.cwd); const taskFile = resolveTaskArg(args[0] || "README.md", ctx.cwd);
// If targeting a specific task file and there's existing progress for it, // 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; const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd;
ensureIgnoredNote(projectDir, ctx, noGitignore);
const project = parseTaskFile(taskFile); const project = parseTaskFile(taskFile);
const config = loadConfig(projectDir); const config = loadConfig(projectDir);
@@ -1200,6 +1240,7 @@ async function handleResume(
parentModel?: unknown, parentModel?: unknown,
parentThinkingLevel?: unknown, parentThinkingLevel?: unknown,
): Promise<void> { ): Promise<void> {
const noGitignore = stripNoGitignore(args);
let taskFile: string; let taskFile: string;
let projectDir: string; let projectDir: string;
let prdKey: string | undefined; let prdKey: string | undefined;
@@ -1266,6 +1307,8 @@ async function handleResume(
return undefined; return undefined;
})(); })();
ensureIgnoredNote(projectDir, ctx, noGitignore);
await resumeLoop( await resumeLoop(
ctx, ctx,
taskFile, taskFile,
@@ -1287,6 +1330,7 @@ async function handleReset(
ctx: ExtensionContext, ctx: ExtensionContext,
args: string[], args: string[],
): Promise<void> { ): Promise<void> {
const noGitignore = stripNoGitignore(args);
let sourcePath: string; let sourcePath: string;
let prdKey: string | undefined; let prdKey: string | undefined;
let progress: ProgressTracker; let progress: ProgressTracker;
@@ -1295,6 +1339,7 @@ async function handleReset(
const taskFile = resolveTaskArg(args[0], ctx.cwd); const taskFile = resolveTaskArg(args[0], ctx.cwd);
const found = findProgressFile(ctx.cwd, taskFile); const found = findProgressFile(ctx.cwd, taskFile);
const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd; const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd;
ensureIgnoredNote(projectDir, ctx, noGitignore);
sourcePath = taskFile; sourcePath = taskFile;
prdKey = found?.prdKey; prdKey = found?.prdKey;
progress = new ProgressTracker(projectDir, taskFile, prdKey); progress = new ProgressTracker(projectDir, taskFile, prdKey);
@@ -1309,6 +1354,8 @@ async function handleReset(
} }
const projectDir = path.dirname(path.dirname(found.path)); const projectDir = path.dirname(path.dirname(found.path));
ensureIgnoredNote(projectDir, ctx, noGitignore);
// Multiple loops may have progress — let the user select which one to // Multiple loops may have progress — let the user select which one to
// reset (sorted by most recent first), same as resume. // reset (sorted by most recent first), same as resume.
const selected = await selectPRD( const selected = await selectPRD(

View File

@@ -100,6 +100,49 @@ export function deleteLoopActive(projectDir: string): void {
} }
} }
// ─── Git Hygiene ────────────────────────────────────────────────────────────
const ralpiIgnoreMemo = new Set<string>();
/**
* 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/`. * Discover the project directory by walking up to find `.ralpi/`.
*/ */

View File

@@ -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");
});
});

View File

@@ -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();
}
});
});