UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

690 lines (689 loc) • 32.5 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. import { randomBytes } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { akmAdapter } from "../../core/adapter/adapters/akm-adapter.js"; import { stashDirNames } from "../../core/asset/asset-placement.js"; import { resolveStashDir } from "../../core/common.js"; import { getSources, loadConfig, resolveConfiguredSources } from "../../core/config/config.js"; import { NotFoundError, UsageError } from "../../core/errors.js"; import { sanitizeCommitMessage } from "../../core/git-message.js"; import { lockContentRootFor } from "../../integrations/lockfile.js"; import { runGit } from "./git-install.js"; import { getCachePaths, parseGitRepoUrl } from "./git-provider.js"; /** * Recognize a stash directory as git-backed by the presence of a `.git` entry. * * Recognition is deliberately by `.git` presence — NOT by a configured remote. * `akm init` git-inits the primary stash (see init.ts `ensureGitRepo`), so a * freshly-initialized local stash with no remote is still git-backed. This is * the single source of truth used both by `saveGitStash` (below) and by the * end-of-run improve auto-sync gate. */ export function isGitBackedStash(stashDir) { return fs.existsSync(path.join(stashDir, ".git")); } /** * Return repo-relative dirty/staged paths without changing the index, and * whether `git status` itself succeeded. A broken submodule, a detached * `GIT_DIR`, or git simply not being on `PATH` all exit nonzero here — a * caller that would otherwise read the empty `paths` as "nothing is dirty" * must check `ok` first (see {@link listGitChangedPaths}'s callers that * cannot, and the archive purge sweep, which can and does). */ export function tryListGitChangedPaths(repoDir) { const result = runGit(["-C", repoDir, "status", "--porcelain", "-z", "--untracked-files=all"]); if (result.status !== 0) return { paths: [], ok: false }; const records = result.stdout.split("\0"); const paths = []; for (let i = 0; i < records.length; i++) { const record = records[i]; if (!record) continue; const status = record.slice(0, 2); paths.push(record.slice(3)); if (status.includes("R") || status.includes("C")) { const previousPath = records[++i]; if (previousPath) paths.push(previousPath); } } return { paths, ok: true }; } /** Return repo-relative dirty/staged paths without changing the index. `[]` on any git failure — see {@link tryListGitChangedPaths} for a caller that must tell that apart from "nothing is dirty". */ export function listGitChangedPaths(repoDir) { return tryListGitChangedPaths(repoDir).paths; } /** * Return repo-relative paths git tracks at HEAD/index under `pathspec` (or * the whole repo when omitted), and whether `git ls-files` itself * succeeded — see {@link tryListGitChangedPaths}, the same contract. */ export function tryListGitTrackedPaths(repoDir, pathspec) { const args = ["-C", repoDir, "ls-files", "-z"]; if (pathspec) args.push("--", pathspec); const result = runGit(args); if (result.status !== 0) return { paths: [], ok: false }; return { paths: result.stdout.split("\0").filter((record) => record.length > 0), ok: true }; } /** * Return repo-relative paths under `pathspec` that `git ls-files -v` tags as * NOT verifiable against the worktree: `assume-unchanged` (a lowercase tag — * `ls-files -v` lowercases a file's normal tag letter when that bit is set) * or `skip-worktree` (the literal `S`). `git status` silently omits an edit * to either kind of file — the index is telling git not to compare it — so a * caller that trusts {@link tryListGitChangedPaths} alone would read a * genuinely modified file as clean. */ export function tryListGitUnverifiablePaths(repoDir, pathspec) { const args = ["-C", repoDir, "ls-files", "-v", "-z"]; if (pathspec) args.push("--", pathspec); const result = runGit(args); if (result.status !== 0) return { paths: [], ok: false }; const paths = []; for (const record of result.stdout.split("\0")) { if (!record) continue; const tag = record.slice(0, 1); if (tag === "S" || (tag >= "a" && tag <= "z")) paths.push(record.slice(2)); } return { paths, ok: true }; } /** * The three-part git cleanliness check shared by the archive purge sweep * (`purgeGracedArchive` in `commands/improve/memory/memory-improve.ts`) and * the `memory-cleanup-archive` health advisory (`collectArchiveUsageAdvisory` * in `commands/health/archive-usage.ts`): a path is safe to treat as * committed only if it is tracked ({@link tryListGitTrackedPaths}), not dirty * ({@link tryListGitChangedPaths}), and not assume-unchanged/skip-worktree * ({@link tryListGitUnverifiablePaths} — those hide their own edits from * `git status`, so an unverifiable file is never trusted as clean either). * * Each of the three git calls can fail independently (a broken submodule, or * git missing from `PATH`); `ok` is `false` if any one does, and `isSafe` * then returns `false` for every path rather than guessing — callers that * need to short-circuit before doing other work still check `ok` themselves. */ export function checkGitPathSafety(repoDir, pathspec) { const dirtyQuery = tryListGitChangedPaths(repoDir); const trackedQuery = tryListGitTrackedPaths(repoDir, pathspec); const unverifiableQuery = tryListGitUnverifiablePaths(repoDir, pathspec); const ok = dirtyQuery.ok && trackedQuery.ok && unverifiableQuery.ok; const dirty = new Set(dirtyQuery.paths); const tracked = new Set(trackedQuery.paths); const unverifiable = new Set(unverifiableQuery.paths); return { ok, isSafe: (repoRelativePath) => ok && tracked.has(repoRelativePath) && !dirty.has(repoRelativePath) && !unverifiable.has(repoRelativePath), }; } export class GitStashPushError extends Error { commit; constructor(message, commit) { super(message); this.name = "GitStashPushError"; this.commit = commit; } } const GIT_PUSH_TIMEOUT_MS = 120_000; const ZERO_OID = "0000000000000000000000000000000000000000"; let exactCommitHookForTests; /** * TEST-ONLY. Interleave work into the exact-path commit sequence; `undefined` * restores. Exists because the CAS window is a few microseconds wide between * two synchronous `git` invocations — a wall-clock race would be * non-deterministic, and every earlier pre-check would swallow a commit that * landed before the window opened. Inert in production. */ export function _setGitExactCommitHookForTests(hook) { exactCommitHookForTests = hook; } /** Fire a named exact-path-commit race point (no-op outside tests). */ function gitExactCommitHook(point) { exactCommitHookForTests?.(point); } /** * Resolve the writable flag for an end-of-run / `akm sync` commit from the * configured default bundle. */ export function resolveWritableOverride(config) { const source = resolveConfiguredSources(config).find((entry) => entry.name === config.defaultBundle); if (!source) return undefined; return (source.writable ?? source.type === "filesystem") ? true : undefined; } /** * Commit (and optionally push) local changes in a git-backed stash. * * Behaviour: * - Not a git repo → skipped (no-op) * - Git repo, no remote → commit only * - Git repo, has remote, but stash is not writable → commit only * - Git repo, has remote, stash is writable → commit + push, unless the * branch has no upstream or is behind or diverged from it: then commit * only, and `reason` says why * * When `name` is omitted the primary stash directory is used. * When `message` is omitted a timestamp is used. * * `options.repoDir` overrides the primary-stash directory the commit targets * (only honoured when `name` is omitted). Callers that already resolved the * primary stash dir (e.g. `akm improve`'s end-of-run sync, whose pre-commit * gate validates that exact directory) pass it here so the gate and the commit * operate on the SAME directory instead of independently calling * `resolveStashDir()`. When absent, behaviour is unchanged. */ export function saveGitStash(name, message, writableOverride, options) { // `push: false` (from `akm sync --no-push`) commits but never pushes, even // when the stash is writable with a remote configured. const allowPush = options?.push !== false; const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19); // Sanitize the user-supplied message: strip CR/LF/NUL, collapse whitespace, // clamp length. An attacker can otherwise pass `--message "subject\n\n\ // Co-Authored-By: someone-else"` and forge trailers in the commit log. // Empty result falls back to the timestamped default. const sanitized = message ? sanitizeCommitMessage(message) : ""; const commitMessage = sanitized || `akm save ${timestamp}`; const transactionId = options?.transactionId; if (transactionId !== undefined && !/^[A-Za-z0-9._-]{1,128}$/.test(transactionId)) { throw new Error("Invalid Git transaction identifier."); } let repoDir; let writable = false; let managedContentRoot; if (name) { const config = loadConfig(); const stash = findSyncStashByTarget(getSources(config), name); // NotFoundError (exit 1), not UsageError (exit 2): the argument is // well-formed, the bundle just isn't configured. if (!stash) throw new NotFoundError(`No git-backed bundle found with name "${name}"`, "SOURCE_NOT_FOUND"); if (stash.enabled === false) { throw new UsageError(`Bundle "${name}" is disabled and cannot be synced.`, "INVALID_FLAG_VALUE"); } if (stash.type === "filesystem") { if (!stash.path) throw new UsageError(`Filesystem bundle "${name}" has no path configured.`); const contentRoot = path.resolve(stash.path); const topLevel = runGit(["-C", contentRoot, "rev-parse", "--show-toplevel"]); if (topLevel.status !== 0 || !topLevel.stdout.trim()) { throw new UsageError(`Filesystem bundle "${name}" is not a Git working tree at ${contentRoot}.`); } repoDir = path.resolve(topLevel.stdout.trim()); managedContentRoot = contentRoot; writable = stash.writable !== false; } else { const lockedRoot = lockContentRootFor(stash.name, stash.type); if (lockedRoot) { const topLevel = runGit(["-C", lockedRoot, "rev-parse", "--show-toplevel"]); if (topLevel.status !== 0 || !topLevel.stdout.trim()) { throw new UsageError(`Managed Git stash "${name}" is not a checkout at ${lockedRoot}`); } repoDir = path.resolve(topLevel.stdout.trim()); managedContentRoot = path.resolve(lockedRoot); } else { if (!stash.url) throw new UsageError(`Stash "${name}" has no URL configured`); const repo = parseGitRepoUrl(stash.url); repoDir = getCachePaths(repo.canonicalUrl).repoDir; } writable = stash.writable === true; } } else { // Honour an explicit primary-stash dir override (keeps the improve gate and // the commit on the same directory); otherwise resolve the default. repoDir = options?.repoDir ?? resolveStashDir(); // Allow the caller to pass the configured default bundle's writability. if (writableOverride !== undefined) { writable = writableOverride; } } // No-op: not a git repo if (!isGitBackedStash(repoDir)) { return { committed: false, pushed: false, skipped: true, reason: "not a git repository", output: "" }; } const statusResult = runGit(["-C", repoDir, "status", "--porcelain"]); if (statusResult.error || statusResult.status !== 0) { throw new Error(`git status failed: ${statusResult.error?.message || statusResult.stderr?.trim() || "unknown error"}`); } if (!statusResult.stdout.trim() && options?.paths === undefined) { return { committed: false, pushed: false, skipped: false, output: "nothing to commit, working tree clean" }; } // Scoped staging (#476 + the auto-sync incident): NEVER refuse akm's commit // because unrelated non-akm files exist in the working tree. When the stash // dir is shared with a non-akm project (stash root == project repo root), a // blunt `git add -A` would sweep the user's unrelated WIP into the stash's // remote. We avoid that by SCOPING what we stage, not by refusing the commit. // // Precedence: // 1. Explicit modified-file list (`options.paths`) — stage exactly those. // 2. A lock-backed content root, or managed adapter pathspecs for an // ordinary stash. Both stay below AKM's resolved content boundary. // 3. No resolvable managed path — no commit. Broad staging is never safe // because it can absorb unrelated work already present in the index. const managedRelativeRoot = managedContentRoot ? path.relative(repoDir, managedContentRoot).replaceAll(path.sep, "/") : ""; const changedPaths = listGitChangedPaths(repoDir); const requestedPaths = normalizeExactPaths(repoDir, options?.paths ?? (managedRelativeRoot && managedRelativeRoot !== ".." && !managedRelativeRoot.startsWith("../") ? changedPaths.filter((changedPath) => changedPath === managedRelativeRoot || changedPath.startsWith(`${managedRelativeRoot}/`)) : managedFallbackPaths(repoDir, changedPaths))); if (requestedPaths.length === 0) { return { committed: false, pushed: false, skipped: false, output: "nothing to commit" }; } assertNoIgnoredExactPaths(repoDir, requestedPaths); const baseHead = readOptionalHead(repoDir); const remoteResult = runGit(["-C", repoDir, "remote"]); if (remoteResult.status !== 0) { throw new Error(`git remote failed: ${remoteResult.stderr?.trim() || "unknown error"}`); } const hasRemote = remoteResult.stdout.trim().length > 0; // A branch that cannot be pushed still gets its commit: only the push is skipped, and the result says why. const upstream = hasRemote && writable && allowPush ? readActualUpstream(repoDir, baseHead) : undefined; const pushTarget = typeof upstream === "object" ? upstream : undefined; const exactCommit = createExactPathCommit(repoDir, { baseHead, commitMessage, paths: requestedPaths, transactionId, expectedSnapshots: options?.expectedSnapshots, }); if (!exactCommit) { return { committed: false, pushed: false, skipped: false, output: "nothing to commit" }; } if (!pushTarget) { return { committed: true, pushed: false, skipped: false, ...(typeof upstream === "string" ? { reason: `not pushed: ${upstream}` } : {}), output: `commit ${exactCommit}`, commit: exactCommit, }; } const pushResult = runGit([ "-C", repoDir, "push", `--force-with-lease=${pushTarget.mergeRef}:${pushTarget.upstreamHead}`, pushTarget.remote, `${exactCommit}:${pushTarget.mergeRef}`, ], { timeout: GIT_PUSH_TIMEOUT_MS }); if (pushResult.status !== 0) { throw new GitStashPushError(`git push failed: ${pushResult.stderr?.trim() || "unknown error"}`, exactCommit); } return { committed: true, pushed: true, skipped: false, output: pushResult.stdout.trim() || "changes committed and pushed", commit: exactCommit, }; } function managedFallbackPaths(repoDir, changedPaths) { const ownedDirs = akmAdapter.directoryList?.({ id: "akm", adapter: "akm", root: repoDir, writable: false }) ?? stashDirNames(); const prefixes = [...ownedDirs, ".akm"]; return changedPaths.filter((changedPath) => prefixes.some((prefix) => changedPath === prefix || changedPath.startsWith(`${prefix}/`))); } function normalizeExactPaths(repoDir, paths) { const normalized = [...new Set(paths.map((value) => value.replaceAll(path.sep, "/")))]; for (const filePath of normalized) { const absolute = path.resolve(repoDir, filePath); const relative = path.relative(repoDir, absolute).replaceAll(path.sep, "/"); if (!filePath || filePath.includes("\0") || path.isAbsolute(filePath) || relative !== filePath || relative === ".." || relative.startsWith("../")) { throw new Error(`Unsafe exact Git path: ${filePath || "<empty>"}`); } } return normalized; } function runExactPathChunks(repoDir, command, paths, options = {}) { const CHUNK = 500; const results = []; for (let i = 0; i < paths.length; i += CHUNK) { results.push(runGit(["--literal-pathspecs", "-C", repoDir, ...command, "--", ...paths.slice(i, i + CHUNK)], { env: options.env, })); } return results; } export function listIgnoredExactPaths(repoDir, paths) { if (paths.length === 0) return []; const input = `${paths.join("\0")}\0`; const result = runGit(["-C", repoDir, "check-ignore", "-z", "--stdin"], { input }); if (result.status === 1) return []; if (result.status !== 0) { throw new Error(`git check-ignore failed: ${result.stderr.trim() || "unknown error"}`); } return result.stdout.split("\0").filter(Boolean); } export function assertNoIgnoredExactPaths(repoDir, paths) { const ignored = listIgnoredExactPaths(repoDir, normalizeExactPaths(repoDir, paths)); if (ignored.length > 0) { throw new UsageError(`Exact Git publication path is ignored: ${ignored[0]}. Update .gitignore or choose a tracked destination before writing.`); } } function readOptionalHead(repoDir) { const result = runGit(["-C", repoDir, "rev-parse", "--verify", "HEAD"]); return result.status === 0 && result.stdout.trim() ? result.stdout.trim() : undefined; } function readBranchRef(repoDir) { const result = runGit(["-C", repoDir, "symbolic-ref", "--quiet", "HEAD"]); if (result.status !== 0 || !result.stdout.trim()) { throw new UsageError(`Writable Git target at ${repoDir} is detached from a branch.`); } return result.stdout.trim(); } /** * Where a commit on top of `baseHead` can be pushed: the branch's upstream, as * long as it is an ancestor of `baseHead` (ahead is fine, the push fast-forwards * it). Otherwise why not: no upstream, or behind or diverged from it. */ function readActualUpstream(repoDir, baseHead) { const branchRef = readBranchRef(repoDir); const branch = branchRef.replace(/^refs\/heads\//, ""); const remote = runGit(["-C", repoDir, "config", "--get", `branch.${branch}.remote`]); const merge = runGit(["-C", repoDir, "config", "--get", `branch.${branch}.merge`]); const upstream = runGit(["-C", repoDir, "rev-parse", "--verify", "@{u}"]); if (remote.status !== 0 || merge.status !== 0 || upstream.status !== 0) return "no upstream branch is configured"; const upstreamHead = upstream.stdout.trim(); if (!baseHead || runGit(["-C", repoDir, "merge-base", "--is-ancestor", upstreamHead, baseHead]).status !== 0) { return "the branch is behind its upstream or has diverged from it"; } return { remote: remote.stdout.trim(), mergeRef: merge.stdout.trim(), upstreamHead }; } function readIndexEntries(repoDir, paths) { const entries = new Map(paths.map((filePath) => [filePath, []])); for (const result of runExactPathChunks(repoDir, ["ls-files", "--stage", "-z"], paths)) { if (result.status !== 0) throw new Error(`Cannot inspect Git index: ${result.stderr.trim() || "unknown error"}`); for (const record of result.stdout.split("\0")) { if (!record) continue; const tab = record.indexOf("\t"); const match = record.slice(0, tab).match(/^(\d+) ([0-9a-f]+) (\d+)$/); if (tab < 0 || !match) throw new Error(`Cannot parse Git index entry.`); const filePath = record.slice(tab + 1); entries.get(filePath)?.push({ mode: match[1], oid: match[2], stage: Number(match[3]) }); } } return entries; } function readTreeStates(repoDir, treeish, paths) { const states = new Map(paths.map((filePath) => [filePath, null])); if (!treeish) return states; for (const result of runExactPathChunks(repoDir, ["ls-tree", "-z", treeish], paths)) { if (result.status !== 0) throw new Error(`Cannot inspect Git tree: ${result.stderr.trim() || "unknown error"}`); for (const record of result.stdout.split("\0")) { if (!record) continue; const tab = record.indexOf("\t"); const match = record.slice(0, tab).match(/^(\d+) blob ([0-9a-f]+)$/); if (tab < 0 || !match) throw new Error(`Exact Git path is not a blob.`); const mode = match[1]; if (mode !== "100644" && mode !== "100755" && mode !== "120000") { throw new Error(`Unsupported Git mode ${mode}.`); } states.set(record.slice(tab + 1), { mode, oid: match[2] }); } } return states; } function assertIndexMatchesBase(paths, indexEntries, baseStates) { if (indexMatchesTree(paths, indexEntries, baseStates)) return; const dirtyPath = paths.find((filePath) => { const entries = indexEntries.get(filePath) ?? []; const base = baseStates.get(filePath) ?? null; return !(base === null ? entries.length === 0 : entries.length === 1 && entries[0]?.stage === 0 && entries[0]?.mode === base.mode && entries[0]?.oid === base.oid); }); throw new UsageError(`Exact Git operation path has staged work: ${dirtyPath ?? paths[0]}. Commit, stash, or discard that path before retrying.`); } function indexMatchesTree(paths, indexEntries, treeStates) { for (const filePath of paths) { const entries = indexEntries.get(filePath) ?? []; const state = treeStates.get(filePath) ?? null; if (!(state === null ? entries.length === 0 : entries.length === 1 && entries[0]?.stage === 0 && entries[0]?.mode === state.mode && entries[0]?.oid === state.oid)) { return false; } } return true; } /** Repair only the stale base index left by a crash after an exact commit CAS. */ export function reconcileGitExactPathIndex(repoDir, baseCommit, commit, paths) { const normalized = normalizeExactPaths(repoDir, paths); const current = readIndexEntries(repoDir, normalized); const committedStates = readTreeStates(repoDir, commit, normalized); if (indexMatchesTree(normalized, current, committedStates)) return; const baseStates = readTreeStates(repoDir, baseCommit, normalized); if (!indexMatchesTree(normalized, current, baseStates)) { throw new UsageError(`Exact Git operation path has staged work after transaction recovery. Commit, stash, or discard it before retrying.`); } for (const result of runExactPathChunks(repoDir, ["reset", "--quiet", commit], normalized)) { if (result.status !== 0) throw new Error(`git index cleanup failed during exact transaction recovery`); } } function captureWorktreeState(repoDir, filePath) { const absolutePath = path.join(repoDir, filePath); let stat; try { stat = fs.lstatSync(absolutePath); } catch (error) { if (error.code === "ENOENT") return null; throw error; } let mode; let result; if (stat.isSymbolicLink()) { mode = "120000"; result = runGit(["-C", repoDir, "hash-object", "--stdin"], { input: fs.readlinkSync(absolutePath) }); } else if (stat.isFile()) { mode = stat.mode & 0o111 ? "100755" : "100644"; result = runGit(["-C", repoDir, "hash-object", `--path=${filePath}`, "--", absolutePath]); } else { throw new Error(`Exact Git publication path is not a file: ${filePath}`); } if (result.status !== 0 || !result.stdout.trim()) { throw new Error(`Cannot hash exact Git publication path: ${filePath}`); } return { oid: result.stdout.trim(), mode }; } function samePathState(left, right) { return left === null ? right === null : right !== null && left.oid === right.oid && left.mode === right.mode; } function assertWorktreeMatchesExpected(repoDir, paths, expected) { for (const filePath of paths) { if (!samePathState(captureWorktreeState(repoDir, filePath), expected.get(filePath) ?? null)) { throw new Error(`Exact Git publication path changed while committing: ${filePath}`); } } } function sameIndexEntries(left, right) { return JSON.stringify([...left]) === JSON.stringify([...right]); } function resetRealIndexPaths(repoDir, commit, paths, initial) { const current = readIndexEntries(repoDir, paths); if (!sameIndexEntries(initial, current)) return; for (const result of runExactPathChunks(repoDir, ["reset", "--quiet", commit], paths)) { if (result.status !== 0) throw new Error(`git index cleanup failed after exact commit`); } } function createExactPathCommit(repoDir, options) { const branchRef = readBranchRef(repoDir); const baseStates = readTreeStates(repoDir, options.baseHead, options.paths); const initialIndex = readIndexEntries(repoDir, options.paths); assertIndexMatchesBase(options.paths, initialIndex, baseStates); const expected = new Map(); for (const filePath of options.paths) { expected.set(filePath, options.expectedSnapshots && Object.hasOwn(options.expectedSnapshots, filePath) ? (options.expectedSnapshots[filePath] ?? null) : captureWorktreeState(repoDir, filePath)); } const gitIndex = runGit(["-C", repoDir, "rev-parse", "--git-path", "index"]); if (gitIndex.status !== 0 || !gitIndex.stdout.trim()) throw new Error(`Cannot resolve Git index path.`); const realIndexPath = path.resolve(repoDir, gitIndex.stdout.trim()); const temporaryIndex = `${realIndexPath}.akm-${process.pid}-${randomBytes(6).toString("hex")}`; const env = { GIT_INDEX_FILE: temporaryIndex }; try { const seeded = options.baseHead ? runGit(["-C", repoDir, "read-tree", options.baseHead], { env }) : runGit(["-C", repoDir, "read-tree", "--empty"], { env }); if (seeded.status !== 0) throw new Error(`Cannot seed temporary Git index: ${seeded.stderr.trim()}`); const stagePaths = options.paths.filter((filePath) => (expected.get(filePath) ?? null) !== null || (baseStates.get(filePath) ?? null) !== null); for (const result of runExactPathChunks(repoDir, ["add", "-A"], stagePaths, { env })) { if (result.status !== 0) throw new Error(`Cannot stage exact Git paths: ${result.stderr.trim()}`); } const tree = runGit(["-C", repoDir, "write-tree"], { env }); if (tree.status !== 0 || !tree.stdout.trim()) throw new Error(`Cannot write exact Git tree.`); const treeOid = tree.stdout.trim(); const committedStates = readTreeStates(repoDir, treeOid, options.paths); for (const filePath of options.paths) { if (!samePathState(committedStates.get(filePath) ?? null, expected.get(filePath) ?? null)) { throw new Error(`Exact Git publication path changed while committing: ${filePath}`); } } assertWorktreeMatchesExpected(repoDir, options.paths, expected); const baseTreeResult = options.baseHead ? runGit(["-C", repoDir, "rev-parse", `${options.baseHead}^{tree}`]) : undefined; if (baseTreeResult && (baseTreeResult.status !== 0 || !baseTreeResult.stdout.trim())) { throw new Error(`Cannot inspect base Git tree.`); } const baseTree = baseTreeResult?.stdout.trim() ?? "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; if (treeOid === baseTree) return null; const body = options.transactionId ? `${options.commitMessage}\n\nAKM-Transaction: ${options.transactionId}\n` : `${options.commitMessage}\n`; const commit = runGit([ "-C", repoDir, "-c", "user.name=akm", "-c", "user.email=akm@local", "commit-tree", treeOid, ...(options.baseHead ? ["-p", options.baseHead] : []), ], { input: body }); if (commit.status !== 0 || !commit.stdout.trim()) { throw new Error(`git commit-tree failed: ${commit.stderr.trim() || "unknown error"}`); } const commitOid = commit.stdout.trim(); const currentBranch = readBranchRef(repoDir); const currentHead = readOptionalHead(repoDir); if (currentBranch !== branchRef || currentHead !== options.baseHead) { throw new Error(`Git target changed before its exact commit could be attached.`); } assertWorktreeMatchesExpected(repoDir, options.paths, expected); // Race window: everything below this line is defended only by the // update-ref compare-and-swap. See // tests/integration/sync-exact-commit-cas.test.ts. gitExactCommitHook("before-update-ref"); const update = runGit(["-C", repoDir, "update-ref", branchRef, commitOid, options.baseHead ?? ZERO_OID]); if (update.status !== 0) { throw new Error(`Git target advanced before its exact commit could be attached.`); } resetRealIndexPaths(repoDir, commitOid, options.paths, initialIndex); return commitOid; } finally { fs.rmSync(temporaryIndex, { force: true }); fs.rmSync(`${temporaryIndex}.lock`, { force: true }); } } function findSyncStashByTarget(stashes, target) { return stashes.find((stash) => { if (stash.type === "git") return matchesGitStashTarget(stash, target); if (stash.type !== "filesystem") return false; if (stash.name === target || stash.path === target) return true; return stash.path !== undefined && path.resolve(stash.path) === path.resolve(target); }); } function matchesGitStashTarget(stash, target) { if (stash.type !== "git") return false; if (stash.name === target || stash.url === target) return true; if (!stash.url) return false; try { const repo = parseGitRepoUrl(stash.url); if (repo.canonicalUrl === target) return true; return buildGithubTargetAliases(repo.canonicalUrl).has(target); } catch { return false; } } function buildGithubTargetAliases(canonicalUrl) { try { const parsed = new URL(canonicalUrl); if (parsed.hostname !== "github.com") return new Set(); const segments = parsed.pathname.split("/").filter(Boolean); if (segments.length < 2) return new Set(); const owner = segments[0]; const repo = segments[1]; const aliases = new Set([`${owner}/${repo}`, `github:${owner}/${repo}`]); if (segments[2] === "tree" && segments.length >= 4) { const ref = segments.slice(3).join("/"); aliases.add(`${owner}/${repo}#${ref}`); aliases.add(`github:${owner}/${repo}#${ref}`); } return aliases; } catch { return new Set(); } }