UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

167 lines 6.57 kB
/** * Project-local .gitignore management. * * AIWG-managed projects historically `.gitignore` the whole `.aiwg/` tree * because most of it is generated state (working scratch, ralph state, * research corpora, etc.). With #1033's project-local artifact lifecycle, * `.aiwg/{addons,extensions,frameworks,plugins}/` is now also operator- * authored source — and a blanket ignore silently drops it from version * control. * * This module provides: * * - `detectAiwgBlanketIgnore()` — read .gitignore, classify the rules * - `appendAiwgSourceTrackBlock()` — append the canonical un-ignore * block (sentinel-marked, idempotent) * - `checkBundleManifestIgnored()` — per-bundle: is its manifest.json * git-ignored right now? * * @implements #1085 */ import { readFile, writeFile, access } from 'fs/promises'; import { join } from 'path'; import { execFile } from 'child_process'; import { promisify } from 'util'; const execFileP = promisify(execFile); /** * Sentinel comment that marks the AIWG-managed un-ignore block. * * We detect existing blocks by this exact line so re-running new-bundle * never produces duplicate blocks. */ export const AIWG_GITIGNORE_SENTINEL = '# AIWG project-local bundle source — track these (managed by AIWG)'; /** * The canonical block we append when a project blanket-ignores `.aiwg/` * but doesn't yet un-ignore the source directories. */ export const AIWG_GITIGNORE_BLOCK = [ '', AIWG_GITIGNORE_SENTINEL, '!.aiwg/aiwg.config', '!.aiwg/addons/', '!.aiwg/extensions/', '!.aiwg/frameworks/', '!.aiwg/plugins/', '', ].join('\n'); /** * Inspect a project's `.gitignore` and classify it for our purposes. * * Detection rules — only the patterns we actually need: * * blanket ignore: a non-negation, non-comment line that matches the * `.aiwg/` directory directly. Specifically: `.aiwg`, `.aiwg/`, * `.aiwg/*`, `.aiwg/**`, or path-anchored variants thereof. * * existing negation: a line beginning with `!` that points into one of * our four source directories. e.g. `!.aiwg/addons/` or * `!.aiwg/extensions/foo/`. */ export async function detectAiwgBlanketIgnore(projectDir) { const path = join(projectDir, '.gitignore'); let raw; try { raw = await readFile(path, 'utf8'); } catch { return { blanketIgnore: false, hasExistingNegation: false, hasManagedBlock: false, gitignoreExists: false, }; } const lines = raw.split(/\r?\n/); const stripped = lines.map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('#')); const isBlanket = (l) => { if (l.startsWith('!')) return false; // Strip a leading slash so `/.aiwg/` and `.aiwg/` are equivalent. const norm = l.replace(/^\//, ''); return norm === '.aiwg' || norm === '.aiwg/' || norm === '.aiwg/*' || norm === '.aiwg/**'; }; const isSourceNegation = (l) => { if (!l.startsWith('!')) return false; // `.aiwg/addons/`, `.aiwg/extensions/foo`, `/.aiwg/plugins/**`, etc. const body = l.slice(1).replace(/^\//, ''); return /^\.aiwg\/(addons|extensions|frameworks|plugins)(\/.*)?$/.test(body); }; const blanketIgnore = stripped.some(isBlanket); const hasExistingNegation = stripped.some(isSourceNegation); const hasManagedBlock = lines.some(l => l.trim() === AIWG_GITIGNORE_SENTINEL); return { blanketIgnore, hasExistingNegation, hasManagedBlock, gitignoreExists: true }; } /** * Append the canonical un-ignore block to `.gitignore` if and only if: * * - `.gitignore` exists, AND * - it blanket-ignores `.aiwg/`, AND * - it does NOT already have any source-directory negation, AND * - it does NOT already have our sentinel-marked block. * * The function is idempotent in all four conditions. No-op when the * project doesn't blanket-ignore (operator has presumably configured a * selective ignore already). */ export async function appendAiwgSourceTrackBlock(projectDir) { const report = await detectAiwgBlanketIgnore(projectDir); if (!report.gitignoreExists) { return { added: false, reason: 'no .gitignore — skipped' }; } if (!report.blanketIgnore) { return { added: false, reason: 'no blanket .aiwg ignore — already tracking sources' }; } // Check managed-block presence BEFORE generic negation detection — once // we've added our block, it itself contains source-directory negations, // so the existing-negation check would otherwise mis-fire on subsequent // runs. if (report.hasManagedBlock) { return { added: false, reason: 'block already present — no change' }; } if (report.hasExistingNegation) { return { added: false, reason: 'operator has explicit !.aiwg negation — no change' }; } const path = join(projectDir, '.gitignore'); const existing = await readFile(path, 'utf8'); const sep = existing.endsWith('\n') ? '' : '\n'; await writeFile(path, existing + sep + AIWG_GITIGNORE_BLOCK, 'utf8'); return { added: true, reason: 'appended .aiwg/{addons,extensions,frameworks,plugins} un-ignore block' }; } /** * Per-bundle: is its `manifest.json` currently ignored by git? * * Uses `git check-ignore` which is authoritative — it reflects every * `.gitignore` precedence rule, including negations and core.excludesFile. * * Returns `null` when git isn't installed or the project isn't a git * repo. Caller should treat null as "skip the check". */ export async function checkBundleManifestIgnored(projectDir, manifestRelPath) { // Quick sanity: ensure path doesn't try to escape projectDir if (manifestRelPath.startsWith('/') || manifestRelPath.includes('..')) { return null; } try { await access(join(projectDir, '.git')); } catch { return null; } try { // `git check-ignore -q <path>` returns exit 0 if path is ignored, // 1 if not ignored, 128 on error. We want a tristate. await execFileP('git', ['check-ignore', '-q', manifestRelPath], { cwd: projectDir, }); // exit 0 → ignored return true; } catch (err) { const code = err.code; if (code === 1) return false; // not ignored return null; // 128 or other — skip the check } } //# sourceMappingURL=project-local-gitignore.js.map