feat: git ignore ralpi
This commit is contained in:
@@ -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`).
|
||||
|
||||
47
index.ts
47
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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(
|
||||
|
||||
43
src/utils.ts
43
src/utils.ts
@@ -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/`.
|
||||
*/
|
||||
|
||||
82
tests/commit-range-diff.test.ts
Normal file
82
tests/commit-range-diff.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
96
tests/gitignore-hygiene.test.ts
Normal file
96
tests/gitignore-hygiene.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user