Skip to content

Commit d52e7b8

Browse files
committed
feat: add git integration with auto-commit and auto-push
- Add git.ts module with hasChanges, generateCommitMessage, commitChanges, pushChanges - Auto-commit changes after each successful task (configurable) - Auto-push commits after each completed cycle (configurable) - Support commit signoff (-s flag) for DCO compliance - Generate conventional commit messages based on task descriptions - Add comprehensive tests for git module Signed-off-by: leocavalcante <[email protected]>
1 parent c6a161c commit d52e7b8

File tree

2 files changed

+262
-0
lines changed

2 files changed

+262
-0
lines changed

src/git.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* Git operations helper module
3+
*/
4+
5+
import { execSync } from "node:child_process"
6+
7+
import type { Logger } from "./logger.ts"
8+
9+
export function hasChanges(projectDir: string): boolean {
10+
try {
11+
const output = execSync("git status --porcelain", {
12+
cwd: projectDir,
13+
encoding: "utf-8",
14+
})
15+
return output.trim().length > 0
16+
} catch {
17+
return false
18+
}
19+
}
20+
21+
export function generateCommitMessage(taskDescription: string): string {
22+
const lowerDesc = taskDescription.toLowerCase()
23+
24+
// Check more specific patterns first before generic ones like "add"
25+
26+
if (
27+
lowerDesc.includes("fix") ||
28+
lowerDesc.includes("bug") ||
29+
lowerDesc.includes("resolve") ||
30+
lowerDesc.includes("issue")
31+
) {
32+
return `fix: ${taskDescription}`
33+
}
34+
35+
if (lowerDesc.includes("test") || lowerDesc.includes("spec") || lowerDesc.includes("coverage")) {
36+
return `test: ${taskDescription}`
37+
}
38+
39+
if (
40+
lowerDesc.includes("docs") ||
41+
lowerDesc.includes("documentation") ||
42+
lowerDesc.includes("readme") ||
43+
lowerDesc.includes("comment")
44+
) {
45+
return `docs: ${taskDescription}`
46+
}
47+
48+
if (
49+
lowerDesc.includes("refactor") ||
50+
lowerDesc.includes("rewrite") ||
51+
lowerDesc.includes("restructure") ||
52+
lowerDesc.includes("improve")
53+
) {
54+
return `refactor: ${taskDescription}`
55+
}
56+
57+
// Generic feature patterns last
58+
if (
59+
lowerDesc.includes("feat") ||
60+
lowerDesc.includes("add") ||
61+
lowerDesc.includes("implement") ||
62+
lowerDesc.includes("new")
63+
) {
64+
return `feat: ${taskDescription}`
65+
}
66+
67+
return `feat: ${taskDescription}`
68+
}
69+
70+
export function commitChanges(
71+
projectDir: string,
72+
logger: Logger,
73+
message: string,
74+
signoff: boolean,
75+
): void {
76+
try {
77+
const signoffFlag = signoff ? " -s" : ""
78+
execSync(`git add . && git commit${signoffFlag} -m "${message}"`, {
79+
cwd: projectDir,
80+
encoding: "utf-8",
81+
})
82+
logger.log(`Committed: ${message}`)
83+
} catch (err) {
84+
logger.logError(`Failed to commit changes: ${err}`)
85+
}
86+
}
87+
88+
export function pushChanges(projectDir: string, logger: Logger): void {
89+
try {
90+
execSync("git push", {
91+
cwd: projectDir,
92+
encoding: "utf-8",
93+
})
94+
logger.log("Pushed changes to remote")
95+
} catch (err) {
96+
logger.logError(`Failed to push changes: ${err}`)
97+
}
98+
}

