feat(ipuaro): add git and run tools (v0.9.0)

Git tools:
- GitStatusTool: repository status (branch, staged, modified, untracked)
- GitDiffTool: uncommitted changes with diff output
- GitCommitTool: create commits with confirmation

Run tools:
- CommandSecurity: blacklist/whitelist shell command validation
- RunCommandTool: execute shell commands with security checks
- RunTestsTool: auto-detect and run vitest/jest/mocha/npm test

All 18 planned tools now implemented.
Tests: 1086 (+233), Coverage: 98.08%
This commit is contained in:
imfozilbek
2025-12-01 02:54:29 +05:00
parent 2ae1ac13f5
commit f5f904a847
16 changed files with 4153 additions and 0 deletions

View File

@@ -0,0 +1,443 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import {
GitCommitTool,
type GitCommitResult,
} from "../../../../../src/infrastructure/tools/git/GitCommitTool.js"
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
import type { SimpleGit, CommitResult, StatusResult } from "simple-git"
function createMockStorage(): IStorage {
return {
getFile: vi.fn(),
setFile: vi.fn(),
deleteFile: vi.fn(),
getAllFiles: vi.fn().mockResolvedValue(new Map()),
getFileCount: vi.fn().mockResolvedValue(0),
getAST: vi.fn(),
setAST: vi.fn(),
deleteAST: vi.fn(),
getAllASTs: vi.fn().mockResolvedValue(new Map()),
getMeta: vi.fn(),
setMeta: vi.fn(),
deleteMeta: vi.fn(),
getAllMetas: vi.fn().mockResolvedValue(new Map()),
getSymbolIndex: vi.fn().mockResolvedValue(new Map()),
setSymbolIndex: vi.fn(),
getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }),
setDepsGraph: vi.fn(),
getProjectConfig: vi.fn(),
setProjectConfig: vi.fn(),
connect: vi.fn(),
disconnect: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
clear: vi.fn(),
} as unknown as IStorage
}
function createMockContext(
storage?: IStorage,
confirmResult: boolean = true,
): ToolContext {
return {
projectRoot: "/test/project",
storage: storage ?? createMockStorage(),
requestConfirmation: vi.fn().mockResolvedValue(confirmResult),
onProgress: vi.fn(),
}
}
function createMockStatusResult(
overrides: Partial<StatusResult> = {},
): StatusResult {
return {
not_added: [],
conflicted: [],
created: [],
deleted: [],
ignored: [],
modified: [],
renamed: [],
files: [],
staged: ["file.ts"],
ahead: 0,
behind: 0,
current: "main",
tracking: "origin/main",
detached: false,
isClean: () => false,
...overrides,
} as StatusResult
}
function createMockCommitResult(
overrides: Partial<CommitResult> = {},
): CommitResult {
return {
commit: "abc1234",
branch: "main",
root: false,
author: null,
summary: {
changes: 1,
insertions: 5,
deletions: 2,
},
...overrides,
} as CommitResult
}
function createMockGit(options: {
isRepo?: boolean
status?: StatusResult
commitResult?: CommitResult
error?: Error
addError?: Error
}): SimpleGit {
const mockGit = {
checkIsRepo: vi.fn().mockResolvedValue(options.isRepo ?? true),
status: vi.fn().mockResolvedValue(
options.status ?? createMockStatusResult(),
),
add: vi.fn(),
commit: vi.fn(),
}
if (options.addError) {
mockGit.add.mockRejectedValue(options.addError)
} else {
mockGit.add.mockResolvedValue(undefined)
}
if (options.error) {
mockGit.commit.mockRejectedValue(options.error)
} else {
mockGit.commit.mockResolvedValue(
options.commitResult ?? createMockCommitResult(),
)
}
return mockGit as unknown as SimpleGit
}
describe("GitCommitTool", () => {
let tool: GitCommitTool
beforeEach(() => {
tool = new GitCommitTool()
})
describe("metadata", () => {
it("should have correct name", () => {
expect(tool.name).toBe("git_commit")
})
it("should have correct category", () => {
expect(tool.category).toBe("git")
})
it("should require confirmation", () => {
expect(tool.requiresConfirmation).toBe(true)
})
it("should have correct parameters", () => {
expect(tool.parameters).toHaveLength(2)
expect(tool.parameters[0].name).toBe("message")
expect(tool.parameters[0].required).toBe(true)
expect(tool.parameters[1].name).toBe("files")
expect(tool.parameters[1].required).toBe(false)
})
it("should have description", () => {
expect(tool.description).toContain("commit")
expect(tool.description).toContain("confirmation")
})
})
describe("validateParams", () => {
it("should return error for missing message", () => {
expect(tool.validateParams({})).toContain("message")
expect(tool.validateParams({})).toContain("required")
})
it("should return error for non-string message", () => {
expect(tool.validateParams({ message: 123 })).toContain("message")
expect(tool.validateParams({ message: 123 })).toContain("string")
})
it("should return error for empty message", () => {
expect(tool.validateParams({ message: "" })).toContain("empty")
expect(tool.validateParams({ message: " " })).toContain("empty")
})
it("should return null for valid message", () => {
expect(tool.validateParams({ message: "fix: bug" })).toBeNull()
})
it("should return null for valid message with files", () => {
expect(
tool.validateParams({ message: "fix: bug", files: ["a.ts", "b.ts"] }),
).toBeNull()
})
it("should return error for non-array files", () => {
expect(
tool.validateParams({ message: "fix: bug", files: "a.ts" }),
).toContain("array")
})
it("should return error for non-string in files array", () => {
expect(
tool.validateParams({ message: "fix: bug", files: [1, 2] }),
).toContain("strings")
})
})
describe("execute", () => {
describe("not a git repository", () => {
it("should return error when not in a git repo", async () => {
const mockGit = createMockGit({ isRepo: false })
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute(
{ message: "test commit" },
ctx,
)
expect(result.success).toBe(false)
expect(result.error).toContain("Not a git repository")
})
})
describe("nothing to commit", () => {
it("should return error when no staged files", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({ staged: [] }),
})
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute(
{ message: "test commit" },
ctx,
)
expect(result.success).toBe(false)
expect(result.error).toContain("Nothing to commit")
})
})
describe("with staged files", () => {
it("should commit successfully", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({ staged: ["file.ts"] }),
commitResult: createMockCommitResult({
commit: "def5678",
branch: "main",
summary: { changes: 1, insertions: 10, deletions: 3 },
}),
})
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute(
{ message: "feat: new feature" },
ctx,
)
expect(result.success).toBe(true)
const data = result.data as GitCommitResult
expect(data.hash).toBe("def5678")
expect(data.branch).toBe("main")
expect(data.message).toBe("feat: new feature")
expect(data.filesChanged).toBe(1)
expect(data.insertions).toBe(10)
expect(data.deletions).toBe(3)
})
it("should include author when available", async () => {
const mockGit = createMockGit({
commitResult: createMockCommitResult({
author: {
name: "Test User",
email: "test@example.com",
},
}),
})
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute(
{ message: "test commit" },
ctx,
)
expect(result.success).toBe(true)
const data = result.data as GitCommitResult
expect(data.author).toEqual({
name: "Test User",
email: "test@example.com",
})
})
})
describe("files parameter", () => {
it("should stage specified files before commit", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({ staged: [] }),
})
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
await toolWithMock.execute(
{ message: "test", files: ["a.ts", "b.ts"] },
ctx,
)
expect(mockGit.add).toHaveBeenCalledWith(["a.ts", "b.ts"])
})
it("should not call add when files is empty", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
await toolWithMock.execute(
{ message: "test", files: [] },
ctx,
)
expect(mockGit.add).not.toHaveBeenCalled()
})
it("should handle add errors", async () => {
const mockGit = createMockGit({
addError: new Error("Failed to add files"),
})
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute(
{ message: "test", files: ["nonexistent.ts"] },
ctx,
)
expect(result.success).toBe(false)
expect(result.error).toContain("Failed to add files")
})
})
describe("confirmation", () => {
it("should request confirmation before commit", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
await toolWithMock.execute({ message: "test commit" }, ctx)
expect(ctx.requestConfirmation).toHaveBeenCalled()
const confirmMessage = (ctx.requestConfirmation as ReturnType<typeof vi.fn>)
.mock.calls[0][0] as string
expect(confirmMessage).toContain("Committing")
expect(confirmMessage).toContain("test commit")
})
it("should cancel commit when user declines", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext(undefined, false)
const result = await toolWithMock.execute(
{ message: "test commit" },
ctx,
)
expect(result.success).toBe(false)
expect(result.error).toContain("cancelled")
expect(mockGit.commit).not.toHaveBeenCalled()
})
it("should proceed with commit when user confirms", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext(undefined, true)
const result = await toolWithMock.execute(
{ message: "test commit" },
ctx,
)
expect(result.success).toBe(true)
expect(mockGit.commit).toHaveBeenCalledWith("test commit")
})
})
describe("error handling", () => {
it("should handle git command errors", async () => {
const mockGit = createMockGit({
error: new Error("Git commit failed"),
})
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute(
{ message: "test commit" },
ctx,
)
expect(result.success).toBe(false)
expect(result.error).toContain("Git commit failed")
})
it("should handle non-Error exceptions", async () => {
const mockGit = {
checkIsRepo: vi.fn().mockResolvedValue(true),
status: vi.fn().mockResolvedValue(createMockStatusResult()),
add: vi.fn(),
commit: vi.fn().mockRejectedValue("string error"),
} as unknown as SimpleGit
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute(
{ message: "test commit" },
ctx,
)
expect(result.success).toBe(false)
expect(result.error).toBe("string error")
})
})
describe("timing", () => {
it("should return timing information", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute(
{ message: "test commit" },
ctx,
)
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
})
})
describe("call id", () => {
it("should generate unique call id", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitCommitTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute(
{ message: "test commit" },
ctx,
)
expect(result.callId).toMatch(/^git_commit-\d+$/)
})
})
})
})

