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
36 changes: 36 additions & 0 deletions src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,42 @@ export async function diffStat(base: string, head: string, cwd: string): Promise
return result.exitCode === 0 ? result.stdout : ""
}

export type DiffTotals = {
files: number
insertions: number
deletions: number
}

/**
* Returns per-file insertion/deletion counts for the range `base..head` by
* parsing `git diff --numstat`. Returns `undefined` when the range is empty or
* git cannot resolve it (e.g. a read-only phase that made no commit).
*
* Uses `--numstat` instead of `--stat` because its machine-readable
* `<added>\t<deleted>\t<path>` output is locale-independent and trivially
* summable, whereas the human-readable `--stat` summary line varies across git
* versions and locales.
*/
export async function diffTotals(base: string, head: string, cwd: string): Promise<DiffTotals | undefined> {
const result = await execFile("git", ["diff", "--numstat", `${base}..${head}`], { cwd, allowFailure: true })
if (result.exitCode !== 0 || result.stdout.trim() === "") return undefined

let files = 0
let insertions = 0
let deletions = 0

for (const line of result.stdout.split("\n")) {
if (!line.trim()) continue
const [added, deleted] = line.split("\t")
files++
// Binary files are represented as "-\t-\t<path>"; count as 1 file, 0 lines.
if (added !== "-") insertions += parseInt(added ?? "0", 10)
if (deleted !== "-") deletions += parseInt(deleted ?? "0", 10)
}

return files === 0 ? undefined : { files, insertions, deletions }
}

