refactor: extract git worktree parser from atlas hook

This commit is contained in:
YeonGyu-Kim
2026-02-08 13:30:00 +09:00
parent 2db9accfc7
commit d8e7e4f170
9 changed files with 195 additions and 110 deletions

View File

@@ -1,5 +1,4 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { execSync } from "node:child_process"
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import {
@@ -12,6 +11,7 @@ import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/ho
import { log } from "../../shared/logger"
import { createSystemDirective, SYSTEM_DIRECTIVE_PREFIX, SystemDirectiveTypes } from "../../shared/system-directive"
import { isCallerOrchestrator, getMessageDir } from "../../shared/session-utils"
import { collectGitDiffStats, formatFileChanges } from "../../shared/git-worktree"
import type { BackgroundManager } from "../../features/background-agent"
export const HOOK_NAME = "atlas"
@@ -269,113 +269,6 @@ function extractSessionIdFromOutput(output: string): string {
return match?.[1] ?? "<session_id>"
}
interface GitFileStat {
path: string
added: number
removed: number
status: "modified" | "added" | "deleted"
}
function getGitDiffStats(directory: string): GitFileStat[] {
try {
const output = execSync("git diff --numstat HEAD", {
cwd: directory,
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim()
if (!output) return []
const statusOutput = execSync("git status --porcelain", {
cwd: directory,
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim()
const statusMap = new Map<string, "modified" | "added" | "deleted">()
for (const line of statusOutput.split("\n")) {
if (!line) continue
const status = line.substring(0, 2).trim()
const filePath = line.substring(3)
if (status === "A" || status === "??") {
statusMap.set(filePath, "added")
} else if (status === "D") {
statusMap.set(filePath, "deleted")
} else {
statusMap.set(filePath, "modified")
}
}
const stats: GitFileStat[] = []
for (const line of output.split("\n")) {
const parts = line.split("\t")
if (parts.length < 3) continue
const [addedStr, removedStr, path] = parts
const added = addedStr === "-" ? 0 : parseInt(addedStr, 10)
const removed = removedStr === "-" ? 0 : parseInt(removedStr, 10)
stats.push({
path,
added,
removed,
status: statusMap.get(path) ?? "modified",
})
}
return stats
} catch {
return []
}
}
function formatFileChanges(stats: GitFileStat[], notepadPath?: string): string {
if (stats.length === 0) return "[FILE CHANGES SUMMARY]\nNo file changes detected.\n"
const modified = stats.filter((s) => s.status === "modified")
const added = stats.filter((s) => s.status === "added")
const deleted = stats.filter((s) => s.status === "deleted")
const lines: string[] = ["[FILE CHANGES SUMMARY]"]
if (modified.length > 0) {
lines.push("Modified files:")
for (const f of modified) {
lines.push(` ${f.path} (+${f.added}, -${f.removed})`)
}
lines.push("")
}
if (added.length > 0) {
lines.push("Created files:")
for (const f of added) {
lines.push(` ${f.path} (+${f.added})`)
}
lines.push("")
}
if (deleted.length > 0) {
lines.push("Deleted files:")
for (const f of deleted) {
lines.push(` ${f.path} (-${f.removed})`)
}
lines.push("")
}
if (notepadPath) {
const notepadStat = stats.find((s) => s.path.includes("notepad") || s.path.includes(".sisyphus"))
if (notepadStat) {
lines.push("[NOTEPAD UPDATED]")
lines.push(` ${notepadStat.path} (+${notepadStat.added})`)
lines.push("")
}
}
return lines.join("\n")
}
interface ToolExecuteAfterInput {
tool: string
sessionID?: string
@@ -750,8 +643,8 @@ export function createAtlasHook(
}
if (output.output && typeof output.output === "string") {
const gitStats = getGitDiffStats(ctx.directory)
const fileChanges = formatFileChanges(gitStats)
const gitStats = collectGitDiffStats(ctx.directory)
const fileChanges = formatFileChanges(gitStats)
const subagentSessionId = extractSessionIdFromOutput(output.output)
const boulderState = readBoulderState(ctx.directory)

View File

@@ -0,0 +1,29 @@
import { execSync } from "node:child_process"
import { parseGitStatusPorcelain } from "./parse-status-porcelain"
import { parseGitDiffNumstat } from "./parse-diff-numstat"
import type { GitFileStat } from "./types"
export function collectGitDiffStats(directory: string): GitFileStat[] {
try {
const diffOutput = execSync("git diff --numstat HEAD", {
cwd: directory,
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim()
if (!diffOutput) return []
const statusOutput = execSync("git status --porcelain", {
cwd: directory,
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim()
const statusMap = parseGitStatusPorcelain(statusOutput)
return parseGitDiffNumstat(diffOutput, statusMap)
} catch {
return []
}
}

View File

@@ -0,0 +1,46 @@
import type { GitFileStat } from "./types"
export function formatFileChanges(stats: GitFileStat[], notepadPath?: string): string {
if (stats.length === 0) return "[FILE CHANGES SUMMARY]\nNo file changes detected.\n"
const modified = stats.filter((s) => s.status === "modified")
const added = stats.filter((s) => s.status === "added")
const deleted = stats.filter((s) => s.status === "deleted")
const lines: string[] = ["[FILE CHANGES SUMMARY]"]
if (modified.length > 0) {
lines.push("Modified files:")
for (const f of modified) {
lines.push(` ${f.path} (+${f.added}, -${f.removed})`)
}
lines.push("")
}
if (added.length > 0) {
lines.push("Created files:")
for (const f of added) {
lines.push(` ${f.path} (+${f.added})`)
}
lines.push("")
}
if (deleted.length > 0) {
lines.push("Deleted files:")
for (const f of deleted) {
lines.push(` ${f.path} (-${f.removed})`)
}
lines.push("")
}
if (notepadPath) {
const notepadStat = stats.find((s) => s.path.includes("notepad") || s.path.includes(".sisyphus"))
if (notepadStat) {
lines.push("[NOTEPAD UPDATED]")
lines.push(` ${notepadStat.path} (+${notepadStat.added})`)
lines.push("")
}
}
return lines.join("\n")
}

View File

@@ -0,0 +1,51 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { formatFileChanges, parseGitDiffNumstat, parseGitStatusPorcelain } from "./index"
describe("git-worktree", () => {
test("#given status porcelain output #when parsing #then maps paths to statuses", () => {
const porcelain = [
" M src/a.ts",
"A src/b.ts",
"?? src/c.ts",
"D src/d.ts",
].join("\n")
const map = parseGitStatusPorcelain(porcelain)
expect(map.get("src/a.ts")).toBe("modified")
expect(map.get("src/b.ts")).toBe("added")
expect(map.get("src/c.ts")).toBe("added")
expect(map.get("src/d.ts")).toBe("deleted")
})
test("#given diff numstat and status map #when parsing #then returns typed stats", () => {
const porcelain = [" M src/a.ts", "A src/b.ts"].join("\n")
const statusMap = parseGitStatusPorcelain(porcelain)
const numstat = ["1\t2\tsrc/a.ts", "3\t0\tsrc/b.ts", "-\t-\tbin.dat"].join("\n")
const stats = parseGitDiffNumstat(numstat, statusMap)
expect(stats).toEqual([
{ path: "src/a.ts", added: 1, removed: 2, status: "modified" },
{ path: "src/b.ts", added: 3, removed: 0, status: "added" },
{ path: "bin.dat", added: 0, removed: 0, status: "modified" },
])
})
test("#given git file stats #when formatting #then produces grouped summary", () => {
const summary = formatFileChanges([
{ path: "src/a.ts", added: 1, removed: 2, status: "modified" },
{ path: "src/b.ts", added: 3, removed: 0, status: "added" },
{ path: "src/c.ts", added: 0, removed: 4, status: "deleted" },
])
expect(summary).toContain("[FILE CHANGES SUMMARY]")
expect(summary).toContain("Modified files:")
expect(summary).toContain("Created files:")
expect(summary).toContain("Deleted files:")
expect(summary).toContain("src/a.ts")
expect(summary).toContain("src/b.ts")
expect(summary).toContain("src/c.ts")
})
})

View File

@@ -0,0 +1,5 @@
export type { GitFileStatus, GitFileStat } from "./types"
export { parseGitStatusPorcelain } from "./parse-status-porcelain"
export { parseGitDiffNumstat } from "./parse-diff-numstat"
export { collectGitDiffStats } from "./collect-git-diff-stats"
export { formatFileChanges } from "./format-file-changes"

View File

@@ -0,0 +1,27 @@
import type { GitFileStat, GitFileStatus } from "./types"
export function parseGitDiffNumstat(
output: string,
statusMap: Map<string, GitFileStatus>
): GitFileStat[] {
if (!output) return []
const stats: GitFileStat[] = []
for (const line of output.split("\n")) {
const parts = line.split("\t")
if (parts.length < 3) continue
const [addedStr, removedStr, path] = parts
const added = addedStr === "-" ? 0 : parseInt(addedStr, 10)
const removed = removedStr === "-" ? 0 : parseInt(removedStr, 10)
stats.push({
path,
added,
removed,
status: statusMap.get(path) ?? "modified",
})
}
return stats
}

View File

@@ -0,0 +1,25 @@
import type { GitFileStatus } from "./types"
export function parseGitStatusPorcelain(output: string): Map<string, GitFileStatus> {
const map = new Map<string, GitFileStatus>()
if (!output) return map
for (const line of output.split("\n")) {
if (!line) continue
const status = line.substring(0, 2).trim()
const filePath = line.substring(3)
if (!filePath) continue
if (status === "A" || status === "??") {
map.set(filePath, "added")
} else if (status === "D") {
map.set(filePath, "deleted")
} else {
map.set(filePath, "modified")
}
}
return map
}

View File

@@ -0,0 +1,8 @@
export type GitFileStatus = "modified" | "added" | "deleted"
export interface GitFileStat {
path: string
added: number
removed: number
status: GitFileStatus
}

View File

@@ -41,5 +41,6 @@ export * from "./tmux"
export * from "./model-suggestion-retry"
export * from "./opencode-server-auth"
export * from "./port-utils"
export * from "./git-worktree"
export * from "./safe-create-hook"
export * from "./truncate-description"