View File

@@ -0,0 +1,401 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import {
GitDiffTool,
type GitDiffResult,
} from "../../../../../src/infrastructure/tools/git/GitDiffTool.js"
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
import type { SimpleGit, DiffResult } from "simple-git"
function createMockStorage(): IStorage {
return {
getFile: vi.fn(),
setFile: vi.fn(),
deleteFile: vi.fn(),
getAllFiles: vi.fn().mockResolvedValue(new Map()),
getFileCount: vi.fn().mockResolvedValue(0),
getAST: vi.fn(),
setAST: vi.fn(),
deleteAST: vi.fn(),
getAllASTs: vi.fn().mockResolvedValue(new Map()),
getMeta: vi.fn(),
setMeta: vi.fn(),
deleteMeta: vi.fn(),
getAllMetas: vi.fn().mockResolvedValue(new Map()),
getSymbolIndex: vi.fn().mockResolvedValue(new Map()),
setSymbolIndex: vi.fn(),
getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }),
setDepsGraph: vi.fn(),
getProjectConfig: vi.fn(),
setProjectConfig: vi.fn(),
connect: vi.fn(),
disconnect: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
clear: vi.fn(),
} as unknown as IStorage
}
function createMockContext(storage?: IStorage): ToolContext {
return {
projectRoot: "/test/project",
storage: storage ?? createMockStorage(),
requestConfirmation: vi.fn().mockResolvedValue(true),
onProgress: vi.fn(),
}
}
function createMockDiffSummary(overrides: Partial<DiffResult> = {}): DiffResult {
return {
changed: 0,
deletions: 0,
insertions: 0,
files: [],
...overrides,
} as DiffResult
}
function createMockGit(options: {
isRepo?: boolean
diffSummary?: DiffResult
diff?: string
error?: Error
}): SimpleGit {
const mockGit = {
checkIsRepo: vi.fn().mockResolvedValue(options.isRepo ?? true),
diffSummary: vi.fn(),
diff: vi.fn(),
}
if (options.error) {
mockGit.diffSummary.mockRejectedValue(options.error)
} else {
mockGit.diffSummary.mockResolvedValue(
options.diffSummary ?? createMockDiffSummary(),
)
mockGit.diff.mockResolvedValue(options.diff ?? "")
}
return mockGit as unknown as SimpleGit
}
describe("GitDiffTool", () => {
let tool: GitDiffTool
beforeEach(() => {
tool = new GitDiffTool()
})
describe("metadata", () => {
it("should have correct name", () => {
expect(tool.name).toBe("git_diff")
})
it("should have correct category", () => {
expect(tool.category).toBe("git")
})
it("should not require confirmation", () => {
expect(tool.requiresConfirmation).toBe(false)
})
it("should have correct parameters", () => {
expect(tool.parameters).toHaveLength(2)
expect(tool.parameters[0].name).toBe("path")
expect(tool.parameters[0].required).toBe(false)
expect(tool.parameters[1].name).toBe("staged")
expect(tool.parameters[1].required).toBe(false)
})
it("should have description", () => {
expect(tool.description).toContain("diff")
expect(tool.description).toContain("changes")
})
})
describe("validateParams", () => {
it("should return null for empty params", () => {
expect(tool.validateParams({})).toBeNull()
})
it("should return null for valid path", () => {
expect(tool.validateParams({ path: "src" })).toBeNull()
})
it("should return null for valid staged", () => {
expect(tool.validateParams({ staged: true })).toBeNull()
expect(tool.validateParams({ staged: false })).toBeNull()
})
it("should return error for invalid path type", () => {
expect(tool.validateParams({ path: 123 })).toContain("path")
expect(tool.validateParams({ path: 123 })).toContain("string")
})
it("should return error for invalid staged type", () => {
expect(tool.validateParams({ staged: "yes" })).toContain("staged")
expect(tool.validateParams({ staged: "yes" })).toContain("boolean")
})
})
describe("execute", () => {
describe("not a git repository", () => {
it("should return error when not in a git repo", async () => {
const mockGit = createMockGit({ isRepo: false })
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("Not a git repository")
})
})
describe("no changes", () => {
it("should return empty diff for clean repo", async () => {
const mockGit = createMockGit({
diffSummary: createMockDiffSummary({ files: [] }),
diff: "",
})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitDiffResult
expect(data.hasChanges).toBe(false)
expect(data.files).toHaveLength(0)
expect(data.diff).toBe("")
})
})
describe("with changes", () => {
it("should return diff for modified files", async () => {
const mockGit = createMockGit({
diffSummary: createMockDiffSummary({
files: [
{ file: "src/index.ts", insertions: 5, deletions: 2, binary: false },
],
insertions: 5,
deletions: 2,
}),
diff: "diff --git a/src/index.ts",
})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitDiffResult
expect(data.hasChanges).toBe(true)
expect(data.files).toHaveLength(1)
expect(data.files[0].file).toBe("src/index.ts")
expect(data.files[0].insertions).toBe(5)
expect(data.files[0].deletions).toBe(2)
})
it("should return multiple files", async () => {
const mockGit = createMockGit({
diffSummary: createMockDiffSummary({
files: [
{ file: "a.ts", insertions: 1, deletions: 0, binary: false },
{ file: "b.ts", insertions: 2, deletions: 1, binary: false },
{ file: "c.ts", insertions: 0, deletions: 5, binary: false },
],
insertions: 3,
deletions: 6,
}),
})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitDiffResult
expect(data.files).toHaveLength(3)
expect(data.summary.filesChanged).toBe(3)
expect(data.summary.insertions).toBe(3)
expect(data.summary.deletions).toBe(6)
})
it("should handle binary files", async () => {
const mockGit = createMockGit({
diffSummary: createMockDiffSummary({
files: [
{ file: "image.png", insertions: 0, deletions: 0, binary: true },
],
}),
})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitDiffResult
expect(data.files[0].binary).toBe(true)
})
})
describe("staged parameter", () => {
it("should default to false (unstaged)", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitDiffResult
expect(data.staged).toBe(false)
expect(mockGit.diffSummary).toHaveBeenCalledWith([])
})
it("should pass --cached for staged=true", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({ staged: true }, ctx)
expect(result.success).toBe(true)
const data = result.data as GitDiffResult
expect(data.staged).toBe(true)
expect(mockGit.diffSummary).toHaveBeenCalledWith(["--cached"])
})
})
describe("path parameter", () => {
it("should filter by path", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({ path: "src" }, ctx)
expect(result.success).toBe(true)
const data = result.data as GitDiffResult
expect(data.pathFilter).toBe("src")
expect(mockGit.diffSummary).toHaveBeenCalledWith(["--", "src"])
})
it("should combine staged and path", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute(
{ staged: true, path: "src/index.ts" },
ctx,
)
expect(result.success).toBe(true)
expect(mockGit.diffSummary).toHaveBeenCalledWith([
"--cached",
"--",
"src/index.ts",
])
})
it("should return null pathFilter when not provided", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitDiffResult
expect(data.pathFilter).toBeNull()
})
})
describe("diff text", () => {
it("should include full diff text", async () => {
const diffText = `diff --git a/src/index.ts b/src/index.ts
index abc123..def456 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,3 +1,4 @@
+import { foo } from "./foo"
export function main() {
console.log("hello")
}`
const mockGit = createMockGit({
diffSummary: createMockDiffSummary({
files: [
{ file: "src/index.ts", insertions: 1, deletions: 0, binary: false },
],
}),
diff: diffText,
})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitDiffResult
expect(data.diff).toBe(diffText)
expect(data.diff).toContain("diff --git")
expect(data.diff).toContain("import { foo }")
})
})
describe("error handling", () => {
it("should handle git command errors", async () => {
const mockGit = createMockGit({
error: new Error("Git command failed"),
})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("Git command failed")
})
it("should handle non-Error exceptions", async () => {
const mockGit = {
checkIsRepo: vi.fn().mockResolvedValue(true),
diffSummary: vi.fn().mockRejectedValue("string error"),
} as unknown as SimpleGit
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(false)
expect(result.error).toBe("string error")
})
})
describe("timing", () => {
it("should return timing information", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
})
})
describe("call id", () => {
it("should generate unique call id", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitDiffTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.callId).toMatch(/^git_diff-\d+$/)
})
})
})
})

