Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,32 @@ version).

## [Unreleased]

### Added

- `log`, `show`, and `check` results now include each commit's hash:
`hash` and `shortHash` fields in `--json` output, and the short hash
leading each entry in text output. Previously a result could be found
but not acted on, since nothing identified which commit it was. Parse
warnings on stderr name the commit's short hash too.
- `log --touches <path>`: filters by the `touches` field, matching whole
path segments in both directions, so `src/auth` finds commits listing
`src/auth`, `src/auth/session.ts`, or the broader `src`, but never
`src/authz`. Leading `./` or `/` and trailing `/` are ignored.
- `agent-commit blame <file> [-L <start>[,<end>]]`: runs `git blame` on
the given lines (the whole file without `-L`) and shows the
`Agent-Context` of each commit responsible, with the line ranges each
one owns, in pretty or `--json` form. Lines not committed yet are
counted on stderr instead of being attributed.

### Fixed

- A commit with an empty message (`git commit --allow-empty-message`)
no longer swallows the commit next to it. Records were split on a
double-NUL boundary, and an empty message produced two adjacent empty
fields that looked identical to it, so `log` dropped the neighboring
commit and misreported what remained as `MISPLACED`. The parser now
reads a fixed number of NUL-terminated fields per commit.

## [0.1.0] - 2026-09-11

First published release.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Prose is exactly what agents already do for free, and it's genuinely fine for a

## What the CLI does today

