diff --git a/specs/SPEC.md b/specs/SPEC.md index 246d7a9..07982e6 100644 --- a/specs/SPEC.md +++ b/specs/SPEC.md @@ -85,6 +85,29 @@ headers = { X-Api-Key = "${API_KEY}" } | `skills` | No | Skill dependencies (array of tables). | | `mcp` | No | MCP server declarations (array of tables). Generates agent-specific config files during install/sync. | | `trust` | No | Trusted source restrictions. When absent, all sources allowed. See `[trust]` below. | +| `update` | No | Update policy configuration. See `[update]` below. | + +#### `[update]` + +Optional section to control how skills are updated. + +```toml +[update] +minimum_release_age = 4320 # 3 days in minutes +exclude = ["myorg", "myorg/internal-skills"] +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `minimum_release_age` | No | Minimum age in **minutes** a commit must have before it's eligible for install. Applies to all git skills (pinned and unpinned). For unpinned skills, resolves to the newest qualifying commit. For pinned skills (`ref`), rejects if the pinned commit is too new. Install fails with an error if no qualifying commit exists. When absent, current behavior — always use HEAD. | +| `exclude` | No | Sources excluded from the age gate. Accepts org names (`"myorg"` matches all repos), org/repo (`"myorg/skills"` exact match), or org wildcards (`"myorg/*"`). Defaults to `[]`. | + +**Semantics:** +- `[update]` absent → no age gating (default behavior) +- `minimum_release_age` set → for git skills, enforce commit age. Unpinned skills resolve to the newest qualifying commit; pinned skills error if the ref is too new +- `exclude` → listed sources bypass the age gate entirely (useful for internal/trusted repos) +- Local skills (`path:`) and well-known skills are unaffected +- The age check uses the git committer date, which reflects when code landed on the branch #### `[trust]` @@ -322,6 +345,7 @@ source = "path:../shared-skills/my-custom-skill" | `resolved_url` | Git and well-known sources | Resolved clone URL or HTTP base URL. | | `resolved_path` | Git sources | Subdirectory within the repo where the skill was discovered. | | `resolved_ref` | Git sources (optional) | The ref that was resolved (tag/branch name). Omitted when using default branch. | +| `resolved_commit` | Git sources (optional) | Full 40-char commit SHA that was installed. **Informational only** — not used for resolution. The lockfile is not checked in, so this field must never be relied on for locking behavior. | --- diff --git a/src/cli/commands/install.test.ts b/src/cli/commands/install.test.ts index 8fb21cf..ad8a156 100644 --- a/src/cli/commands/install.test.ts +++ b/src/cli/commands/install.test.ts @@ -83,8 +83,8 @@ describe("runInstall", () => { expect(lockfile).not.toBeNull(); expect(lockfile!.skills["pdf"]).toBeDefined(); expect(lockfile!.skills["pdf"]!.source).toBeDefined(); - // No commit or integrity fields in v1 - expect("commit" in lockfile!.skills["pdf"]!).toBe(false); + // resolved_commit is informational, should be present for git skills + expect("resolved_commit" in lockfile!.skills["pdf"]!).toBe(true); expect("integrity" in lockfile!.skills["pdf"]!).toBe(false); }); @@ -553,4 +553,94 @@ describe("runInstall", () => { // Should not auto-create .gitignore (only init does that) expect(existsSync(join(projectRoot, ".gitignore"))).toBe(false); }); + + it("minimum_release_age resolves to an older commit when HEAD is too new", async () => { + // Create an old commit (backdated) then a new one + await exec("git", ["rm", "-rf", "pdf", "skills"], { cwd: repoDir }); + await exec("git", ["commit", "-m", "clear"], { cwd: repoDir }); + + // Create the old commit with a backdated author date + await mkdir(join(repoDir, "pdf"), { recursive: true }); + await writeFile(join(repoDir, "pdf", "SKILL.md"), SKILL_MD("pdf")); + await exec("git", ["add", "."], { cwd: repoDir }); + await exec("git", ["commit", "-m", "old commit", "--date", "2020-01-01T00:00:00"], { + cwd: repoDir, + env: { ...process.env, GIT_COMMITTER_DATE: "2020-01-01T00:00:00" }, + }); + + const { stdout: oldSha } = await exec("git", ["rev-parse", "HEAD"], { cwd: repoDir }); + + // Create a brand new commit (today) + await writeFile(join(repoDir, "pdf", "SKILL.md"), `${SKILL_MD("pdf")}\nupdated`); + await exec("git", ["add", "."], { cwd: repoDir }); + await exec("git", ["commit", "-m", "new commit"], { cwd: repoDir }); + + const { stdout: newSha } = await exec("git", ["rev-parse", "HEAD"], { cwd: repoDir }); + expect(oldSha.trim()).not.toBe(newSha.trim()); + + // Install with minimum_release_age = 1 — should skip the brand-new commit and use the old one + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1\n\n[update]\nminimum_release_age = 1\n\n[[skills]]\nname = "pdf"\nsource = "git:${repoDir}"\n`, + ); + + const scope = resolveScope("project", projectRoot); + const result = await runInstall({ scope }); + expect(result.installed).toContain("pdf"); + + const lockfile = await loadLockfile(join(projectRoot, "agents.lock")); + expect(lockfile).not.toBeNull(); + expect(lockfile!.skills["pdf"]!).toBeDefined(); + // Should have resolved to the old commit, not HEAD + const locked = lockfile!.skills["pdf"]! as { resolved_commit?: string }; + expect(locked.resolved_commit).toBe(oldSha.trim()); + }); + + it("minimum_release_age throws when repo is younger than threshold", async () => { + // All commits in repoDir are recent (created in beforeEach) + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1\n\n[update]\nminimum_release_age = 9999\n\n[[skills]]\nname = "pdf"\nsource = "git:${repoDir}"\n`, + ); + + const scope = resolveScope("project", projectRoot); + await expect(runInstall({ scope })).rejects.toThrow(/minimum_release_age/); + }); + + it("minimum_release_age rejects pinned skills that are too new", async () => { + const { stdout: sha } = await exec("git", ["rev-parse", "HEAD"], { cwd: repoDir }); + + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1\n\n[update]\nminimum_release_age = 9999\n\n[[skills]]\nname = "pdf"\nsource = "git:${repoDir}"\nref = "${sha.trim()}"\n`, + ); + + const scope = resolveScope("project", projectRoot); + await expect(runInstall({ scope })).rejects.toThrow(/minimum_release_age/); + }); + + it("exclude bypasses minimum_release_age for matching sources", async () => { + // All commits are recent, but the source is excluded — should install fine + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1\n\n[update]\nminimum_release_age = 9999\nexclude = ["git:${repoDir}"]\n\n[[skills]]\nname = "pdf"\nsource = "git:${repoDir}"\n`, + ); + + const scope = resolveScope("project", projectRoot); + const result = await runInstall({ scope }); + expect(result.installed).toContain("pdf"); + }); + + it("exclude with org pattern bypasses minimum_release_age", async () => { + // Use a GitHub-style source with an org exclude + await writeFile( + join(projectRoot, "agents.toml"), + `version = 1\n\n[update]\nminimum_release_age = 9999\nexclude = ["myorg"]\n\n[[skills]]\nname = "pdf"\nsource = "myorg/skills"\n`, + ); + + // This will fail to clone (myorg/skills doesn't exist), but it should fail + // at clone time, not at the age gate — proving the exclude is working. + const scope = resolveScope("project", projectRoot); + await expect(runInstall({ scope })).rejects.toThrow(/clone|resolve/i); + }); }); diff --git a/src/cli/commands/install.ts b/src/cli/commands/install.ts index a7ad21f..722995f 100644 --- a/src/cli/commands/install.ts +++ b/src/cli/commands/install.ts @@ -72,7 +72,7 @@ async function expandSkills( defaultRepositorySource: RepositorySource; }, lockfile: Lockfile | null, - opts: { frozen?: boolean; force?: boolean; projectRoot: string }, + opts: { frozen?: boolean; force?: boolean; projectRoot: string; minimumReleaseAge?: number; updateExclude?: string[] }, ): Promise { const regularDeps = config.skills.filter((d) => !isWildcardDep(d)); const wildcardDeps = config.skills.filter(isWildcardDep); @@ -106,11 +106,19 @@ async function expandSkills( expanded.push({ name, dep: wDep }); } } else { - const named = await resolveWildcardSkills(wDep, { - projectRoot: opts.projectRoot, - ...(opts.force ? { ttlMs: 0 } : {}), - defaultRepositorySource: config.defaultRepositorySource, - }); + let named; + try { + named = await resolveWildcardSkills(wDep, { + projectRoot: opts.projectRoot, + ...(opts.force ? { ttlMs: 0 } : {}), + defaultRepositorySource: config.defaultRepositorySource, + minimumReleaseAge: opts.minimumReleaseAge, + updateExclude: opts.updateExclude, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new InstallError(`Failed to resolve wildcard source "${wDep.source}": ${msg}`); + } for (const { name, resolved } of named) { if (explicitNames.has(name)) {continue;} // explicit wins @@ -153,6 +161,8 @@ export async function runInstall(opts: InstallOptions): Promise { throw new InstallError("--frozen requires agents.lock to exist."); } + const minimumReleaseAge = config.update?.minimum_release_age; + const updateExclude = config.update?.exclude; const expanded = await expandSkills( { skills: config.skills, @@ -160,7 +170,7 @@ export async function runInstall(opts: InstallOptions): Promise { defaultRepositorySource: config.defaultRepositorySource, }, lockfile, - { frozen, force, projectRoot: scope.root }, + { frozen, force, projectRoot: scope.root, minimumReleaseAge, updateExclude }, ); if (frozen) { @@ -189,6 +199,8 @@ export async function runInstall(opts: InstallOptions): Promise { projectRoot: scope.root, ...(force ? { ttlMs: 0 } : {}), defaultRepositorySource: config.defaultRepositorySource, + minimumReleaseAge, + updateExclude, }; let resolved: ResolvedSkill; @@ -217,6 +229,7 @@ export async function runInstall(opts: InstallOptions): Promise { resolved_url: resolved.resolvedUrl, resolved_path: resolved.resolvedPath, ...(resolved.resolvedRef ? { resolved_ref: resolved.resolvedRef } : {}), + resolved_commit: resolved.commit, }; } else if (resolved.type === "well-known") { lockEntry = { diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index d295812..dc2d8d9 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -383,6 +383,73 @@ describe("agentsConfigSchema", () => { }); }); + describe("update section", () => { + it("is undefined when absent", () => { + const result = agentsConfigSchema.safeParse({ version: 1 }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.update).toBeUndefined(); + } + }); + + it("parses minimum_release_age", () => { + const result = agentsConfigSchema.safeParse({ + version: 1, + update: { minimum_release_age: 4320 }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.update?.minimum_release_age).toBe(4320); + } + }); + + it("accepts minimum_release_age = 0", () => { + const result = agentsConfigSchema.safeParse({ + version: 1, + update: { minimum_release_age: 0 }, + }); + expect(result.success).toBe(true); + }); + + it("rejects negative minimum_release_age", () => { + const result = agentsConfigSchema.safeParse({ + version: 1, + update: { minimum_release_age: -1 }, + }); + expect(result.success).toBe(false); + }); + + it("rejects non-integer minimum_release_age", () => { + const result = agentsConfigSchema.safeParse({ + version: 1, + update: { minimum_release_age: 1.5 }, + }); + expect(result.success).toBe(false); + }); + + it("parses exclude list", () => { + const result = agentsConfigSchema.safeParse({ + version: 1, + update: { minimum_release_age: 4320, exclude: ["myorg", "other/repo"] }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.update?.exclude).toEqual(["myorg", "other/repo"]); + } + }); + + it("defaults exclude to empty array", () => { + const result = agentsConfigSchema.safeParse({ + version: 1, + update: { minimum_release_age: 4320 }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.update?.exclude).toEqual([]); + } + }); + }); + describe("backward compatibility", () => { it("parses config without agents or mcp fields", () => { const result = agentsConfigSchema.safeParse({ diff --git a/src/config/schema.ts b/src/config/schema.ts index 0afb187..6895be0 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -146,6 +146,13 @@ const hookSchema = z.object({ export type HookConfig = z.infer; +const updateConfigSchema = z.object({ + minimum_release_age: z.number().int().min(0).optional(), + exclude: z.array(z.string()).default([]), +}); + +export type UpdateConfig = z.infer; + const trustConfigSchema = z.object({ allow_all: z.boolean().default(false), github_orgs: z.array(z.string()).default([]), @@ -168,6 +175,7 @@ export const agentsConfigSchema = z.object({ mcp: z.array(mcpSchema).default([]), hooks: z.array(hookSchema).default([]), trust: trustConfigSchema.optional(), + update: updateConfigSchema.optional(), }); export type AgentsConfig = z.infer; diff --git a/src/lockfile/schema.test.ts b/src/lockfile/schema.test.ts index 313532c..a93c94f 100644 --- a/src/lockfile/schema.test.ts +++ b/src/lockfile/schema.test.ts @@ -54,5 +54,39 @@ describe("lockfileSchema", () => { }); expect(result.success).toBe(true); }); + + it("parses git skills with resolved_commit", () => { + const result = lockfileSchema.safeParse({ + version: 1, + skills: { + "my-skill": { + source: "org/repo", + resolved_url: "https://github.com/org/repo.git", + resolved_path: "my-skill", + resolved_commit: "405638a2ee3f131b910be238af499eac5c86e92c", + }, + }, + }); + expect(result.success).toBe(true); + }); + + it("parses git skills without resolved_commit (backwards compat)", () => { + const result = lockfileSchema.safeParse({ + version: 1, + skills: { + "my-skill": { + source: "org/repo", + resolved_url: "https://github.com/org/repo.git", + resolved_path: "my-skill", + resolved_ref: "v1.0.0", + }, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + const skill = result.data.skills["my-skill"]!; + expect("resolved_commit" in skill).toBe(false); + } + }); }); diff --git a/src/lockfile/schema.ts b/src/lockfile/schema.ts index 4e0a365..09bb24b 100644 --- a/src/lockfile/schema.ts +++ b/src/lockfile/schema.ts @@ -5,6 +5,8 @@ const lockedGitSkillSchema = z.object({ resolved_url: z.string(), resolved_path: z.string(), resolved_ref: z.string().optional(), + /** Informational only — records the commit installed. Not used for resolution. */ + resolved_commit: z.string().optional(), }); const lockedWellKnownSkillSchema = z.object({ diff --git a/src/skills/resolver.test.ts b/src/skills/resolver.test.ts index 673ea13..971fc88 100644 --- a/src/skills/resolver.test.ts +++ b/src/skills/resolver.test.ts @@ -5,6 +5,7 @@ import { parseSource, normalizeSource, sourcesMatch, + isSourceExcluded, } from "./resolver.js"; describe("parseOwnerRepoShorthand", () => { @@ -431,3 +432,37 @@ describe("sourcesMatch", () => { ).toBe(false); }); }); + +describe("isSourceExcluded", () => { + it("returns false with no exclude list", () => { + expect(isSourceExcluded("getsentry/skills")).toBe(false); + expect(isSourceExcluded("getsentry/skills", [])).toBe(false); + }); + + it("matches bare org name", () => { + expect(isSourceExcluded("getsentry/skills", ["getsentry"])).toBe(true); + expect(isSourceExcluded("getsentry/other", ["getsentry"])).toBe(true); + }); + + it("does not match different org", () => { + expect(isSourceExcluded("anthropics/skills", ["getsentry"])).toBe(false); + }); + + it("matches exact org/repo", () => { + expect(isSourceExcluded("getsentry/skills", ["getsentry/skills"])).toBe(true); + }); + + it("does not match different repo in same org", () => { + expect(isSourceExcluded("getsentry/other", ["getsentry/skills"])).toBe(false); + }); + + it("matches wildcard org/*", () => { + expect(isSourceExcluded("getsentry/skills", ["getsentry/*"])).toBe(true); + expect(isSourceExcluded("getsentry/other", ["getsentry/*"])).toBe(true); + }); + + it("strips git: prefix for matching", () => { + expect(isSourceExcluded("git:getsentry/skills", ["getsentry"])).toBe(true); + expect(isSourceExcluded("git:getsentry/skills", ["getsentry/skills"])).toBe(true); + }); +}); diff --git a/src/skills/resolver.ts b/src/skills/resolver.ts index 112ebdc..6d70ebb 100644 --- a/src/skills/resolver.ts +++ b/src/skills/resolver.ts @@ -247,6 +247,10 @@ export async function resolveSkill( defaultRepositorySource?: RepositorySource; /** Override cache TTL (pass 0 to force refresh) */ ttlMs?: number; + /** When set, resolve to the newest commit at least this many minutes old. */ + minimumReleaseAge?: number; + /** Sources excluded from the age gate (org or org/repo patterns). */ + updateExclude?: string[]; }, ): Promise { const sourceForResolve = applyDefaultRepositorySource( @@ -300,11 +304,13 @@ export async function resolveSkill( ? `${parsed.owner}/${parsed.repo}` : url.replace(/^https?:\/\//, "").replace(/\.git$/, ""); + const excluded = isSourceExcluded(dep.source, opts?.updateExclude); const cached = await ensureCached({ url: cloneUrl, cacheKey, ref, ttlMs: opts?.ttlMs, + minimumReleaseAge: excluded ? undefined : opts?.minimumReleaseAge, }); // Discover the skill within the repo @@ -354,6 +360,10 @@ export async function resolveWildcardSkills( defaultRepositorySource?: RepositorySource; /** Override cache TTL (pass 0 to force refresh) */ ttlMs?: number; + /** When set, resolve to the newest commit at least this many minutes old. */ + minimumReleaseAge?: number; + /** Sources excluded from the age gate (org or org/repo patterns). */ + updateExclude?: string[]; }, ): Promise { const sourceForResolve = applyDefaultRepositorySource( @@ -419,11 +429,13 @@ export async function resolveWildcardSkills( ? `${parsed.owner}/${parsed.repo}` : url.replace(/^https?:\/\//, "").replace(/\.git$/, ""); + const excluded = isSourceExcluded(dep.source, opts?.updateExclude); const cached = await ensureCached({ url: cloneUrl, cacheKey, ref, ttlMs: opts?.ttlMs, + minimumReleaseAge: excluded ? undefined : opts?.minimumReleaseAge, }); const discovered = await discoverAllSkills(cached.repoDir); @@ -445,3 +457,25 @@ export async function resolveWildcardSkills( }, })); } + +/** + * Check if a source string matches any pattern in the exclude list. + * Patterns: "org" matches "org/anything", "org/repo" is exact match, + * "org/*" matches "org/anything" (explicit wildcard). + */ +export function isSourceExcluded(source: string, exclude?: string[]): boolean { + if (!exclude?.length) {return false;} + const normalized = source.replace(/^git:/, ""); + for (const raw of exclude) { + const pattern = raw.replace(/^git:/, ""); + if (pattern.includes("/") && !pattern.endsWith("/*")) { + // Exact org/repo match + if (normalized === pattern) {return true;} + } else { + // Bare org ("myorg") or wildcard ("myorg/*") — both match as prefix + const prefix = pattern.replace(/\/\*$/, ""); + if (normalized.startsWith(`${prefix}/`)) {return true;} + } + } + return false; +} diff --git a/src/sources/cache.ts b/src/sources/cache.ts index b6b2946..4f9c852 100644 --- a/src/sources/cache.ts +++ b/src/sources/cache.ts @@ -1,11 +1,18 @@ import { join } from "node:path"; import { mkdir, stat } from "node:fs/promises"; import { homedir } from "node:os"; -import { clone, fetchAndReset, fetchRef, headCommit, isGitRepo } from "./git.js"; +import { clone, fetchAndReset, fetchRef, headCommit, headCommitDate, findCommitOlderThan, checkout, isGitRepo } from "./git.js"; const DEFAULT_STATE_DIR = join(homedir(), ".local", "dotagents"); const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours +export class CacheError extends Error { + constructor(message: string) { + super(message); + this.name = "CacheError"; + } +} + export interface CacheResult { /** Path to the cached repo checkout */ repoDir: string; @@ -25,6 +32,8 @@ export async function ensureCached(opts: { cacheKey: string; ref?: string; ttlMs?: number; + /** When set, resolve to the newest commit at least this many minutes old. */ + minimumReleaseAge?: number; }): Promise { const stateDir = process.env["DOTAGENTS_STATE_DIR"] || DEFAULT_STATE_DIR; const ttl = opts.ttlMs ?? DEFAULT_TTL_MS; @@ -32,9 +41,9 @@ export async function ensureCached(opts: { const repoDir = join(stateDir, opts.cacheKey); if (isGitRepo(repoDir)) { - // Always fetch when a specific ref is requested — the cached checkout - // may point at a different ref regardless of staleness. - const needsRefresh = opts.ref || await isStale(repoDir, ttl); + // Always fetch when: a specific ref is requested, age gating is active + // (checkout may have left HEAD at an old commit), or cache is stale. + const needsRefresh = opts.ref || opts.minimumReleaseAge || await isStale(repoDir, ttl); if (needsRefresh) { if (opts.ref) { await fetchRef(repoDir, opts.ref); @@ -42,17 +51,40 @@ export async function ensureCached(opts: { await fetchAndReset(repoDir); } } - const commit = await headCommit(repoDir); - return { repoDir, commit }; + } else { + // Not cached yet — clone + await mkdir(join(stateDir, opts.cacheKey, ".."), { recursive: true }); + await clone(opts.url, repoDir, opts.ref); + } + + // Age gate: reject or resolve to an older commit when HEAD is too new + if (opts.minimumReleaseAge) { + const age = minutesOld(await headCommitDate(repoDir)); + if (age < opts.minimumReleaseAge) { + if (opts.ref) { + // Pinned skill — can't resolve to a different commit, just reject + throw new CacheError( + `ref "${opts.ref}" is ${Math.floor(age)} minutes old, minimum_release_age requires ${opts.minimumReleaseAge}`, + ); + } + const older = await findCommitOlderThan(repoDir, opts.minimumReleaseAge); + if (!older) { + throw new CacheError( + `minimum_release_age is ${opts.minimumReleaseAge} minutes but no commit that old exists in ${opts.cacheKey}`, + ); + } + await checkout(repoDir, older); + } } - // Not cached yet — clone - await mkdir(join(stateDir, opts.cacheKey, ".."), { recursive: true }); - await clone(opts.url, repoDir, opts.ref); const commit = await headCommit(repoDir); return { repoDir, commit }; } +function minutesOld(date: Date): number { + return (Date.now() - date.getTime()) / (60 * 1000); +} + async function isStale(repoDir: string, ttlMs: number): Promise { try { const gitDir = join(repoDir, ".git", "FETCH_HEAD"); diff --git a/src/sources/git.test.ts b/src/sources/git.test.ts index 2daf7ec..4489ce9 100644 --- a/src/sources/git.test.ts +++ b/src/sources/git.test.ts @@ -5,7 +5,7 @@ vi.mock("../utils/exec.js", () => ({ ExecError: Error, })); -import { clone } from "./git.js"; +import { clone, headCommitDate, findCommitOlderThan } from "./git.js"; import { exec } from "../utils/exec.js"; const mockExec = vi.mocked(exec); @@ -145,3 +145,69 @@ describe("clone", () => { ]); }); }); + +describe("headCommitDate", () => { + it("returns the committer date of HEAD", async () => { + mockExec.mockResolvedValueOnce({ stdout: "2026-03-15T10:30:00+00:00\n", stderr: "" }); + + const date = await headCommitDate("/tmp/repo"); + + expect(mockExec).toHaveBeenCalledWith( + "git", + ["log", "-1", "--format=%cI", "HEAD"], + { cwd: "/tmp/repo" }, + ); + expect(date.toISOString()).toBe("2026-03-15T10:30:00.000Z"); + }); +}); + +describe("findCommitOlderThan", () => { + it("returns SHA when a qualifying commit exists", async () => { + const sha = "abc123def456789012345678901234567890abcd"; + // First call: fetch --unshallow + mockExec.mockResolvedValueOnce({ stdout: "", stderr: "" }); + // Second call: git log --before + mockExec.mockResolvedValueOnce({ stdout: `${sha}\n`, stderr: "" }); + + const result = await findCommitOlderThan("/tmp/repo", 3); + + expect(result).toBe(sha); + expect(mockExec).toHaveBeenNthCalledWith( + 1, + "git", + ["fetch", "--unshallow", "--", "origin"], + { cwd: "/tmp/repo" }, + ); + expect(mockExec).toHaveBeenNthCalledWith( + 2, + "git", + expect.arrayContaining(["log", "--format=%H", "--before", expect.any(String), "-1", "HEAD"]), + { cwd: "/tmp/repo" }, + ); + }); + + it("returns null when no qualifying commit exists", async () => { + // fetch --unshallow succeeds + mockExec.mockResolvedValueOnce({ stdout: "", stderr: "" }); + // git log returns empty (repo younger than threshold) + mockExec.mockResolvedValueOnce({ stdout: "", stderr: "" }); + + const result = await findCommitOlderThan("/tmp/repo", 30); + + expect(result).toBeNull(); + }); + + it("tolerates already-unshallowed repos", async () => { + const sha = "abc123def456789012345678901234567890abcd"; + // fetch --unshallow fails because repo is already complete + const err = new Error("fatal: --unshallow on a complete repository does not make sense") as Error & { stderr: string }; + err.stderr = "fatal: --unshallow on a complete repository does not make sense"; + mockExec.mockRejectedValueOnce(err); + // git log --before + mockExec.mockResolvedValueOnce({ stdout: `${sha}\n`, stderr: "" }); + + const result = await findCommitOlderThan("/tmp/repo", 3); + + expect(result).toBe(sha); + }); +}); diff --git a/src/sources/git.ts b/src/sources/git.ts index e5a2b1b..ebe16a3 100644 --- a/src/sources/git.ts +++ b/src/sources/git.ts @@ -114,6 +114,68 @@ export async function headCommit(repoDir: string): Promise { return stdout.trim(); } +/** + * Get the committer date of HEAD as a Date. + * Uses committer date (not author date) to reflect when the commit landed on the branch, + * which aligns with "release age" semantics (survives cherry-picks and merges). + */ +export async function headCommitDate(repoDir: string): Promise { + const { stdout } = await exec("git", ["log", "-1", "--format=%cI", "HEAD"], { cwd: repoDir }); + return new Date(stdout.trim()); +} + +/** + * Find the newest commit on the current branch whose committer date is at least + * `minAgeMinutes` minutes old. Unshallows the repo to search full history. + * + * Returns the SHA, or `null` if no qualifying commit exists (repo is younger + * than the threshold). + */ +export async function findCommitOlderThan( + repoDir: string, + minAgeMinutes: number, +): Promise { + const cutoff = new Date(Date.now() - minAgeMinutes * 60 * 1000); + const iso = cutoff.toISOString(); + + // Unshallow to get full history — needed to find commits older than the cutoff. + // Only called when HEAD is too new, so the extra fetch is acceptable. + try { + await exec("git", ["fetch", "--unshallow", "--", "origin"], { cwd: repoDir }); + } catch (err) { + if (!(err instanceof ExecError)) {throw err;} + // --unshallow fails on a complete (non-shallow) repository — that's fine + if (!/unshallow on a complete repository/i.test(err.stderr)) { + throw new GitError(`Failed to fetch history for age gate: ${err.stderr}`); + } + } + + // Find the newest commit at or before the cutoff. + // Use HEAD (not FETCH_HEAD) — after the prior fetchAndReset, HEAD is at the + // latest commit and --unshallow made full history available behind it. + const { stdout } = await exec( + "git", + ["log", "--format=%H", "--before", iso, "-1", "HEAD"], + { cwd: repoDir }, + ); + const sha = stdout.trim(); + return sha || null; +} + +/** + * Checkout a local ref (commit, branch, tag). No fetch — the ref must already exist locally. + */ +export async function checkout(repoDir: string, ref: string): Promise { + try { + await exec("git", ["checkout", ref], { cwd: repoDir }); + } catch (err) { + if (err instanceof ExecError) { + throw new GitError(`Failed to checkout ${ref} in ${repoDir}: ${err.stderr}`); + } + throw err; + } +} + /** * Check if a directory is a git repository. */