Skip to content

Commit 31f4cd8

Browse files
author
Test
committed
feat: add auto-test runner and git auto-commit
Auto-test runner: - Detect project type and test command - Run tests and analyze results - Support snapshot updates with -u flag - Analyze test output for failures and snapshots Git auto-commit: - GitManager for git operations - Auto-commit with descriptive messages - TaskCommitter for automatic git workflow - Push to remote when available - Smart commit message generation based on changes
1 parent 68d2e78 commit 31f4cd8

File tree

2 files changed

+490
-0
lines changed

2 files changed

+490
-0
lines changed

internal/autonomous/git_manager.go

Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
package autonomous
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os/exec"
7+
"strings"
8+
"time"
9+
)
10+
11+
type GitManager struct {
12+
cwd string
13+
}
14+
15+
func NewGitManager(cwd string) *GitManager {
16+
return &GitManager{cwd: cwd}
17+
}
18+
19+
type CommitResult struct {
20+
Success bool
21+
Message string
22+
Files []string
23+
CommitSHA string
24+
}
25+
26+
// HasChanges checks if there are uncommitted changes
27+
func (g *GitManager) HasChanges() (bool, error) {
28+
cmd := exec.Command("git", "status", "--porcelain")
29+
cmd.Dir = g.cwd
30+
output, err := cmd.CombinedOutput()
31+
if err != nil {
32+
return false, fmt.Errorf("git status failed: %w", err)
33+
}
34+
return len(strings.TrimSpace(string(output))) > 0, nil
35+
}
36+
37+
// GetChangedFiles returns list of changed files
38+
func (g *GitManager) GetChangedFiles() ([]string, error) {
39+
cmd := exec.Command("git", "diff", "--name-only")
40+
cmd.Dir = g.cwd
41+
output, err := cmd.Output()
42+
if err != nil {
43+
return nil, fmt.Errorf("git diff failed: %w", err)
44+
}
45+
46+
files := strings.Split(strings.TrimSpace(string(output)), "\n")
47+
var result []string
48+
for _, f := range files {
49+
if f != "" {
50+
result = append(result, f)
51+
}
52+
}
53+
return result, nil
54+
}
55+
56+
// GetStagedFiles returns list of staged files
57+
func (g *GitManager) GetStagedFiles() ([]string, error) {
58+
cmd := exec.Command("git", "diff", "--cached", "--name-only")
59+
cmd.Dir = g.cwd
60+
output, err := cmd.Output()
61+
if err != nil {
62+
return nil, fmt.Errorf("git diff --cached failed: %w", err)
63+
}
64+
65+
files := strings.Split(strings.TrimSpace(string(output)), "\n")
66+
var result []string
67+
for _, f := range files {
68+
if f != "" {
69+
result = append(result, f)
70+
}
71+
}
72+
return result, nil
73+
}
74+
75+
// AutoCommit creates an automatic commit with a descriptive message
76+
func (g *GitManager) AutoCommit(context context.Context, taskDescription string) (*CommitResult, error) {
77+
result := &CommitResult{}
78+
79+
// Check for changes
80+
hasChanges, err := g.HasChanges()
81+
if err != nil {
82+
return nil, err
83+
}
84+
if !hasChanges {
85+
result.Success = true
86+
result.Message = "No changes to commit"
87+
return result, nil
88+
}
89+
90+
// Stage all changes
91+
cmd := exec.Command("git", "add", "-A")
92+
cmd.Dir = g.cwd
93+
if err := cmd.Run(); err != nil {
94+
return nil, fmt.Errorf("git add failed: %w", err)
95+
}
96+
97+
// Get staged files
98+
stagedFiles, err := g.GetStagedFiles()
99+
if err != nil {
100+
return nil, err
101+
}
102+
result.Files = stagedFiles
103+
104+
// Generate commit message
105+
commitMsg := g.GenerateCommitMessage(taskDescription, stagedFiles)
106+
107+
// Commit
108+
cmd = exec.Command("git", "commit", "-m", commitMsg)
109+
cmd.Dir = g.cwd
110+
output, err := cmd.CombinedOutput()
111+
if err != nil {
112+
return nil, fmt.Errorf("git commit failed: %w: %s", err, string(output))
113+
}
114+
115+
result.Success = true
116+
result.Message = commitMsg
117+
118+
// Get commit SHA
119+
cmd = exec.Command("git", "rev-parse", "HEAD")
120+
cmd.Dir = g.cwd
121+
sha, err := cmd.Output()
122+
if err == nil {
123+
result.CommitSHA = strings.TrimSpace(string(sha))
124+
}
125+
126+
return result, nil
127+
}
128+
129+
// GenerateCommitMessage creates a commit message based on changes
130+
func (g *GitManager) GenerateCommitMessage(task string, files []string) string {
131+
// Analyze file types
132+
var fileTypes []string
133+
var hasTests, hasDocs, hasFix bool
134+
135+
for _, f := range files {
136+
lower := strings.ToLower(f)
137+
if strings.HasSuffix(f, "_test.go") || strings.HasSuffix(f, ".test.ts") || strings.HasSuffix(f, ".spec.ts") {
138+
hasTests = true
139+
} else if strings.HasSuffix(f, ".md") || strings.HasSuffix(f, ".txt") {
140+
hasDocs = true
141+
} else if strings.Contains(lower, "fix") || strings.Contains(lower, "bug") {
142+
hasFix = true
143+
}
144+
145+
ext := getExtension(f)
146+
if ext != "" && !contains(fileTypes, ext) {
147+
fileTypes = append(fileTypes, ext)
148+
}
149+
}
150+
151+
// Generate message
152+
var prefix string
153+
if hasFix {
154+
prefix = "fix"
155+
} else if hasTests && !hasDocs {
156+
prefix = "test"
157+
} else if hasDocs && !hasTests {
158+
prefix = "docs"
159+
} else {
160+
prefix = "chore"
161+
}
162+
163+
// Clean task description
164+
task = cleanTaskDescription(task)
165+
166+
// Build message
167+
msg := fmt.Sprintf("%s: %s", prefix, task)
168+
169+
if len(fileTypes) > 0 {
170+
msg += fmt.Sprintf(" [%s]", strings.Join(fileTypes, ", "))
171+
}
172+
173+
return msg
174+
}
175+
176+
// HasRemote checks if there's a remote to push to
177+
func (g *GitManager) HasRemote() bool {
178+
cmd := exec.Command("git", "remote", "-v")
179+
cmd.Dir = g.cwd
180+
output, err := cmd.Output()
181+
if err != nil {
182+
return false
183+
}
184+
return len(strings.TrimSpace(string(output))) > 0
185+
}
186+
187+
// Push pushes commits to remote
188+
func (g *GitManager) Push() error {
189+
cmd := exec.Command("git", "push")
190+
cmd.Dir = g.cwd
191+
output, err := cmd.CombinedOutput()
192+
if err != nil {
193+
return fmt.Errorf("git push failed: %w: %s", err, string(output))
194+
}
195+
return nil
196+
}
197+
198+
// GetStatus returns current git status
199+
func (g *GitManager) GetStatus() (string, error) {
200+
cmd := exec.Command("git", "status")
201+
cmd.Dir = g.cwd
202+
output, err := cmd.Output()
203+
if err != nil {
204+
return "", fmt.Errorf("git status failed: %w", err)
205+
}
206+
return string(output), nil
207+
}
208+
209+
// GetCurrentBranch returns the current branch name
210+
func (g *GitManager) GetCurrentBranch() (string, error) {
211+
cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
212+
cmd.Dir = g.cwd
213+
output, err := cmd.Output()
214+
if err != nil {
215+
return "", fmt.Errorf("git branch failed: %w", err)
216+
}
217+
return strings.TrimSpace(string(output)), nil
218+
}
219+
220+
// CreateBranch creates a new branch
221+
func (g *GitManager) CreateBranch(name string) error {
222+
cmd := exec.Command("git", "checkout", "-b", name)
223+
cmd.Dir = g.cwd
224+
output, err := cmd.CombinedOutput()
225+
if err != nil {
226+
return fmt.Errorf("git checkout -b failed: %w: %s", err, string(output))
227+
}
228+
return nil
229+
}
230+
231+
func getExtension(path string) string {
232+
parts := strings.Split(path, ".")
233+
if len(parts) > 1 {
234+
return parts[len(parts)-1]
235+
}
236+
return ""
237+
}
238+
239+
func contains(slice []string, item string) bool {
240+
for _, s := range slice {
241+
if s == item {
242+
return true
243+
}
244+
}
245+
return false
246+
}
247+
248+
func cleanTaskDescription(task string) string {
249+
// Remove common prefixes
250+
task = strings.TrimPrefix(task, "fix: ")
251+
task = strings.TrimPrefix(task, "fix ")
252+
task = strings.TrimPrefix(task, "Fix ")
253+
254+
// Truncate if too long
255+
if len(task) > 72 {
256+
task = task[:69] + "..."
257+
}
258+
259+
return task
260+
}
261+
262+
// TaskCommitter helps with automatic git operations for tasks
263+
type TaskCommitter struct {
264+
git *GitManager
265+
}
266+
267+
func NewTaskCommitter(cwd string) *TaskCommitter {
268+
return &TaskCommitter{
269+
git: NewGitManager(cwd),
270+
}
271+
}
272+
273+
// CommitTask automatically commits with appropriate message
274+
func (tc *TaskCommitter) CommitTask(ctx context.Context, task string) (*CommitResult, error) {
275+
return tc.git.AutoCommit(ctx, task)
276+
}
277+
278+
// ShouldCommit determines if a commit is worthwhile
279+
func (tc *TaskCommitter) ShouldCommit() (bool, error) {
280+
hasChanges, err := tc.git.HasChanges()
281+
if err != nil || !hasChanges {
282+
return false, err
283+
}
284+
285+
// Don't commit if only lock files or generated files changed
286+
files, err := tc.git.GetChangedFiles()
287+
if err != nil {
288+
return false, err
289+
}
290+
291+
skipFiles := map[string]bool{
292+
"package-lock.json": true,
293+
"yarn.lock": true,
294+
"go.sum": true,
295+
"Gemfile.lock": true,
296+
"pnpm-lock.yaml": true,
297+
}
298+
299+
for _, f := range files {
300+
// Skip if any meaningful file changed
301+
if !skipFiles[f] && !strings.HasSuffix(f, ".lock") {
302+
return true, nil
303+
}
304+
}
305+
306+
return false, nil
307+
}
308+
309+
// AutoGitWorkflow runs a complete git workflow: commit and optionally push
310+
func (tc *TaskCommitter) AutoGitWorkflow(ctx context.Context, task string, push bool) (*CommitResult, error) {
311+
// Check if we should commit
312+
shouldCommit, err := tc.ShouldCommit()
313+
if err != nil {
314+
return nil, err
315+
}
316+
if !shouldCommit {
317+
return &CommitResult{
318+
Success: true,
319+
Message: "No meaningful changes to commit",
320+
}, nil
321+
}
322+
323+
// Commit
324+
result, err := tc.git.AutoCommit(ctx, task)
325+
if err != nil {
326+
return nil, err
327+
}
328+
if !result.Success {
329+
return result, nil
330+
}
331+
332+
// Push if requested and available
333+
if push && tc.git.HasRemote() {
334+
if err := tc.git.Push(); err != nil {
335+
result.Message += " (push failed)"
336+
} else {
337+
result.Message += " (pushed)"
338+
}
339+
}
340+
341+
return result, nil
342+
}
343+
344+
// GetTimestamp returns current timestamp for commit messages
345+
func GetTimestamp() string {
346+
return time.Now().Format("2006-01-02 15:04:05")
347+
}

0 commit comments

Comments
 (0)