tests/git.test.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/**
2+
* Tests for git.ts module
3+
*/
4+
5+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
6+
import { execSync } from "node:child_process"
7+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
8+
import { join } from "node:path"
9+
import { generateCommitMessage, hasChanges } from "../src/git.ts"
10+
11+
const TEST_DIR = "/tmp/opencoder-test-git"
12+
13+
describe("git", () => {
14+
describe("hasChanges", () => {
15+
beforeEach(() => {
16+
// Create a test directory with git repo
17+
if (existsSync(TEST_DIR)) {
18+
rmSync(TEST_DIR, { recursive: true })
19+
}
20+
mkdirSync(TEST_DIR, { recursive: true })
21+
execSync("git init", { cwd: TEST_DIR })
22+
execSync('git config user.email "[email protected]"', { cwd: TEST_DIR })
23+
execSync('git config user.name "Test User"', { cwd: TEST_DIR })
24+
})
25+
26+
afterEach(() => {
27+
if (existsSync(TEST_DIR)) {
28+
rmSync(TEST_DIR, { recursive: true })
29+
}
30+
})
31+
32+
test("returns false for clean repo", () => {
33+
// Create initial commit so repo is not empty
34+
writeFileSync(join(TEST_DIR, "README.md"), "# Test")
35+
execSync("git add . && git commit -m 'Initial commit'", { cwd: TEST_DIR })
36+
37+
expect(hasChanges(TEST_DIR)).toBe(false)
38+
})
39+
40+
test("returns true for untracked files", () => {
41+
// Create initial commit
42+
writeFileSync(join(TEST_DIR, "README.md"), "# Test")
43+
execSync("git add . && git commit -m 'Initial commit'", { cwd: TEST_DIR })
44+
45+
// Add untracked file
46+
writeFileSync(join(TEST_DIR, "new-file.txt"), "new content")
47+
48+
expect(hasChanges(TEST_DIR)).toBe(true)
49+
})
50+
51+
test("returns true for modified files", () => {
52+
// Create initial commit
53+
writeFileSync(join(TEST_DIR, "README.md"), "# Test")
54+
execSync("git add . && git commit -m 'Initial commit'", { cwd: TEST_DIR })
55+
56+
// Modify file
57+
writeFileSync(join(TEST_DIR, "README.md"), "# Modified")
58+
59+
expect(hasChanges(TEST_DIR)).toBe(true)
60+
})
61+
62+
test("returns true for staged files", () => {
63+
// Create initial commit
64+
writeFileSync(join(TEST_DIR, "README.md"), "# Test")
65+
execSync("git add . && git commit -m 'Initial commit'", { cwd: TEST_DIR })
66+
67+
// Stage a change
68+
writeFileSync(join(TEST_DIR, "README.md"), "# Staged")
69+
execSync("git add README.md", { cwd: TEST_DIR })
70+
71+
expect(hasChanges(TEST_DIR)).toBe(true)
72+
})
73+
74+
test("returns false for non-git directory", () => {
75+
const nonGitDir = "/tmp/opencoder-test-non-git"
76+
if (existsSync(nonGitDir)) {
77+
rmSync(nonGitDir, { recursive: true })
78+
}
79+
mkdirSync(nonGitDir, { recursive: true })
80+
81+
expect(hasChanges(nonGitDir)).toBe(false)
82+
83+
rmSync(nonGitDir, { recursive: true })
84+
})
85+
86+
test("returns false for non-existent directory", () => {
87+
expect(hasChanges("/tmp/does-not-exist-xyz")).toBe(false)
88+
})
89+
})
90+
91+
describe("generateCommitMessage", () => {
92+
test("generates fix prefix for bug-related tasks", () => {
93+
expect(generateCommitMessage("fix the login bug")).toBe("fix: fix the login bug")
94+
expect(generateCommitMessage("Fix null pointer exception")).toBe(
95+
"fix: Fix null pointer exception",
96+
)
97+
expect(generateCommitMessage("Resolve the timeout issue")).toBe(
98+
"fix: Resolve the timeout issue",
99+
)
100+
expect(generateCommitMessage("Address bug in parser")).toBe("fix: Address bug in parser")
101+
expect(generateCommitMessage("Issue with authentication")).toBe(
102+
"fix: Issue with authentication",
103+
)
104+
})
105+
106+
test("generates feat prefix for feature-related tasks", () => {
107+
expect(generateCommitMessage("Add new login feature")).toBe("feat: Add new login feature")
108+
expect(generateCommitMessage("Implement user dashboard")).toBe(
109+
"feat: Implement user dashboard",
110+
)
111+
expect(generateCommitMessage("new API endpoint for users")).toBe(
112+
"feat: new API endpoint for users",
113+
)
114+
// Note: If description already has prefix, it will be doubled
115+
expect(generateCommitMessage("dark mode support")).toBe("feat: dark mode support")
116+
})
117+
118+
test("generates test prefix for test-related tasks", () => {
119+
expect(generateCommitMessage("Add unit tests for parser")).toBe(
120+
"test: Add unit tests for parser",
121+
)
122+
expect(generateCommitMessage("Write spec for login component")).toBe(
123+
"test: Write spec for login component",
124+
)
125+
expect(generateCommitMessage("Improve test coverage")).toBe("test: Improve test coverage")
126+
})
127+
128+
test("generates docs prefix for documentation tasks", () => {
129+
expect(generateCommitMessage("Update README with instructions")).toBe(
130+
"docs: Update README with instructions",
131+
)
132+
expect(generateCommitMessage("Add documentation for API")).toBe(
133+
"docs: Add documentation for API",
134+
)
135+
expect(generateCommitMessage("Add code comments")).toBe("docs: Add code comments")
136+
})
137+
138+
test("generates refactor prefix for refactoring tasks", () => {
139+
expect(generateCommitMessage("Refactor the auth module")).toBe(
140+
"refactor: Refactor the auth module",
141+
)
142+
expect(generateCommitMessage("Rewrite parser for clarity")).toBe(
143+
"refactor: Rewrite parser for clarity",
144+
)
145+
expect(generateCommitMessage("Restructure project layout")).toBe(
146+
"refactor: Restructure project layout",
147+
)
148+
expect(generateCommitMessage("Improve code organization")).toBe(
149+
"refactor: Improve code organization",
150+
)
151+
})
152+
153+
test("defaults to feat for unrecognized patterns", () => {
154+
expect(generateCommitMessage("Update dependencies")).toBe("feat: Update dependencies")
155+
expect(generateCommitMessage("Some random task")).toBe("feat: Some random task")
156+
})
157+
158+
test("is case insensitive", () => {
159+
expect(generateCommitMessage("FIX THE BUG")).toBe("fix: FIX THE BUG")
160+
expect(generateCommitMessage("ADD NEW FEATURE")).toBe("feat: ADD NEW FEATURE")
161+
expect(generateCommitMessage("REFACTOR CODE")).toBe("refactor: REFACTOR CODE")
162+
})
163+
})
164+
})

0 commit comments

Comments
 (0)