refactor: extract git worktree parser from atlas hook
This commit is contained in:
parent
2db9accfc7
commit
d8e7e4f170
@ -1,5 +1,4 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { execSync } from "node:child_process"
|
|
||||||
import { existsSync, readdirSync } from "node:fs"
|
import { existsSync, readdirSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import {
|
import {
|
||||||
@ -12,6 +11,7 @@ import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/ho
|
|||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import { createSystemDirective, SYSTEM_DIRECTIVE_PREFIX, SystemDirectiveTypes } from "../../shared/system-directive"
|
import { createSystemDirective, SYSTEM_DIRECTIVE_PREFIX, SystemDirectiveTypes } from "../../shared/system-directive"
|
||||||
import { isCallerOrchestrator, getMessageDir } from "../../shared/session-utils"
|
import { isCallerOrchestrator, getMessageDir } from "../../shared/session-utils"
|
||||||
|
import { collectGitDiffStats, formatFileChanges } from "../../shared/git-worktree"
|
||||||
import type { BackgroundManager } from "../../features/background-agent"
|
import type { BackgroundManager } from "../../features/background-agent"
|
||||||
|
|
||||||
export const HOOK_NAME = "atlas"
|
export const HOOK_NAME = "atlas"
|
||||||
@ -269,113 +269,6 @@ function extractSessionIdFromOutput(output: string): string {
|
|||||||
return match?.[1] ?? "<session_id>"
|
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 {
|
interface ToolExecuteAfterInput {
|
||||||
tool: string
|
tool: string
|
||||||
sessionID?: string
|
sessionID?: string
|
||||||
@ -750,8 +643,8 @@ export function createAtlasHook(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (output.output && typeof output.output === "string") {
|
if (output.output && typeof output.output === "string") {
|
||||||
const gitStats = getGitDiffStats(ctx.directory)
|
const gitStats = collectGitDiffStats(ctx.directory)
|
||||||
const fileChanges = formatFileChanges(gitStats)
|
const fileChanges = formatFileChanges(gitStats)
|
||||||
const subagentSessionId = extractSessionIdFromOutput(output.output)
|
const subagentSessionId = extractSessionIdFromOutput(output.output)
|
||||||
|
|
||||||
const boulderState = readBoulderState(ctx.directory)
|
const boulderState = readBoulderState(ctx.directory)
|
||||||
|
|||||||
29
src/shared/git-worktree/collect-git-diff-stats.ts
Normal file
29
src/shared/git-worktree/collect-git-diff-stats.ts
Normal 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 []
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/shared/git-worktree/format-file-changes.ts
Normal file
46
src/shared/git-worktree/format-file-changes.ts
Normal 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")
|
||||||
|
}
|
||||||
51
src/shared/git-worktree/git-worktree.test.ts
Normal file
51
src/shared/git-worktree/git-worktree.test.ts
Normal 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")
|
||||||
|
})
|
||||||
|
})
|
||||||
5
src/shared/git-worktree/index.ts
Normal file
5
src/shared/git-worktree/index.ts
Normal 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"
|
||||||
27
src/shared/git-worktree/parse-diff-numstat.ts
Normal file
27
src/shared/git-worktree/parse-diff-numstat.ts
Normal 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
|
||||||
|
}
|
||||||
25
src/shared/git-worktree/parse-status-porcelain.ts
Normal file
25
src/shared/git-worktree/parse-status-porcelain.ts
Normal 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
|
||||||
|
}
|
||||||
8
src/shared/git-worktree/types.ts
Normal file
8
src/shared/git-worktree/types.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export type GitFileStatus = "modified" | "added" | "deleted"
|
||||||
|
|
||||||
|
export interface GitFileStat {
|
||||||
|
path: string
|
||||||
|
added: number
|
||||||
|
removed: number
|
||||||
|
status: GitFileStatus
|
||||||
|
}
|
||||||
@ -41,5 +41,6 @@ export * from "./tmux"
|
|||||||
export * from "./model-suggestion-retry"
|
export * from "./model-suggestion-retry"
|
||||||
export * from "./opencode-server-auth"
|
export * from "./opencode-server-auth"
|
||||||
export * from "./port-utils"
|
export * from "./port-utils"
|
||||||
|
export * from "./git-worktree"
|
||||||
export * from "./safe-create-hook"
|
export * from "./safe-create-hook"
|
||||||
export * from "./truncate-description"
|
export * from "./truncate-description"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user