From 4bf13760064ca7bbf28fbae2c3ad53f4ea1952ad Mon Sep 17 00:00:00 2001 From: Lloyd Engebretsen Date: Wed, 9 Sep 2026 19:48:50 -0400 Subject: [PATCH 1/4] feat: project-local jobs dir via relative jobsDir (auto git-exclude, config-change-safe log lookup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A relative jobsDir (any config layer or PI_BGRUN_DIR) now resolves against the session's project root, putting logs inside the workspace — reachable for project-sandboxed analysis tools (ctx_execute_file/ctx_index) and scoped per checkout. Absolute paths behave exactly as before (migration-safe). With no recognizable project root a relative path falls back to the global dir instead of scattering logs. Project-local dirs are auto-added to .git/info/exclude (local-only, linked-worktree aware) so logs never pollute git status. bgtail now prefers the session record's logPath, so jobs stay readable across mid-session config changes (e.g. switching to project-local after upgrade). --- README.md | 32 ++++++- extension/index.test.ts | 185 +++++++++++++++++++++++++++++++++++++++- extension/index.ts | 124 ++++++++++++++++++++++++--- skill/run-bg/SKILL.md | 5 +- 4 files changed, 332 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index fe53acb..a8e6126 100644 --- a/README.md +++ b/README.md @@ -118,11 +118,41 @@ config file (trusted projects only) ← environment variables**. } ``` +### Project-local logs + +A **relative** `jobsDir` (from any config layer, or `PI_BGRUN_DIR`) opts into +project-local logs: it resolves against the session's project root, so job logs +land inside the workspace — e.g. `"jobsDir": ".pi-bgrun/jobs"` in the project +config writes logs to `/.pi-bgrun/jobs`. + +Why you might want this: + +- Logs sit inside the project sandbox, so project-confined analysis tools + (e.g. context-mode's `ctx_execute_file` / `ctx_index`) can process whole logs + without pulling raw bytes into the context window. +- Each checkout/worktree gets its own logs — no cross-project clutter in the + shared dir. +- The dir is auto-added to the repo's `.git/info/exclude` (local-only — the + tracked `.gitignore` is never touched), so logs never pollute `git status`. + Works in linked worktrees too (`.git` file → pointed git dir). + +Rules and migration notes: + +- Absolute `jobsDir` values behave exactly as in older versions — nothing + moves, nothing breaks on upgrade. +- If the session cwd is not a recognizable project root (no `.git`/`.pi`), a + relative path falls back to the global dir rather than scattering logs + across arbitrary directories. +- Tools resolve a job's log from the session's job record first, so jobs + started before a config change stay readable after it. +- Existing logs in the old global dir are not migrated (they're ephemeral, + `cleanupDays`-retained); `bgclean all` sweeps them once you've switched. + Environment variables (same knobs, handy for one-off overrides): | Variable | Default | Description | | --- | --- | --- | -| `PI_BGRUN_DIR` | `~/.pi-bgrun/jobs` | Override where job logs are stored. | +| `PI_BGRUN_DIR` | `~/.pi-bgrun/jobs` | Override where job logs are stored. An absolute path is used as-is; a **relative** path resolves against the project root (see [project-local logs](#project-local-logs)), falling back to the default when there is no project root. | | `PI_BGRUN_FOREIGN_JOBS` | `false` | Adopt other sessions' running jobs into this session's widget and job list. Adopted jobs are polled so they leave the widget when they finish. | | `PI_BGRUN_SHOW_COMPLETED` | `false` | Include finished jobs in `bgstatus` listings by default. | | `PI_BGRUN_CLEANUP_DAYS` | `7` | Log retention for cleanup sweeps and the `bgclean` default. | diff --git a/extension/index.test.ts b/extension/index.test.ts index 484c47a..41b3f42 100644 --- a/extension/index.test.ts +++ b/extension/index.test.ts @@ -19,10 +19,11 @@ import { readFileSync, writeFileSync, existsSync, + mkdirSync, readdirSync, } from "node:fs"; import { join } from "node:path"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { pathToFileURL } from "node:url"; interface CapturedWake { @@ -30,7 +31,13 @@ interface CapturedWake { options?: Record; } -function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): { +function makeFakePi( + opts: { + idle?: boolean; + priorEntries?: any[]; + ctxFields?: Record; + } = {}, +): { pi: any; wakes: CapturedWake[]; entries: any[]; @@ -60,6 +67,7 @@ function makeFakePi(opts: { idle?: boolean; priorEntries?: any[] } = {}): { hasUI: false, ui: { notify() {}, setWidget() {}, setStatus() {} }, sessionManager: { getEntries: () => entries }, + ...(opts.ctxFields as Record | undefined), }; const pi = { sendUserMessage(text: string, options?: Record) { @@ -1770,3 +1778,176 @@ test("formatSince: same-day shows time only; older days include the date", async assert.match(prevYearStr, /Dec 30/); assert.match(prevYearStr, /08:00:00/); }); + +// ── Project-local jobs dir ────────────────────────────────────────────────── + +test("resolveJobsDirPath: relative resolves against a project root; absolute and no-root fall back", async () => { + const mod = await import( + pathToFileURL(join(process.cwd(), "extension/index.ts")).href + ); + const proj = mkdtempSync(join(tmpdir(), "pi-bgrun-proj-")); + const scratch = mkdtempSync(join(tmpdir(), "pi-bgrun-scratch-")); + try { + mkdirSync(join(proj, ".git"), { recursive: true }); + + // absolute → used as-is, never flagged project-local (older configs keep + // working unchanged — the migration guarantee) + const absPath = join(proj, "abs-jobs"); + const abs = mod.resolveJobsDirPath(absPath, { cwd: proj }); + assert.equal(abs.dir, absPath); + assert.equal(abs.projectLocal, false); + + // relative + project root → resolved against the root, flagged project-local + const rel = mod.resolveJobsDirPath(".pi-bgrun/jobs", { cwd: proj }); + assert.equal(rel.dir, join(proj, ".pi-bgrun", "jobs")); + assert.equal(rel.projectLocal, true); + + // unset → global default + const none = mod.resolveJobsDirPath(undefined, { cwd: proj }); + assert.equal(none.dir, join(homedir(), ".pi-bgrun", "jobs")); + assert.equal(none.projectLocal, false); + + // relative + cwd that is not a project → global fallback, never cwd-relative + const fb = mod.resolveJobsDirPath(".pi-bgrun/jobs", { cwd: scratch }); + assert.equal(fb.dir, join(homedir(), ".pi-bgrun", "jobs")); + assert.equal(fb.projectLocal, false); + } finally { + rmSync(proj, { recursive: true, force: true }); + rmSync(scratch, { recursive: true, force: true }); + } +}); + +test("ensureGitExcluded: appends the jobs dir pattern to .git/info/exclude once per dir", async () => { + const mod = await import( + pathToFileURL(join(process.cwd(), "extension/index.ts")).href + ); + const repo = mkdtempSync(join(tmpdir(), "pi-bgrun-repo-")); + try { + mkdirSync(join(repo, ".git", "info"), { recursive: true }); + mod.ensureGitExcluded(join(repo, ".pi-bgrun", "jobs")); + mod.ensureGitExcluded(join(repo, ".pi-bgrun", "jobs")); + // a second, different jobs dir under the same repo adds its own pattern + mod.ensureGitExcluded(join(repo, ".pi-bgrun", "other")); + const exclude = readFileSync(join(repo, ".git", "info", "exclude"), "utf8"); + assert.match(exclude, /# pi-bgrun job logs/); + assert.equal( + exclude.split("\n").filter((l) => l.trim() === ".pi-bgrun/jobs/").length, + 1, + "pattern appears exactly once", + ); + assert.ok(exclude.split("\n").includes(".pi-bgrun/other/")); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + +test("ensureGitExcluded: linked worktree (.git file) writes to the pointed git dir", async () => { + const mod = await import( + pathToFileURL(join(process.cwd(), "extension/index.ts")).href + ); + const wt = mkdtempSync(join(tmpdir(), "pi-bgrun-wt-")); + const gd = mkdtempSync(join(tmpdir(), "pi-bgrun-gitdir-")); + try { + writeFileSync(join(wt, ".git"), `gitdir: ${gd}\n`); + mod.ensureGitExcluded(join(wt, ".pi-bgrun", "jobs")); + const exclude = readFileSync(join(gd, "info", "exclude"), "utf8"); + assert.match(exclude, /^\.pi-bgrun\/jobs\/$/m); + // nothing was created inside the worktree's own .git (it's a file) + assert.ok(!existsSync(join(wt, ".git", "info"))); + } finally { + rmSync(wt, { recursive: true, force: true }); + rmSync(gd, { recursive: true, force: true }); + } +}); + +test("bgrun: relative jobsDir in project config → project-local log + auto git-exclude", async () => { + const proj = mkdtempSync(join(tmpdir(), "pi-bgrun-proj-")); + delete process.env.PI_BGRUN_DIR; + try { + mkdirSync(join(proj, ".git"), { recursive: true }); + mkdirSync(join(proj, ".pi"), { recursive: true }); + writeFileSync( + join(proj, ".pi", "pi-bgrun.json"), + JSON.stringify({ jobsDir: ".pi-bgrun/jobs" }), + ); + const { pi, wakes, tools, ctx } = makeFakePi({ + ctxFields: { cwd: proj, isProjectTrusted: () => true }, + }); + await loadExtension(pi); + const bgrun = tools.get("bgrun")!; + + const res = await bgrun.execute( + "call-1", + { command: "echo project-local" }, + undefined, + undefined, + ctx, + ); + const id = ((res.content[0].text as string).match( + /^started: ([^\n]+)/, + ) || [])[1]; + assert.ok(id, "got a job id"); + + await waitForWakes(wakes, 1); + + const logPath = join(proj, ".pi-bgrun", "jobs", `${id}.log`); + assert.ok(existsSync(logPath), "log written inside the project"); + assert.match(readFileSync(logPath, "utf8"), /project-local/); + + const exclude = join(proj, ".git", "info", "exclude"); + assert.ok(existsSync(exclude), "exclude file created"); + assert.match(readFileSync(exclude, "utf8"), /^\.pi-bgrun\/jobs\/$/m); + } finally { + delete process.env.PI_BGRUN_DIR; + rmSync(proj, { recursive: true, force: true }); + } +}); + +test("bgtail: prefers the session record's logPath when the jobsDir config changes", async () => { + const proj = mkdtempSync(join(tmpdir(), "pi-bgrun-proj-")); + delete process.env.PI_BGRUN_DIR; + try { + mkdirSync(join(proj, ".git"), { recursive: true }); + mkdirSync(join(proj, ".pi"), { recursive: true }); + writeFileSync( + join(proj, ".pi", "pi-bgrun.json"), + JSON.stringify({ jobsDir: ".pi-bgrun/jobs" }), + ); + const { pi, wakes, tools, ctx } = makeFakePi({ + ctxFields: { cwd: proj, isProjectTrusted: () => true }, + }); + await loadExtension(pi); + const bgrun = tools.get("bgrun")!; + const bgtail = tools.get("bgtail")!; + + const res = await bgrun.execute( + "call-1", + { command: "echo migrated-log" }, + undefined, + undefined, + ctx, + ); + const id = ((res.content[0].text as string).match( + /^started: ([^\n]+)/, + ) || [])[1]; + assert.ok(id, "got a job id"); + await waitForWakes(wakes, 1); + + // A ctx with no project config/trust now resolves the jobs dir to the + // GLOBAL default — only the session record's logPath can still find the + // log (the mid-upgrade config-change scenario). + const plainCtx = { ...ctx, cwd: undefined, isProjectTrusted: undefined }; + const tail = await bgtail.execute( + "call-2", + { id, lines: 10 }, + undefined, + undefined, + plainCtx, + ); + assert.equal(tail.details.notFound, false); + assert.match(tail.content[0].text as string, /migrated-log/); + } finally { + delete process.env.PI_BGRUN_DIR; + rmSync(proj, { recursive: true, force: true }); + } +}); diff --git a/extension/index.ts b/extension/index.ts index dd3a559..36107a9 100644 --- a/extension/index.ts +++ b/extension/index.ts @@ -34,17 +34,19 @@ import { Type } from "typebox"; import { Box, Text } from "@earendil-works/pi-tui"; import { spawn } from "node:child_process"; import { - openSync, + appendFileSync, closeSync, - readFileSync, + existsSync, mkdirSync, + openSync, + readFileSync, readdirSync, renameSync, - unlinkSync, statSync, + unlinkSync, writeFileSync, } from "node:fs"; -import { join } from "node:path"; +import { dirname, isAbsolute, join, relative, sep } from "node:path"; import { homedir } from "node:os"; // Exit marker appended to every log so the file is self-describing: the exit @@ -54,6 +56,7 @@ const EXIT_MARKER = "__BGRUN_EXIT__="; const DEFAULT_CLEANUP_DAYS = 7; const STALE_POLL_MS = 30_000; // re-check interval for jobs with no live child handle +const GLOBAL_JOBS_DIR = join(homedir(), ".pi-bgrun", "jobs"); // ── Configuration ─────────────────────────────────────────────────────────── // @@ -66,6 +69,10 @@ const STALE_POLL_MS = 30_000; // re-check interval for jobs with no live child h interface BgrunConfig { jobsDir: string; + // True when jobsDir came from a RELATIVE path resolved against the project + // root (project-local logs). Only then does bgrun auto-ignore the dir in + // .git/info/exclude — an absolute dir is the user's explicit choice. + jobsDirProjectLocal: boolean; // Adopt other sessions' running jobs (found in the shared jobs dir) into // this session's widget and job list. Default false — most sessions don't // want unrelated jobs from other projects cluttering the widget. @@ -112,6 +119,95 @@ function readConfigFile(path: string): BgrunConfigFile { return {}; } +// ── Project-local jobs dir ────────────────────────────────────────────────── +// +// A RELATIVE `jobsDir` (from any config layer, or PI_BGRUN_DIR) opts into +// project-local logs: it resolves against the session's project root, so logs +// land inside the workspace. That keeps them within the project sandbox — +// analysis tools confined to the project root (e.g. context-mode's +// ctx_execute_file/ctx_index) can then process whole logs without flooding +// context. Absolute paths behave exactly as in older versions +// (migration-safe), and with no recognizable project root a relative path +// falls back to the global dir instead of scattering logs across whatever +// directory pi happened to start in. + +function isProjectRootLike(dir: string): boolean { + // Cheap heuristic: a directory holding .git or pi's config dir is a project. + return ( + existsSync(join(dir, ".git")) || existsSync(join(dir, CONFIG_DIR_NAME)) + ); +} + +export function resolveJobsDirPath( + raw: string | undefined, + ctx?: { cwd?: string }, +): { dir: string; projectLocal: boolean } { + if (!raw) return { dir: GLOBAL_JOBS_DIR, projectLocal: false }; + if (isAbsolute(raw)) return { dir: raw, projectLocal: false }; + const root = ctx?.cwd ?? process.cwd(); + if (!root || !isProjectRootLike(root)) { + return { dir: GLOBAL_JOBS_DIR, projectLocal: false }; + } + return { dir: join(root, raw), projectLocal: true }; +} + +// Auto-ignore a project-local jobs dir in git so logs never pollute +// `git status`: appends the dir pattern to the enclosing repo's +// .git/info/exclude (local-only — the tracked .gitignore is never touched). +// Memoized per dir; every step is best-effort and must never fail a bgrun. +const gitExcludedDirs = new Set(); + +export function ensureGitExcluded(jobsDir: string): void { + if (gitExcludedDirs.has(jobsDir)) return; + gitExcludedDirs.add(jobsDir); // set first — best-effort, never retry-spam + try { + // Walk up from jobsDir to the enclosing work tree. + let cur = jobsDir; + for (;;) { + const dot = join(cur, ".git"); + if (existsSync(dot)) { + appendExcludePattern(cur, dot, jobsDir); + return; + } + const parent = dirname(cur); + if (parent === cur) return; // filesystem root — not inside a work tree + cur = parent; + } + } catch { + // best-effort — ignore hygiene must never break job creation + } +} + +function appendExcludePattern( + repoRoot: string, + dotGit: string, + jobsDir: string, +): void { + if (jobsDir === repoRoot) return; // can't exclude the whole repo + // `.git` is a directory in a normal checkout, or a file pointing at the + // real git dir in linked worktrees (git worktree add) and submodules. + let gitDir = dotGit; + if (statSync(dotGit).isFile()) { + const m = readFileSync(dotGit, "utf8").match(/^gitdir:\s*(\S+)/); + if (!m) return; + gitDir = m[1]; + } + const pattern = relative(repoRoot, jobsDir).split(sep).join("/") + "/"; + const excludePath = join(gitDir, "info", "exclude"); + let existing = ""; + try { + existing = readFileSync(excludePath, "utf8"); + } catch { + // no exclude file yet — we'll create it + } + if (existing.split("\n").some((l) => l.trim() === pattern)) return; + mkdirSync(join(gitDir, "info"), { recursive: true }); + appendFileSync( + excludePath, + `\n# pi-bgrun job logs (auto-added)\n${pattern}\n`, + ); +} + // Resolved per call (cheap: at most two small file reads) so env/config // changes are picked up without module reloads — and tests can isolate. function resolveConfig(ctx?: { @@ -154,11 +250,11 @@ function resolveConfig(ctx?: { : undefined; const envDays = Number(process.env.PI_BGRUN_CLEANUP_DAYS); const daysEnv = Number.isFinite(envDays) && envDays > 0 ? envDays : undefined; + const { dir: jobsDir, projectLocal: jobsDirProjectLocal } = + resolveJobsDirPath(process.env.PI_BGRUN_DIR || dirFile, ctx); return { - jobsDir: - process.env.PI_BGRUN_DIR || - dirFile || - join(homedir(), ".pi-bgrun", "jobs"), + jobsDir, + jobsDirProjectLocal, adoptForeignJobs: parseBoolEnv(process.env.PI_BGRUN_FOREIGN_JOBS) ?? foreignFile ?? false, showCompletedJobs: @@ -733,7 +829,11 @@ export default function (pi: ExtensionAPI) { } const name = sanitizeName(rawName); - const jobsDir = resolveConfig(ctx).jobsDir; + const cfg = resolveConfig(ctx); + // Project-local logs are auto-ignored in .git/info/exclude (best-effort) + // so they never pollute `git status`. Absolute dirs are left untouched. + if (cfg.jobsDirProjectLocal) ensureGitExcluded(cfg.jobsDir); + const jobsDir = cfg.jobsDir; mkdirSync(jobsDir, { recursive: true }); const slug = makeSlug(name ?? command); @@ -962,7 +1062,11 @@ export default function (pi: ExtensionAPI) { }> { const { id, lines = 40, raw = false } = params; if (!id) throw new Error("bgtail: id is required"); - const logPath = join(resolveConfig(ctx).jobsDir, `${id}.log`); + // Prefer this session's record: its logPath stays correct even if the + // config (and thus the resolved jobs dir) changes mid-session — e.g. a + // user switching to project-local logs right after upgrading. + const logPath = + jobs.get(id)?.logPath ?? join(resolveConfig(ctx).jobsDir, `${id}.log`); try { const content = readFileSync(logPath, "utf8"); const all = content diff --git a/skill/run-bg/SKILL.md b/skill/run-bg/SKILL.md index 203c807..c16df2a 100644 --- a/skill/run-bg/SKILL.md +++ b/skill/run-bg/SKILL.md @@ -85,7 +85,10 @@ no polling. - Call the tools; never hand-roll `nohup … &` inline. - One job = one id. Multiple concurrent jobs are fine — each has its own log. -- Logs live in `~/.pi-bgrun/jobs` (override with `PI_BGRUN_DIR`). +- Logs live in `~/.pi-bgrun/jobs` (override with `PI_BGRUN_DIR`). A **relative** + `jobsDir` in the project config (e.g. `.pi-bgrun/jobs`) puts logs inside the + project — auto-ignored via `.git/info/exclude` — which keeps them reachable + for project-sandboxed analysis tools like `ctx_execute_file`. - Cleanup: `bgclean` removes only THIS session's old logs; `bgclean all` sweeps every session's. Auto-sweeps at session start/shutdown are session-scoped plus a global orphan pass (default on — removes finished From db0547958191d1316d92f04d2a7c4e7e0e01fbb5 Mon Sep 17 00:00:00 2001 From: Lloyd Engebretsen Date: Wed, 9 Sep 2026 19:54:10 -0400 Subject: [PATCH 2/4] chore: formatter pass on new tests; language-tag SKILL.md example fence (MD040) --- extension/index.test.ts | 10 ++++------ skill/run-bg/SKILL.md | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/extension/index.test.ts b/extension/index.test.ts index 41b3f42..62501ea 100644 --- a/extension/index.test.ts +++ b/extension/index.test.ts @@ -1883,9 +1883,8 @@ test("bgrun: relative jobsDir in project config → project-local log + auto git undefined, ctx, ); - const id = ((res.content[0].text as string).match( - /^started: ([^\n]+)/, - ) || [])[1]; + const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) || + [])[1]; assert.ok(id, "got a job id"); await waitForWakes(wakes, 1); @@ -1927,9 +1926,8 @@ test("bgtail: prefers the session record's logPath when the jobsDir config chang undefined, ctx, ); - const id = ((res.content[0].text as string).match( - /^started: ([^\n]+)/, - ) || [])[1]; + const id = ((res.content[0].text as string).match(/^started: ([^\n]+)/) || + [])[1]; assert.ok(id, "got a job id"); await waitForWakes(wakes, 1); diff --git a/skill/run-bg/SKILL.md b/skill/run-bg/SKILL.md index c16df2a..8f6d3ce 100644 --- a/skill/run-bg/SKILL.md +++ b/skill/run-bg/SKILL.md @@ -54,7 +54,7 @@ no polling. - **Quick peek (≤40 lines):** call `bgtail` with the job id and `lines: 40` — strips the `__BGRUN_EXIT__` marker. - **Whole-log failure analysis:** `ctx_execute_file` on the log path: - ``` + ```javascript ctx_execute_file( path: "~/.pi-bgrun/jobs/.log", language: "javascript", From 328a443d3e2458ea6f773c3d814f32833911590a Mon Sep 17 00:00:00 2001 From: Lloyd Engebretsen Date: Wed, 9 Sep 2026 20:04:45 -0400 Subject: [PATCH 3/4] fix: exclude-write retries on transient failure; gitdir pointers with spaces ensureGitExcluded now memoizes only on success: a failed attempt (unwritable exclude file, unparseable .git pointer) is retried on the next bgrun instead of being permanently skipped. The gitdir pointer regex accepts paths containing spaces (was \S+, which truncated at the first space). --- extension/index.test.ts | 43 +++++++++++++++++++++++++++++++++++++++++ extension/index.ts | 41 +++++++++++++++++++++++++-------------- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/extension/index.test.ts b/extension/index.test.ts index 62501ea..1d3fe99 100644 --- a/extension/index.test.ts +++ b/extension/index.test.ts @@ -1860,6 +1860,49 @@ test("ensureGitExcluded: linked worktree (.git file) writes to the pointed git d } }); +test("ensureGitExcluded: gitdir pointer with spaces in the path", async () => { + const mod = await import( + pathToFileURL(join(process.cwd(), "extension/index.ts")).href + ); + const wt = mkdtempSync(join(tmpdir(), "pi-bgrun-wt-")); + const gd = join(tmpdir(), "pi-bgrun git dir with spaces"); + mkdirSync(gd, { recursive: true }); + try { + writeFileSync(join(wt, ".git"), `gitdir: ${gd}\n`); + assert.equal( + mod.ensureGitExcluded(join(wt, ".pi-bgrun", "jobs")), + true, + ); + const exclude = readFileSync(join(gd, "info", "exclude"), "utf8"); + assert.match(exclude, /^\.pi-bgrun\/jobs\/$/m); + } finally { + rmSync(wt, { recursive: true, force: true }); + rmSync(gd, { recursive: true, force: true }); + } +}); + +test("ensureGitExcluded: retries after a transient failure — memoizes only on success", async () => { + const mod = await import( + pathToFileURL(join(process.cwd(), "extension/index.ts")).href + ); + const repo = mkdtempSync(join(tmpdir(), "pi-bgrun-repo-")); + try { + mkdirSync(join(repo, ".git", "info"), { recursive: true }); + // Block the exclude path with a directory → the append fails (EISDIR) + mkdirSync(join(repo, ".git", "info", "exclude")); + const jobsDir = join(repo, ".pi-bgrun", "jobs"); + assert.equal(mod.ensureGitExcluded(jobsDir), false); + + // Unblock: the next call must retry (failure was not memoized) and succeed + rmSync(join(repo, ".git", "info", "exclude"), { recursive: true }); + assert.equal(mod.ensureGitExcluded(jobsDir), true); + const exclude = readFileSync(join(repo, ".git", "info", "exclude"), "utf8"); + assert.match(exclude, /^\.pi-bgrun\/jobs\/$/m); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +}); + test("bgrun: relative jobsDir in project config → project-local log + auto git-exclude", async () => { const proj = mkdtempSync(join(tmpdir(), "pi-bgrun-proj-")); delete process.env.PI_BGRUN_DIR; diff --git a/extension/index.ts b/extension/index.ts index 36107a9..153797e 100644 --- a/extension/index.ts +++ b/extension/index.ts @@ -154,27 +154,37 @@ export function resolveJobsDirPath( // Auto-ignore a project-local jobs dir in git so logs never pollute // `git status`: appends the dir pattern to the enclosing repo's // .git/info/exclude (local-only — the tracked .gitignore is never touched). -// Memoized per dir; every step is best-effort and must never fail a bgrun. +// Memoized only on SUCCESS — a transient failure (unwritable exclude file, +// .git appearing later) is retried on the next bgrun. Every step is +// best-effort and must never fail a bgrun. const gitExcludedDirs = new Set(); -export function ensureGitExcluded(jobsDir: string): void { - if (gitExcludedDirs.has(jobsDir)) return; - gitExcludedDirs.add(jobsDir); // set first — best-effort, never retry-spam +// Returns true when the dir is settled (pattern written, already present, or +// legitimately nothing to do — no repo above, dir is the repo root itself). +// False only on failure, so the caller retries next time. +export function ensureGitExcluded(jobsDir: string): boolean { + if (gitExcludedDirs.has(jobsDir)) return true; + if (tryEnsureGitExcluded(jobsDir)) { + gitExcludedDirs.add(jobsDir); + return true; + } + return false; +} + +function tryEnsureGitExcluded(jobsDir: string): boolean { try { // Walk up from jobsDir to the enclosing work tree. let cur = jobsDir; for (;;) { const dot = join(cur, ".git"); - if (existsSync(dot)) { - appendExcludePattern(cur, dot, jobsDir); - return; - } + if (existsSync(dot)) return appendExcludePattern(cur, dot, jobsDir); const parent = dirname(cur); - if (parent === cur) return; // filesystem root — not inside a work tree + if (parent === cur) return true; // filesystem root — no repo above; nothing to do cur = parent; } } catch { // best-effort — ignore hygiene must never break job creation + return false; } } @@ -182,15 +192,15 @@ function appendExcludePattern( repoRoot: string, dotGit: string, jobsDir: string, -): void { - if (jobsDir === repoRoot) return; // can't exclude the whole repo +): boolean { + if (jobsDir === repoRoot) return true; // can't exclude the whole repo; nothing to do // `.git` is a directory in a normal checkout, or a file pointing at the // real git dir in linked worktrees (git worktree add) and submodules. let gitDir = dotGit; if (statSync(dotGit).isFile()) { - const m = readFileSync(dotGit, "utf8").match(/^gitdir:\s*(\S+)/); - if (!m) return; - gitDir = m[1]; + const m = readFileSync(dotGit, "utf8").match(/^gitdir:\s*(.+)$/m); + if (!m) return false; // unparseable .git file — retry later + gitDir = m[1].trim(); } const pattern = relative(repoRoot, jobsDir).split(sep).join("/") + "/"; const excludePath = join(gitDir, "info", "exclude"); @@ -200,12 +210,13 @@ function appendExcludePattern( } catch { // no exclude file yet — we'll create it } - if (existing.split("\n").some((l) => l.trim() === pattern)) return; + if (existing.split("\n").some((l) => l.trim() === pattern)) return true; mkdirSync(join(gitDir, "info"), { recursive: true }); appendFileSync( excludePath, `\n# pi-bgrun job logs (auto-added)\n${pattern}\n`, ); + return true; } // Resolved per call (cheap: at most two small file reads) so env/config From 4c95f3ecfe455328eacdcf0eed1e4e68549d5938 Mon Sep 17 00:00:00 2001 From: Lloyd Engebretsen Date: Thu, 10 Sep 2026 10:01:44 -0400 Subject: [PATCH 4/4] chore: formatter reflow in spaces-path test --- extension/index.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/extension/index.test.ts b/extension/index.test.ts index 1d3fe99..e3f8351 100644 --- a/extension/index.test.ts +++ b/extension/index.test.ts @@ -1869,10 +1869,7 @@ test("ensureGitExcluded: gitdir pointer with spaces in the path", async () => { mkdirSync(gd, { recursive: true }); try { writeFileSync(join(wt, ".git"), `gitdir: ${gd}\n`); - assert.equal( - mod.ensureGitExcluded(join(wt, ".pi-bgrun", "jobs")), - true, - ); + assert.equal(mod.ensureGitExcluded(join(wt, ".pi-bgrun", "jobs")), true); const exclude = readFileSync(join(gd, "info", "exclude"), "utf8"); assert.match(exclude, /^\.pi-bgrun\/jobs\/$/m); } finally {