/** Points `<ref>` at `<sha>`. Used to stash a pre-rewrite HEAD under refs/convoy/. */
export async function updateRef(ref: string, sha: string, cwd: string) {
await execFile("git", ["update-ref", ref, sha], { cwd })
Expand Down
12 changes: 11 additions & 1 deletion src/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { join } from "node:path"
import { log } from "./log"
import { isSafeStepName } from "./pipeline"

import type { RepoSnapshot } from "./git"
import type { DiffTotals, RepoSnapshot } from "./git"
import type {
ProgressPhaseSnapshot,
ProgressStepUsage,
Expand Down Expand Up @@ -35,6 +35,7 @@ export type PhaseMetadata = {
repositoryBaseline?: RepoSnapshot
advisor?: AdvisorPhaseAggregate
advisorEvents?: AdvisorEvent[]
diff?: DiffTotals
}

export type RunMetadata = {
Expand Down Expand Up @@ -68,6 +69,8 @@ export type RunMetadataStore = {
repositoryBaseline(name: string): RepoSnapshot | undefined
phaseRepositoryBaseline(name: string, baseline: RepoSnapshot): Promise<void>
phaseEnded(name: string, status: "completed" | "skipped" | "failed"): void
recordPhaseDiff(name: string, diff: DiffTotals): void
phaseDiff(name: string): DiffTotals | undefined
controlState(): RunControlState
setControlState(state: RunControlState): Promise<void>
flush(): Promise<void>
Expand Down Expand Up @@ -254,6 +257,13 @@ export async function openRunMetadata(
if (entry.startedAt !== undefined) entry.durationMs = entry.endedAt - entry.startedAt
void persist()
},
recordPhaseDiff(name, diff) {
phase(name).diff = diff
scheduleSave()
},
phaseDiff(name) {
return data.phases[name]?.diff
},
controlState() {
return data.control.state
},
Expand Down
33 changes: 23 additions & 10 deletions src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { opencodeConfig } from "./agents"
import { fileParts } from "./attachments"
import { Caffeinate } from "./caffeinate"
import { ensureClaudeAvailable, promptClaudePhase } from "./claude-code"
import { addAllAndCommit, createCleanRepoSnapshot, describeRepoSnapshotDifference, dirtyFilesPreview, dirtyTreeError, ensureRepoReady, restoreRepoSnapshot, type RepoSnapshot, statusPorcelain, writeDiff } from "./git"
import { addAllAndCommit, createCleanRepoSnapshot, describeRepoSnapshotDifference, diffTotals, dirtyFilesPreview, dirtyTreeError, ensureRepoReady, resolveCommit, restoreRepoSnapshot, type DiffTotals, type RepoSnapshot, statusPorcelain, writeDiff } from "./git"
import { hookPhaseNames, hooksForPipeline, runHooks, type HookStage } from "./hooks"
import { getSessionEventHub, payloadProperties } from "./event-hub"
import { runHumanReviewGate } from "./human"
Expand Down Expand Up @@ -44,7 +44,7 @@ import { discoverProjectContextFiles } from "./project-context"
import { createStepRunnerImpl, stepRunnerFor, stepRunnerModel, type StepRunnerId, type StepRunnerImpl } from "./step-runners"
import type { AgentSpec, AgentStep, HookSet, HookSpec, Pipeline, RunOptions, Step } from "./types"
import { addTokens, emptyTokens, tokensFromValue } from "./usage"
import { cleanupWorkspace, createWorkspace, opencodeConfigDir, resumeWorkspace, type Workspace, writeSummary } from "./workspace"
import { cleanupWorkspace, createWorkspace, opencodeConfigDir, renderScoreboard, resumeWorkspace, type Workspace, writeSummary } from "./workspace"

export type ActiveSession = {
client: OpencodeClient
Expand Down Expand Up @@ -619,10 +619,17 @@ export async function run(options: RunOptions) {

progress.message("writing run summary")
const advisorSection = advisorNeeds.agents.size > 0 ? renderAdvisorSplit(await readAdvisorSplit(workspace.dir)) : undefined
const scoreboardRows = pipeline.steps.map((step) => ({
name: step.name,
status: runMetadata.phaseStatus(step.name),
diff: runMetadata.phaseDiff(step.name),
}))
const scoreboard = renderScoreboard(scoreboardRows)
await writeSummary(
workspace,
pipeline.steps.map((step) => step.name),
advisorSection ? [advisorSection] : [],
scoreboard,
)
postHooksStarted = true
await runHooks("post", hookSet.post, {
Expand Down Expand Up @@ -893,7 +900,8 @@ async function runPhase(
const assistantText = await runPhaseWithRetries(client, workspace, phase, options.targetDir, prepared, baseline, progress, shutdown, gitLock, takeover, undefined, advisors)
return persistPhaseReport(workspace, phase, assistantText)
})
await gitLock(() => finalizePhaseRepository(phase, reportAbs, options.targetDir, baseline))
const phaseDiff = await gitLock(() => finalizePhaseRepository(phase, reportAbs, options.targetDir, baseline))
if (phaseDiff) metadata.recordPhaseDiff(phase.name, phaseDiff)
progress.phaseCompleted(phase.name, "report saved and commit checked")
} catch (error) {
progress.phaseFailed(phase.name, formatSdkError(error))
Expand Down Expand Up @@ -1211,14 +1219,16 @@ async function persistPhaseReport(workspace: Workspace, phase: AgentStep, assist
return reportAbs
}

async function commitPhase(phase: AgentStep, reportAbs: string, targetDir: string) {
async function commitPhase(phase: AgentStep, reportAbs: string, targetDir: string): Promise<string | undefined> {
const message = `convoy(${phase.name}): ${await summaryFromReport(reportAbs)}`
const committed = await addAllAndCommit(message, targetDir)
if (!committed) {
log.info(`[${phase.name}] no changes - no commit`)
} else {
log.info(`[${phase.name}] commit: ${message}`)
return undefined
}
log.info(`[${phase.name}] commit: ${message}`)
// Resolve the new HEAD so the caller can compute a per-phase diffstat.
return resolveCommit("HEAD", targetDir)
}

export async function finalizePhaseRepository(
Expand All @@ -1227,17 +1237,20 @@ export async function finalizePhaseRepository(
targetDir: string,
baseline: RepoSnapshot | undefined,
originalError?: unknown,
): Promise<void> {
): Promise<DiffTotals | undefined> {
if (!phase.readOnly) {
await commitPhase(phase, reportAbs, targetDir)
return
const newHead = await commitPhase(phase, reportAbs, targetDir)
if (newHead && baseline) {
return diffTotals(baseline.head, newHead, targetDir)
}
return undefined
}
if (!baseline) throw new Error(`[${phase.name}] read-only step has no clean repository baseline`)

const difference = await describeRepoSnapshotDifference(baseline, targetDir)
if (!difference) {
log.info(`[${phase.name}] read-only step left the repository unchanged`)
return
return undefined
}

if (originalError instanceof ReadOnlyRepositoryMutationError) throw originalError
Expand Down
51 changes: 50 additions & 1 deletion src/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"
import { homedir } from "node:os"
import { isAbsolute, join, relative, resolve } from "node:path"

import type { DiffTotals } from "./git"
import type { PhaseMetadataStatus } from "./metadata"

export type Workspace = {
dir: string
runID: string
Expand Down Expand Up @@ -37,9 +40,55 @@ export async function cleanupWorkspace(workspace: Workspace) {
await rm(workspace.dir, { recursive: true, force: true })
}

export async function writeSummary(workspace: Workspace, phaseNames: string[], extraSections: readonly string[] = []) {
export type ScoreboardRow = {
name: string
status: PhaseMetadataStatus | undefined
diff: DiffTotals | undefined
}

/**
* Renders the per-phase diffstat scoreboard as a Markdown table.
*
* Returns `undefined` when every phase has no diff data (all-read-only or
* no-commit run), so the caller can omit the section entirely.
*/
export function renderScoreboard(rows: ScoreboardRow[]): string | undefined {
const withData = rows.filter((row) => row.diff !== undefined)
if (withData.length === 0) return undefined

const totalFiles = withData.reduce((sum, row) => sum + row.diff!.files, 0)
const totalInsertions = withData.reduce((sum, row) => sum + row.diff!.insertions, 0)
const totalDeletions = withData.reduce((sum, row) => sum + row.diff!.deletions, 0)
const totalDelta = totalInsertions - totalDeletions

const lines: string[] = [
"## Scoreboard",
"",
"| Phase | Files | + | − | Δ net |",
"|---|---|---|---|---|",
]

for (const row of rows) {
if (row.diff) {
const delta = row.diff.insertions - row.diff.deletions
const deltaStr = delta >= 0 ? `+${delta}` : `${delta}`
lines.push(`| ${row.name} | ${row.diff.files} | +${row.diff.insertions} | −${row.diff.deletions} | ${deltaStr} |`)
} else {
lines.push(`| ${row.name} | — | — | — | — |`)
}
}

const totalDeltaStr = totalDelta >= 0 ? `+${totalDelta}` : `${totalDelta}`
lines.push(`| **Total** | **${totalFiles}** | **+${totalInsertions}** | **−${totalDeletions}** | **${totalDeltaStr}** |`)

return lines.join("\n")
}

export async function writeSummary(workspace: Workspace, phaseNames: string[], extraSections: readonly string[] = [], scoreboard?: string) {
const chunks: string[] = [`# convoy run ${workspace.runID} - summary`, ""]

if (scoreboard) chunks.push(scoreboard, "")

for (const section of extraSections) chunks.push(section, "")

for (const name of phaseNames) {
Expand Down
142 changes: 141 additions & 1 deletion test/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"

import { addAllAndCommit, addWorktree, branchExists, detectBaseRef, ensureRepoReady, findSuspiciousStagedFiles, initializeRepoWithInitialCommit, repoBootstrapStatus } from "../src/git"
import { addAllAndCommit, addWorktree, branchExists, detectBaseRef, diffTotals, ensureRepoReady, findSuspiciousStagedFiles, initializeRepoWithInitialCommit, repoBootstrapStatus } from "../src/git"

describe("findSuspiciousStagedFiles", () => {
test("flags common secret filenames", () => {
Expand Down Expand Up @@ -405,3 +405,143 @@ describe("addWorktree", () => {
expect(await branch.exited).toBe(0)
})
})

describe("diffTotals", () => {
const dirs: string[] = []
afterAll(async () => {
await Promise.all(dirs.map((dir) => rm(dir, { recursive: true, force: true })))
})

async function git(args: string[], cwd: string) {
const proc = Bun.spawn(["git", "-c", "commit.gpgsign=false", ...args], {
cwd,
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
GIT_AUTHOR_NAME: "convoy-test",
GIT_AUTHOR_EMAIL: "convoy-test@example.invalid",
GIT_COMMITTER_NAME: "convoy-test",
GIT_COMMITTER_EMAIL: "convoy-test@example.invalid",
},
})
const stdout = await new Response(proc.stdout).text()
if ((await proc.exited) !== 0) throw new Error(`git ${args.join(" ")}: ${await new Response(proc.stderr).text()}`)
return stdout.trim()
}

async function repo(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "convoy-difftotals-"))
dirs.push(dir)
await git(["init", "-q", "-b", "main"], dir)
await writeFile(join(dir, "base.txt"), "line1\nline2\nline3\n")
await git(["add", "-A"], dir)
await git(["commit", "-q", "-m", "initial"], dir)
return dir
}

test("returns undefined when the range is empty (no diff)", async () => {
const dir = await repo()
const head = await git(["rev-parse", "HEAD"], dir)
expect(await diffTotals(head, head, dir)).toBeUndefined()
})

test("counts insertions for a new file", async () => {
const dir = await repo()
const base = await git(["rev-parse", "HEAD"], dir)
await writeFile(join(dir, "new.ts"), "export const x = 1\nexport const y = 2\n")
await git(["add", "-A"], dir)
await git(["commit", "-q", "-m", "add new file"], dir)
const head = await git(["rev-parse", "HEAD"], dir)

const result = await diffTotals(base, head, dir)
expect(result).toEqual({ files: 1, insertions: 2, deletions: 0 })
})

test("counts deletions for a removed file", async () => {
const dir = await repo()
const base = await git(["rev-parse", "HEAD"], dir)
await git(["rm", "base.txt"], dir)
await git(["commit", "-q", "-m", "remove file"], dir)
const head = await git(["rev-parse", "HEAD"], dir)

const result = await diffTotals(base, head, dir)
expect(result).toEqual({ files: 1, insertions: 0, deletions: 3 })
})

test("counts both insertions and deletions for a modified file", async () => {
const dir = await repo()
const base = await git(["rev-parse", "HEAD"], dir)
await writeFile(join(dir, "base.txt"), "line1\nline2\nnew-line\n")
await git(["add", "-A"], dir)
await git(["commit", "-q", "-m", "modify file"], dir)
const head = await git(["rev-parse", "HEAD"], dir)

const result = await diffTotals(base, head, dir)
expect(result).toEqual({ files: 1, insertions: 1, deletions: 1 })
})

test("aggregates multiple files in the same commit", async () => {
const dir = await repo()
const base = await git(["rev-parse", "HEAD"], dir)
await writeFile(join(dir, "a.ts"), "const a = 1\n")
await writeFile(join(dir, "b.ts"), "const b = 2\nconst c = 3\n")
await git(["add", "-A"], dir)
await git(["commit", "-q", "-m", "add two files"], dir)
const head = await git(["rev-parse", "HEAD"], dir)

const result = await diffTotals(base, head, dir)
expect(result).toEqual({ files: 2, insertions: 3, deletions: 0 })
})

test("counts binary file as 1 file with 0 lines", async () => {
const dir = await repo()
const base = await git(["rev-parse", "HEAD"], dir)
// Write a proper binary with null bytes; git treats null-byte files as binary.
const binaryContent = new Uint8Array(16)
binaryContent[0] = 0x00 // null byte forces git to detect binary
binaryContent[1] = 0x89
binaryContent[2] = 0x50
binaryContent[3] = 0x4e
binaryContent[4] = 0x47
binaryContent.fill(0x00, 5) // rest are null bytes
await Bun.write(join(dir, "image.bin"), binaryContent)
await git(["add", "-A"], dir)
await git(["commit", "-q", "-m", "add binary"], dir)
const head = await git(["rev-parse", "HEAD"], dir)

const result = await diffTotals(base, head, dir)
expect(result).toEqual({ files: 1, insertions: 0, deletions: 0 })
})

test("returns undefined when git cannot resolve the range", async () => {
const dir = await repo()
expect(await diffTotals("nonexistent-sha", "HEAD", dir)).toBeUndefined()
})

test("handles file renames correctly", async () => {
const dir = await repo()
const base = await git(["rev-parse", "HEAD"], dir)
await git(["mv", "base.txt", "renamed.txt"], dir)
await git(["commit", "-q", "-m", "rename base.txt to renamed.txt"], dir)
const head = await git(["rev-parse", "HEAD"], dir)

const result = await diffTotals(base, head, dir)
expect(result).toEqual({ files: 1, insertions: 0, deletions: 0 })
})

test("handles a single-commit range correctly", async () => {
const dir = await repo()
const base = await git(["rev-parse", "HEAD"], dir)
await writeFile(join(dir, "feat.ts"), "export const feature = true\n")
await git(["add", "-A"], dir)
await git(["commit", "-q", "-m", "add feature"], dir)
const head = await git(["rev-parse", "HEAD"], dir)

const result = await diffTotals(base, head, dir)
expect(result).toBeDefined()
expect(result?.files).toBe(1)
expect(result?.insertions).toBeGreaterThan(0)
expect(result?.deletions).toBe(0)
})
})
Loading