Reading a single repo's history back already works today with plain git, no tool required (see `SPEC.md`, "Reading it back"). The CLI in this repo (`cli/`, published to npm) adds structured filtering on top: `agent-commit log --breaking`, `--grep` (case-insensitive substring match against `why`), `--refs` (exact match against an entry in `refs`, so it's only as reliable as the canonical form guidance in `SPEC.md`; a repo that mixes `INFRA-441` and `#441` for the same ticket will find neither with a query for the other, silently, no error), `--since`/`--until`, `agent-commit show <ref>` for a single commit, both with a pretty-printed default view and a `--json` mode for machine consumption. No `--type` filter, on purpose, `type` isn't a field this format uses (see SPEC.md). `agent-commit check` reports, read-only, whether recent commits on the current branch have a usable `Agent-Context` trailer, distinguishing a trailer that was never written from one that exists in the message but wasn't recognized as a trailer (`MISPLACED`, see SPEC.md's squash-merge section for why that happens), useful right after a squash-merge to confirm the merge commit actually got one. `agent-commit merge-draft <range>` deterministically unions a branch's `touches`/`refs`/`breaking` and lists per-commit `why` candidates for a squash-merge trailer. `agent-commit init` writes the `AGENTS.md` block (see "How to use it" below) instead of an agent hand-transcribing it. `agent-commit --version` prints the installed version. Try it now with no install: `npx agent-commit log`. Building from source instead: `cd cli && npm install && npm run build && node dist/index.js log`.
Reading a single repo's history back already works today with plain git, no tool required (see `SPEC.md`, "Reading it back"). The CLI in this repo (`cli/`, published to npm) adds structured filtering on top: `agent-commit log --breaking`, `--grep` (case-insensitive substring match against `why`), `--refs` (exact match against an entry in `refs`, so it's only as reliable as the canonical form guidance in `SPEC.md`; a repo that mixes `INFRA-441` and `#441` for the same ticket will find neither with a query for the other, silently, no error), `--touches` (path match against `touches` on whole segments, in both directions: `--touches src/auth` finds commits listing `src/auth`, `src/auth/session.ts`, or the broader `src`, never `src/authz`), `--since`/`--until`, `agent-commit show <ref>` for a single commit, both with a pretty-printed default view and a `--json` mode for machine consumption. Every result carries its commit hash (`hash` and `shortHash` in `--json`, the short hash leading each entry in pretty output), so anything found can be handed straight to `git show`. `agent-commit blame <file> [-L <start>[,<end>]]` is the blame-time lookup described under "The problem" in one step: it runs `git blame` on those lines and prints the `Agent-Context` of each commit responsible, with the line ranges each one owns. No `--type` filter, on purpose, `type` isn't a field this format uses (see SPEC.md). `agent-commit check` reports, read-only, whether recent commits on the current branch have a usable `Agent-Context` trailer, distinguishing a trailer that was never written from one that exists in the message but wasn't recognized as a trailer (`MISPLACED`, see SPEC.md's squash-merge section for why that happens), useful right after a squash-merge to confirm the merge commit actually got one. `agent-commit merge-draft <range>` deterministically unions a branch's `touches`/`refs`/`breaking` and lists per-commit `why` candidates for a squash-merge trailer. `agent-commit init` writes the `AGENTS.md` block (see "How to use it" below) instead of an agent hand-transcribing it. `agent-commit --version` prints the installed version. Try it now with no install: `npx agent-commit log`. Building from source instead: `cd cli && npm install && npm run build && node dist/index.js log`.

## What's not built

Expand Down
2 changes: 1 addition & 1 deletion SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,4 @@ Split the output on the NUL byte (`\0` in most languages, `tr '\0' '\n'` at a sh

This already works today, verified against a real commit, not just described; see `README.md`, "What the CLI does today," for the working tool built on top of it.

The CLI's own internal format goes one step further than the two commands above: it also captures each commit's raw message body, specifically so it can tell a genuinely absent trailer apart from one that exists in the message but landed outside the position git recognizes as a trailer (see "The real trap: placement, not the merge strategy itself" above). That's a CLI capability, not a requirement of the format itself; the plain two-field commands here remain everything a reader needs with nothing installed.
The CLI's own internal format goes one step further than the two commands above: it also captures each commit's hash, so every result can be acted on, and its raw message body, specifically so it can tell a genuinely absent trailer apart from one that exists in the message but landed outside the position git recognizes as a trailer (see "The real trap: placement, not the merge strategy itself" above). That's a CLI capability, not a requirement of the format itself; the plain two-field commands here remain everything a reader needs with nothing installed.
17 changes: 15 additions & 2 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,28 @@ target directory (the current directory if none is given), and links
an existing, unrelated commit-message convention or an already-set-up
repo without `--force`/`--update`.

`agent-commit log [--breaking] [--grep <text>] [--refs <ref>] [--since <date>] [--until <date>] [--json]`
`agent-commit log [--breaking] [--grep <text>] [--refs <ref>] [--touches <path>] [--since <date>] [--until <date>] [--json]`
Filters commit history by `Agent-Context` fields. `--grep` is a
case-insensitive substring match against `why`, not semantic search.
`--refs` is an exact match, see the main repo's `SPEC.md` for why ref
form consistency matters.
form consistency matters. `--touches` matches whole path segments in
both directions: `src/auth` finds `src/auth`, `src/auth/session.ts`,
and the broader `src`, but never `src/authz`.

`agent-commit show <ref> [--json]`
Shows a single commit's `Agent-Context` trailer.

Every result from `log`, `show`, `blame`, and `check` includes the commit's hash:
`hash` (full) and `shortHash` (git's own abbreviation) in `--json`
output, and the short hash at the start of each entry in text output.

`agent-commit blame <file> [-L <start>[,<end>]] [--json]`
Shows the `Agent-Context` of each commit that last changed the given
lines (the whole file without `-L`), via `git blame`, with the line
ranges each commit owns. `-L` takes git's numeric form, 1-based and
inclusive: `-L 42` or `-L 40,60`. Lines not committed yet are counted
on stderr rather than attributed to anything.

`agent-commit check [--range <range>] [--count <n>] [--json]`
Read-only, advisory report on whether recent commits have a usable
trailer. Distinguishes a commit with no trailer at all from one where an
Expand Down
139 changes: 139 additions & 0 deletions cli/src/blame.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { formatPretty } from "./format.js";
import { parseLog, runGit, runGitLog } from "./parser.js";
import type { AgentCommitRecord, ParseWarning } from "./types.js";

/** An inclusive range of line numbers in the blamed file, as it currently stands. */
export type LineRange = [start: number, end: number];

export interface BlameEntry {
/** Full hash of the commit that last changed these lines. */
hash: string;
/** Every line attributed to this commit, compressed into ranges, in file order. */
lines: LineRange[];
}

export interface BlameParseResult {
/** One entry per distinct commit, ordered by where its first line appears in the file. */
entries: BlameEntry[];
/** Lines changed in the working tree but not committed yet; git attributes them to an all-zero hash. */
uncommittedLines: number;
}

export interface BlameRecord extends AgentCommitRecord {
lines: LineRange[];
}

export interface BlameResult {
records: BlameRecord[];
uncommittedLines: number;
warnings: ParseWarning[];
}

/**
* Header lines in `git blame --porcelain` output: the commit hash, the
* line number in that commit's version, the line number in the file as it
* stands now, and (on the first line of a group only) the group's size.
* Verified against real git output. Every other line is either commit
* metadata starting with a lowercase keyword (`author`, `summary`,
* `previous`, `boundary`, ...) or the line's own content prefixed with a
* tab, so neither can be mistaken for a header. Accepts SHA-1 and SHA-256
* object names.
*/
const PORCELAIN_HEADER = /^([0-9a-f]{40}|[0-9a-f]{64}) \d+ (\d+)(?: \d+)?$/;
const UNCOMMITTED_HASH = /^0+$/;

function toRanges(lineNumbers: number[]): LineRange[] {
const sorted = [...lineNumbers].sort((a, b) => a - b);
const ranges: LineRange[] = [];
for (const line of sorted) {
const last = ranges[ranges.length - 1];
if (last && line === last[1] + 1) {
last[1] = line;
} else {
ranges.push([line, line]);
}
}
return ranges;
}

/** Parses `git blame --porcelain` output into the distinct commits responsible for the blamed lines. */
export function parseBlamePorcelain(raw: string): BlameParseResult {
const linesByHash = new Map<string, number[]>();
let uncommittedLines = 0;

for (const line of raw.split("\n")) {
const match = PORCELAIN_HEADER.exec(line);
if (!match) continue;
const [, hash, finalLine] = match;
if (UNCOMMITTED_HASH.test(hash)) {
uncommittedLines += 1;
continue;
}
const existing = linesByHash.get(hash);
if (existing) {
existing.push(Number(finalLine));
} else {
linesByHash.set(hash, [Number(finalLine)]);
}
}

const entries = [...linesByHash].map(([hash, lineNumbers]) => ({ hash, lines: toRanges(lineNumbers) }));
entries.sort((a, b) => a.lines[0][0] - b.lines[0][0]);
return { entries, uncommittedLines };
}

/**
* Parses a `-L` value: `<start>` or `<start>,<end>`, 1-based and
* inclusive, the numeric subset of git blame's own `-L` syntax. git's
* regex and function-name forms are deliberately not accepted, so every
* value that reaches git here is known to be plain numbers.
*/
export function parseLineRange(value: string): LineRange | undefined {
const match = /^(\d+)(?:,(\d+))?$/.exec(value.trim());
if (!match) return undefined;
const start = Number(match[1]);
const end = match[2] === undefined ? start : Number(match[2]);
if (start < 1 || end < start) return undefined;
return [start, end];
}

/**
* Finds the commits that last changed the given lines of a file (the
* whole file if no range is given), then reads each one's Agent-Context.
* Blames the working tree, like plain `git blame`, so lines not committed
* yet are counted rather than attributed to anything.
*/
export function blame(file: string, range?: LineRange, cwd?: string): BlameResult {
const blameArgs = ["blame", "--porcelain"];
if (range) blameArgs.push("-L", `${range[0]},${range[1]}`);
// `--` ends option parsing, so a path starting with "-" is read as a path.
blameArgs.push("--", file);

const { entries, uncommittedLines } = parseBlamePorcelain(runGit(blameArgs, cwd));
if (entries.length === 0) return { records: [], uncommittedLines, warnings: [] };

// --no-walk=unsorted reads exactly these commits, in the order given.
const { records, warnings } = parseLog(
runGitLog(["--no-walk=unsorted", ...entries.map((entry) => entry.hash)], cwd),
);
const linesByHash = new Map(entries.map((entry) => [entry.hash, entry.lines]));
return {
records: records.map((record) => ({ ...record, lines: linesByHash.get(record.hash) ?? [] })),
uncommittedLines,
warnings,
};
}

function formatRanges(ranges: LineRange[]): string {
return ranges.map(([start, end]) => (start === end ? `${start}` : `${start}-${end}`)).join(", ");
}

/** Renders blame results like `log`'s pretty output, with each commit's line ranges right under its subject. */
export function formatBlamePretty(records: BlameRecord[]): string {
return records
.map((record) => {
const [head, ...rest] = formatPretty(record).split("\n");
return [head, ` lines: ${formatRanges(record.lines)}`, ...rest].join("\n");
})
.join("\n\n");
}
7 changes: 5 additions & 2 deletions cli/src/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { AgentCommitRecord } from "./types.js";
export type CheckStatus = "ok" | "miss" | "misplaced";

export interface CheckResult {
hash: string;
shortHash: string;
subject: string;
status: CheckStatus;
}
Expand All @@ -20,8 +22,9 @@ export interface CheckResult {
*/
export function checkRecords(records: AgentCommitRecord[]): CheckResult[] {
return records.map((record) => {
const base = { hash: record.hash, shortHash: record.shortHash, subject: record.subject };
if (record.misplaced) {
return { subject: record.subject, status: "misplaced" };
return { ...base, status: "misplaced" };
}
// "ok" requires an object-shaped trailer, the same bar query.ts's
// structured filters use (isAgentContextObject). SPEC.md allows any
Expand All @@ -32,6 +35,6 @@ export function checkRecords(records: AgentCommitRecord[]): CheckResult[] {
// a false green light query.ts could never act on. Malformed and
// ambiguous trailers already fall through to miss below.
const ok = isAgentContextObject(record.context) && !record.malformed && !record.ambiguous;
return { subject: record.subject, status: ok ? "ok" : "miss" };
return { ...base, status: ok ? "ok" : "miss" };
});
}
8 changes: 6 additions & 2 deletions cli/src/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,16 @@ function formatContextFields(context: Record<string, unknown>): string[] {
}

/**
* Renders one record as human-readable text. Field order favors SPEC.md's
* Renders one record as human-readable text: short hash and subject, then
* the trailer's fields. Field order favors SPEC.md's
* common fields but never drops an unrecognized key, the format has no
* required schema, so the renderer can't assume one either.
*/
export function formatPretty(record: AgentCommitRecord): string {
const lines = [sanitizeForTerminal(record.subject)];
// Hash first, `git log --oneline` style, so every result can be handed
// straight to `git show` or `agent-commit show`. Hashes are git's own
// hex output, never commit content, so they need no sanitizing.
const lines = [`${record.shortHash} ${sanitizeForTerminal(record.subject)}`];

if (record.malformed) {
lines.push(" (malformed Agent-Context trailer, could not parse as JSON)");
Expand Down
44 changes: 42 additions & 2 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { existsSync, readFileSync, statSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { Command } from "commander";
import { blame, formatBlamePretty, parseLineRange } from "./blame.js";
import { checkRecords } from "./check.js";
import { formatJson, formatPrettyList, sanitizeForTerminal } from "./format.js";
import { formatInitResult, needsAction, runInit } from "./init.js";
Expand Down Expand Up @@ -59,7 +60,7 @@ function runFatal<T>(fn: () => T): T {
function printWarnings(warnings: ParseWarning[]): void {
for (const warning of warnings) {
process.stderr.write(
`agent-commit: warning: ${warning.reason} on commit "${sanitizeForTerminal(warning.subject)}", skipped\n`,
`agent-commit: warning: ${warning.reason} on commit ${warning.shortHash} "${sanitizeForTerminal(warning.subject)}", skipped\n`,
);
}
}
Expand Down Expand Up @@ -91,13 +92,18 @@ program
.option("--breaking", "filter to breaking: true only")
.option("--grep <text>", "case-insensitive substring match against why")
.option("--refs <ref>", "filter to commits whose refs array contains this exact value")
.option(
"--touches <path>",
"filter to commits whose touches overlaps this path, on whole path segments (src/auth matches src/auth/session.ts and src)",
)
.option("--since <date>", "passed through to git log --since")
.option("--until <date>", "passed through to git log --until")
.option("--json", "output machine-readable JSON instead of pretty text")
.action((opts: {
breaking?: boolean;
grep?: string;
refs?: string;
touches?: string;
since?: string;
until?: string;
json?: boolean;
Expand All @@ -116,6 +122,7 @@ program
if (opts.breaking) queryOptions.breaking = true;
if (opts.grep) queryOptions.grep = opts.grep;
if (opts.refs) queryOptions.refs = opts.refs;
if (opts.touches !== undefined) queryOptions.touches = opts.touches;

const filtered = queryRecords(records, queryOptions);

Expand All @@ -142,6 +149,37 @@ program
process.stdout.write((opts.json ? formatJson(records) : formatPrettyList(records)) + "\n");
});

program
.command("blame")
.argument("<file>", "a file in the repository, as it currently stands in the working tree")
.description(
"shows the Agent-Context of each commit that last changed the given lines (the whole file " +
"without -L), via git blame. One entry per commit, ordered by where its lines first " +
"appear in the file. Lines not committed yet are counted on stderr, not attributed.",
)
.option("-L <start[,end]>", "only these lines, 1-based and inclusive, e.g. -L 42 or -L 40,60")
.option("--json", "output machine-readable JSON instead of pretty text")
.action((file: string, opts: { L?: string; json?: boolean }) => {
let range: ReturnType<typeof parseLineRange>;
if (opts.L !== undefined) {
range = parseLineRange(opts.L);
if (!range) {
fail(`-L must be <start> or <start>,<end> with 1 <= start <= end, got "${opts.L}"`);
}
}
runFatal(() => checkGitVersion());

const { records, uncommittedLines, warnings } = runFatal(() => blame(file, range));
printWarnings(warnings);
if (uncommittedLines > 0) {
process.stderr.write(
`agent-commit: ${uncommittedLines} line(s) not committed yet, so no commit to show for them\n`,
);
}

process.stdout.write((opts.json ? formatJson(records) : formatBlamePretty(records)) + "\n");
});

program
.command("check")
.description(
Expand Down Expand Up @@ -186,7 +224,9 @@ program
misplaced: "MISPLACED".padEnd(labelWidth),
};
for (const result of results) {
process.stdout.write(`${labels[result.status]} ${sanitizeForTerminal(result.subject)}\n`);
process.stdout.write(
`${labels[result.status]} ${result.shortHash} ${sanitizeForTerminal(result.subject)}\n`,
);
}
}

Expand Down
Loading
Loading