View File

@@ -0,0 +1,503 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import {
GitStatusTool,
type GitStatusResult,
} from "../../../../../src/infrastructure/tools/git/GitStatusTool.js"
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
import type { SimpleGit, StatusResult } from "simple-git"
function createMockStorage(): IStorage {
return {
getFile: vi.fn(),
setFile: vi.fn(),
deleteFile: vi.fn(),
getAllFiles: vi.fn().mockResolvedValue(new Map()),
getFileCount: vi.fn().mockResolvedValue(0),
getAST: vi.fn(),
setAST: vi.fn(),
deleteAST: vi.fn(),
getAllASTs: vi.fn().mockResolvedValue(new Map()),
getMeta: vi.fn(),
setMeta: vi.fn(),
deleteMeta: vi.fn(),
getAllMetas: vi.fn().mockResolvedValue(new Map()),
getSymbolIndex: vi.fn().mockResolvedValue(new Map()),
setSymbolIndex: vi.fn(),
getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }),
setDepsGraph: vi.fn(),
getProjectConfig: vi.fn(),
setProjectConfig: vi.fn(),
connect: vi.fn(),
disconnect: vi.fn(),
isConnected: vi.fn().mockReturnValue(true),
clear: vi.fn(),
} as unknown as IStorage
}
function createMockContext(storage?: IStorage): ToolContext {
return {
projectRoot: "/test/project",
storage: storage ?? createMockStorage(),
requestConfirmation: vi.fn().mockResolvedValue(true),
onProgress: vi.fn(),
}
}
function createMockStatusResult(overrides: Partial<StatusResult> = {}): StatusResult {
return {
not_added: [],
conflicted: [],
created: [],
deleted: [],
ignored: [],
modified: [],
renamed: [],
files: [],
staged: [],
ahead: 0,
behind: 0,
current: "main",
tracking: "origin/main",
detached: false,
isClean: () => true,
...overrides,
} as StatusResult
}
function createMockGit(options: {
isRepo?: boolean
status?: StatusResult
error?: Error
}): SimpleGit {
const mockGit = {
checkIsRepo: vi.fn().mockResolvedValue(options.isRepo ?? true),
status: vi.fn(),
}
if (options.error) {
mockGit.status.mockRejectedValue(options.error)
} else {
mockGit.status.mockResolvedValue(options.status ?? createMockStatusResult())
}
return mockGit as unknown as SimpleGit
}
describe("GitStatusTool", () => {
let tool: GitStatusTool
beforeEach(() => {
tool = new GitStatusTool()
})
describe("metadata", () => {
it("should have correct name", () => {
expect(tool.name).toBe("git_status")
})
it("should have correct category", () => {
expect(tool.category).toBe("git")
})
it("should not require confirmation", () => {
expect(tool.requiresConfirmation).toBe(false)
})
it("should have no parameters", () => {
expect(tool.parameters).toHaveLength(0)
})
it("should have description", () => {
expect(tool.description).toContain("git")
expect(tool.description).toContain("status")
})
})
describe("validateParams", () => {
it("should return null for empty params", () => {
expect(tool.validateParams({})).toBeNull()
})
it("should return null for any params (no required)", () => {
expect(tool.validateParams({ foo: "bar" })).toBeNull()
})
})
describe("execute", () => {
describe("not a git repository", () => {
it("should return error when not in a git repo", async () => {
const mockGit = createMockGit({ isRepo: false })
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("Not a git repository")
})
})
describe("clean repository", () => {
it("should return clean status", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({
current: "main",
tracking: "origin/main",
ahead: 0,
behind: 0,
isClean: () => true,
}),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.branch).toBe("main")
expect(data.tracking).toBe("origin/main")
expect(data.isClean).toBe(true)
expect(data.staged).toHaveLength(0)
expect(data.modified).toHaveLength(0)
expect(data.untracked).toHaveLength(0)
})
})
describe("branch information", () => {
it("should return current branch name", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({ current: "feature/test" }),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.branch).toBe("feature/test")
})
it("should handle detached HEAD", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({ current: null }),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.branch).toBe("HEAD (detached)")
})
it("should return tracking branch when available", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({ tracking: "origin/develop" }),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.tracking).toBe("origin/develop")
})
it("should handle no tracking branch", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({ tracking: null }),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.tracking).toBeNull()
})
it("should return ahead/behind counts", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({ ahead: 3, behind: 1 }),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.ahead).toBe(3)
expect(data.behind).toBe(1)
})
})
describe("staged files", () => {
it("should return staged files (new file)", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({
files: [{ path: "new.ts", index: "A", working_dir: " " }],
isClean: () => false,
}),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.staged).toHaveLength(1)
expect(data.staged[0].path).toBe("new.ts")
expect(data.staged[0].index).toBe("A")
})
it("should return staged files (modified)", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({
files: [{ path: "src/index.ts", index: "M", working_dir: " " }],
isClean: () => false,
}),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.staged).toHaveLength(1)
expect(data.staged[0].path).toBe("src/index.ts")
expect(data.staged[0].index).toBe("M")
})
it("should return staged files (deleted)", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({
files: [{ path: "old.ts", index: "D", working_dir: " " }],
isClean: () => false,
}),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.staged).toHaveLength(1)
expect(data.staged[0].index).toBe("D")
})
it("should return multiple staged files", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({
files: [
{ path: "a.ts", index: "A", working_dir: " " },
{ path: "b.ts", index: "M", working_dir: " " },
{ path: "c.ts", index: "D", working_dir: " " },
],
isClean: () => false,
}),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.staged).toHaveLength(3)
})
})
describe("modified files", () => {
it("should return modified unstaged files", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({
files: [{ path: "src/app.ts", index: " ", working_dir: "M" }],
isClean: () => false,
}),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.modified).toHaveLength(1)
expect(data.modified[0].path).toBe("src/app.ts")
expect(data.modified[0].workingDir).toBe("M")
})
it("should return deleted unstaged files", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({
files: [{ path: "deleted.ts", index: " ", working_dir: "D" }],
isClean: () => false,
}),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.modified).toHaveLength(1)
expect(data.modified[0].workingDir).toBe("D")
})
})
describe("untracked files", () => {
it("should return untracked files", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({
not_added: ["new-file.ts", "another.js"],
isClean: () => false,
}),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.untracked).toContain("new-file.ts")
expect(data.untracked).toContain("another.js")
})
})
describe("conflicted files", () => {
it("should return conflicted files", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({
conflicted: ["conflict.ts"],
isClean: () => false,
}),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.conflicted).toContain("conflict.ts")
})
})
describe("mixed status", () => {
it("should correctly categorize files with both staged and unstaged changes", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({
files: [{ path: "both.ts", index: "M", working_dir: "M" }],
isClean: () => false,
}),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.staged).toHaveLength(1)
expect(data.modified).toHaveLength(1)
expect(data.staged[0].path).toBe("both.ts")
expect(data.modified[0].path).toBe("both.ts")
})
it("should not include untracked in staged/modified", async () => {
const mockGit = createMockGit({
status: createMockStatusResult({
files: [{ path: "new.ts", index: "?", working_dir: "?" }],
not_added: ["new.ts"],
isClean: () => false,
}),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(true)
const data = result.data as GitStatusResult
expect(data.staged).toHaveLength(0)
expect(data.modified).toHaveLength(0)
expect(data.untracked).toContain("new.ts")
})
})
describe("error handling", () => {
it("should handle git command errors", async () => {
const mockGit = createMockGit({
error: new Error("Git command failed"),
})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(false)
expect(result.error).toContain("Git command failed")
})
it("should handle non-Error exceptions", async () => {
const mockGit = {
checkIsRepo: vi.fn().mockResolvedValue(true),
status: vi.fn().mockRejectedValue("string error"),
} as unknown as SimpleGit
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.success).toBe(false)
expect(result.error).toBe("string error")
})
})
describe("timing", () => {
it("should return timing information", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
})
it("should include timing on error", async () => {
const mockGit = createMockGit({ error: new Error("fail") })
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
})
})
describe("call id", () => {
it("should generate unique call id", async () => {
const mockGit = createMockGit({})
const toolWithMock = new GitStatusTool(() => mockGit)
const ctx = createMockContext()
const result = await toolWithMock.execute({}, ctx)
expect(result.callId).toMatch(/^git_status-\d+$/)
})
})
})
})