// Package process provides git operation execution for agentctl. package process import ( "bytes" "context" "errors" "fmt" "os" "os/exec" "path/filepath" "regexp" "strings" "sync" "time" "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/common/logger" "go.uber.org/zap" ) // ErrOperationInProgress is returned when a git operation is already in progress. var ErrOperationInProgress = errors.New("git operation already in progress") // ErrInvalidBranchName is returned when a branch name contains invalid characters. var ErrInvalidBranchName = errors.New("invalid branch name") // validBranchNameRegex matches safe git branch names. // Allows alphanumeric, hyphens, underscores, slashes, and dots. // Disallows: spaces, shell metacharacters, and control characters. var validBranchNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`) // isValidBranchName validates that a branch name is safe to use in git commands. func isValidBranchName(branch string) bool { if branch == "" || len(branch) > 255 { return false } // Disallow ".." to prevent path traversal if strings.Contains(branch, "..") { return false } // Disallow ending with ".lock" if strings.HasSuffix(branch, ".lock") { return false } return validBranchNameRegex.MatchString(branch) } // GitOperationResult represents the result of a git operation. type GitOperationResult struct { Success bool `json:"success"` Operation string `json:"operation"` Output string `json:"output"` Error string `json:"error,omitempty"` ConflictFiles []string `json:"conflict_files,omitempty"` } // GitOperator executes git operations in a workspace directory. type GitOperator struct { workDir string logger *logger.Logger workspaceTracker *WorkspaceTracker mu sync.Mutex // Prevents concurrent git operations inProgress bool currentOp string } // NewGitOperator creates a new GitOperator for the given workspace directory. func NewGitOperator(workDir string, log *logger.Logger, workspaceTracker *WorkspaceTracker) *GitOperator { return &GitOperator{ workDir: workDir, logger: log.WithFields(zap.String("component", "git-operator")), workspaceTracker: workspaceTracker, } } // runGitCommand executes a git command in the workDir func (g *GitOperator) runGitCommand(ctx context.Context, args ...string) (string, error) { cmd := exec.CommandContext(ctx, "git", args...) cmd.Dir = g.workDir var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr g.logger.Debug("executing git command", zap.Strings("args", args)) err := cmd.Run() output := stdout.String() if stderr.Len() > 0 { if output != "" { output += "\n" } output += stderr.String() } if err != nil { return output, fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) } return output, nil } // filterGitEnv removes GIT_DIR and GIT_WORK_TREE from the environment. // This ensures that external tools like gh CLI correctly detect the repository // from the working directory, which is essential for worktrees where these // env vars could point to the wrong location. func filterGitEnv(env []string) []string { result := make([]string, 0, len(env)) for _, e := range env { if strings.HasPrefix(e, "GIT_DIR=") || strings.HasPrefix(e, "GIT_WORK_TREE=") { continue } result = append(result, e) } return result } // triggerFsNotify creates and removes a temporary file to trigger fsnotify and refresh git status. // This is OS-agnostic and reliably triggers filesystem events that the workspace tracker watches. // Called after git operations like commit, push, pull, etc. to refresh the UI. func (g *GitOperator) triggerFsNotify() { sentinelPath := filepath.Join(g.workDir, ".git-op-complete") f, err := os.Create(sentinelPath) if err != nil { g.logger.Debug("failed to create sentinel file", zap.Error(err)) return } _ = f.Close() if err := os.Remove(sentinelPath); err != nil { g.logger.Debug("failed to remove sentinel file", zap.Error(err)) } } // getCurrentBranch returns the current branch name func (g *GitOperator) getCurrentBranch(ctx context.Context) (string, error) { output, err := g.runGitCommand(ctx, "rev-parse", "--abbrev-ref", "HEAD") if err != nil { return "", fmt.Errorf("failed to get current branch: %w", err) } return strings.TrimSpace(output), nil } // hasUncommittedChanges checks if there are uncommitted changes func (g *GitOperator) hasUncommittedChanges(ctx context.Context) (bool, error) { output, err := g.runGitCommand(ctx, "status", "--porcelain") if err != nil { return false, fmt.Errorf("failed to check uncommitted changes: %w", err) } return strings.TrimSpace(output) != "", nil } // parseConflictFiles parses conflict file names from git output func (g *GitOperator) parseConflictFiles(output string) []string { var conflicts []string lines := strings.Split(output, "\n") for _, line := range lines { line = strings.TrimSpace(line) // Look for "CONFLICT" markers in git output if strings.HasPrefix(line, "CONFLICT") { // Extract file name from patterns like: // "CONFLICT (content): Merge conflict in " // "CONFLICT (add/add): Merge conflict in " if idx := strings.Index(line, "Merge conflict in "); idx != -1 { file := strings.TrimSpace(line[idx+len("Merge conflict in "):]) if file != "" { conflicts = append(conflicts, file) } } } } return conflicts } // Pull performs a git pull operation. func (g *GitOperator) Pull(ctx context.Context, rebase bool) (*GitOperationResult, error) { if !g.tryLock("pull") { return nil, ErrOperationInProgress } defer g.unlock() result := &GitOperationResult{ Operation: "pull", } branch, err := g.getCurrentBranch(ctx) if err != nil { result.Error = err.Error() return result, nil } var args []string if rebase { args = []string{"pull", "--rebase", "origin", branch} } else { args = []string{"pull", "origin", branch} } output, err := g.runGitCommand(ctx, args...) result.Output = output if err != nil { result.Error = err.Error() result.ConflictFiles = g.parseConflictFiles(output) // For rebase conflicts, auto-abort to restore clean state if rebase && len(result.ConflictFiles) > 0 { g.logger.Info("rebase conflict detected, aborting rebase") if _, abortErr := g.runGitCommand(ctx, "rebase", "--abort"); abortErr != nil { g.logger.Warn("failed to abort rebase", zap.Error(abortErr)) } } return result, nil } result.Success = true g.logger.Info("pull completed", zap.String("branch", branch), zap.Bool("rebase", rebase)) return result, nil } // Push performs a git push operation. func (g *GitOperator) Push(ctx context.Context, force bool, setUpstream bool) (*GitOperationResult, error) { if !g.tryLock("push") { return nil, ErrOperationInProgress } defer g.unlock() result := &GitOperationResult{ Operation: "push", } branch, err := g.getCurrentBranch(ctx) if err != nil { result.Error = err.Error() return result, nil } args := []string{"push"} if setUpstream { args = append(args, "--set-upstream") } if force { // Use --force-with-lease for safer force push args = append(args, "--force-with-lease") } args = append(args, "origin", branch) output, err := g.runGitCommand(ctx, args...) result.Output = output if err != nil { result.Error = err.Error() return result, nil } result.Success = true g.logger.Info("push completed", zap.String("branch", branch), zap.Bool("force", force), zap.Bool("set_upstream", setUpstream)) return result, nil } // Rebase performs a git rebase onto the specified base branch. func (g *GitOperator) Rebase(ctx context.Context, baseBranch string) (*GitOperationResult, error) { // Validate branch name to prevent command injection if !isValidBranchName(baseBranch) { return nil, ErrInvalidBranchName } if !g.tryLock("rebase") { return nil, ErrOperationInProgress } defer g.unlock() result := &GitOperationResult{ Operation: "rebase", } // Fetch the base branch first fetchOutput, err := g.runGitCommand(ctx, "fetch", "origin", baseBranch) if err != nil { result.Error = fmt.Sprintf("failed to fetch base branch: %s", err.Error()) result.Output = fetchOutput return result, nil } // Perform the rebase rebaseOutput, err := g.runGitCommand(ctx, "rebase", "origin/"+baseBranch) result.Output = fetchOutput + rebaseOutput if err != nil { result.Error = err.Error() result.ConflictFiles = g.parseConflictFiles(rebaseOutput) // Auto-abort rebase on conflicts to restore clean state if len(result.ConflictFiles) > 0 { g.logger.Info("rebase conflict detected, aborting rebase") if _, abortErr := g.runGitCommand(ctx, "rebase", "--abort"); abortErr != nil { g.logger.Warn("failed to abort rebase", zap.Error(abortErr)) } } return result, nil } result.Success = true g.logger.Info("rebase completed", zap.String("base_branch", baseBranch)) return result, nil } // Merge performs a git merge of the specified base branch. func (g *GitOperator) Merge(ctx context.Context, baseBranch string) (*GitOperationResult, error) { // Validate branch name to prevent command injection if !isValidBranchName(baseBranch) { return nil, ErrInvalidBranchName } if !g.tryLock("merge") { return nil, ErrOperationInProgress } defer g.unlock() result := &GitOperationResult{ Operation: "merge", } // Fetch the base branch first fetchOutput, err := g.runGitCommand(ctx, "fetch", "origin", baseBranch) if err != nil { result.Error = fmt.Sprintf("failed to fetch base branch: %s", err.Error()) result.Output = fetchOutput return result, nil } // Perform the merge mergeOutput, err := g.runGitCommand(ctx, "merge", "origin/"+baseBranch) result.Output = fetchOutput + mergeOutput if err != nil { result.Error = err.Error() result.ConflictFiles = g.parseConflictFiles(mergeOutput) // For merge conflicts, leave in place so user can resolve // Do NOT auto-abort like we do for rebase return result, nil } result.Success = true g.logger.Info("merge completed", zap.String("base_branch", baseBranch)) return result, nil } // Commit creates a git commit with the specified message. // If stageAll is true, it stages all changes before committing. func (g *GitOperator) Commit(ctx context.Context, message string, stageAll bool) (*GitOperationResult, error) { if !g.tryLock("commit") { return nil, ErrOperationInProgress } defer g.unlock() result := &GitOperationResult{ Operation: "commit", } // Check if there are changes to commit hasChanges, err := g.hasUncommittedChanges(ctx) if err != nil { result.Error = err.Error() return result, nil } if !hasChanges { result.Error = "no changes to commit" return result, nil } // Stage all changes if requested if stageAll { stageOutput, err := g.runGitCommand(ctx, "add", "-A") if err != nil { result.Error = fmt.Sprintf("failed to stage changes: %s", err.Error()) result.Output = stageOutput return result, nil } result.Output = stageOutput } // Create the commit commitOutput, err := g.runGitCommand(ctx, "commit", "-m", message) result.Output += commitOutput if err != nil { result.Error = err.Error() return result, nil } result.Success = true g.logger.Info("commit completed", zap.String("message", message)) // Publish commit notification if we have a workspace tracker if g.workspaceTracker != nil { // Get commit details commitSHA, _ := g.runGitCommand(ctx, "rev-parse", "HEAD") parentSHA, _ := g.runGitCommand(ctx, "rev-parse", "HEAD~1") // Get commit info (author name|author email) authorInfo, _ := g.runGitCommand(ctx, "show", "-s", "--format=%an|%ae", "HEAD") authorParts := strings.Split(strings.TrimSpace(authorInfo), "|") authorName := "" authorEmail := "" if len(authorParts) >= 2 { authorName = authorParts[0] authorEmail = authorParts[1] } // Get commit stats filesChanged, insertions, deletions := g.getCommitStats(ctx, strings.TrimSpace(commitSHA)) commit := &streams.GitCommitNotification{ CommitSHA: strings.TrimSpace(commitSHA), ParentSHA: strings.TrimSpace(parentSHA), Message: message, AuthorName: authorName, AuthorEmail: authorEmail, FilesChanged: filesChanged, Insertions: insertions, Deletions: deletions, CommittedAt: time.Now().UTC(), } g.workspaceTracker.NotifyGitCommit(commit) } return result, nil } // Stage stages files for commit using git add. // If paths is empty, stages all changes (git add -A). func (g *GitOperator) Stage(ctx context.Context, paths []string) (*GitOperationResult, error) { if !g.tryLock("stage") { return nil, ErrOperationInProgress } defer g.unlock() result := &GitOperationResult{ Operation: "stage", } var args []string if len(paths) == 0 { // Stage all changes args = []string{"add", "-A"} } else { // Stage specific files args = append([]string{"add", "--"}, paths...) } output, err := g.runGitCommand(ctx, args...) result.Output = output if err != nil { result.Error = err.Error() return result, nil } result.Success = true g.logger.Info("stage completed", zap.Int("files", len(paths))) // Refresh git status so the UI reflects the staged state if g.workspaceTracker != nil { g.workspaceTracker.RefreshGitStatus(ctx) } return result, nil } // Unstage unstages files from the index using git reset. // If paths is empty, unstages all changes (git reset HEAD). func (g *GitOperator) Unstage(ctx context.Context, paths []string) (*GitOperationResult, error) { if !g.tryLock("unstage") { return nil, ErrOperationInProgress } defer g.unlock() result := &GitOperationResult{ Operation: "unstage", } var args []string if len(paths) == 0 { // Unstage all changes args = []string{"reset", "HEAD"} } else { // Unstage specific files args = append([]string{"reset", "HEAD", "--"}, paths...) } output, err := g.runGitCommand(ctx, args...) result.Output = output if err != nil { result.Error = err.Error() return result, nil } result.Success = true g.logger.Info("unstage completed", zap.Int("files", len(paths))) // Refresh git status so the UI reflects the unstaged state if g.workspaceTracker != nil { g.workspaceTracker.RefreshGitStatus(ctx) } return result, nil } // Discard discards changes to files, reverting them to HEAD state. // This removes both staged and unstaged changes. // If paths is empty, returns an error (discarding all files requires explicit confirmation). func (g *GitOperator) Discard(ctx context.Context, paths []string) (*GitOperationResult, error) { if !g.tryLock("discard") { return nil, ErrOperationInProgress } defer g.unlock() result := &GitOperationResult{ Operation: "discard", } // Require explicit paths for safety if len(paths) == 0 { result.Error = "no files specified to discard" return result, nil } // Separate files into categories based on their git status // We need to handle untracked/new files differently from tracked files untrackedFiles := []string{} trackedFiles := []string{} // Get status for each file to determine how to discard it for _, path := range paths { statusArgs := []string{"status", "--porcelain", "--", path} statusOutput, err := g.runGitCommand(ctx, statusArgs...) if err != nil { // If we can't get status, assume it's tracked and try to restore it trackedFiles = append(trackedFiles, path) continue } statusLine := strings.TrimSpace(statusOutput) if len(statusLine) >= 2 { indexStatus := statusLine[0] workTreeStatus := statusLine[1] // Untracked files (??), or added files (A ) that don't exist in HEAD if (indexStatus == '?' && workTreeStatus == '?') || indexStatus == 'A' { untrackedFiles = append(untrackedFiles, path) } else { trackedFiles = append(trackedFiles, path) } } else if statusLine == "" { // Empty status means file is not modified - nothing to discard continue } } var outputs []string var errors []string // Handle untracked/new files - remove them from filesystem and index if len(untrackedFiles) > 0 { for _, path := range untrackedFiles { // First, remove from index if staged resetArgs := []string{"rm", "--cached", "--force", "--", path} resetOutput, resetErr := g.runGitCommand(ctx, resetArgs...) if resetErr != nil && !strings.Contains(resetErr.Error(), "did not match any files") { // Ignore "did not match" errors as file might not be in index errors = append(errors, fmt.Sprintf("failed to unstage %s: %s", path, resetErr.Error())) } if resetOutput != "" { outputs = append(outputs, resetOutput) } // Then remove from working tree fullPath := filepath.Join(g.workDir, path) if err := os.Remove(fullPath); err != nil && !os.IsNotExist(err) { errors = append(errors, fmt.Sprintf("failed to remove %s: %s", path, err.Error())) } } } // Handle tracked files - restore from HEAD if len(trackedFiles) > 0 { // Use git restore to discard changes (Git 2.23+) // --source=HEAD: restore from HEAD // --staged: remove from index // --worktree: remove from working tree // --: separator between options and paths args := append([]string{"restore", "--source=HEAD", "--staged", "--worktree", "--"}, trackedFiles...) output, err := g.runGitCommand(ctx, args...) if output != "" { outputs = append(outputs, output) } if err != nil { errors = append(errors, err.Error()) } } // Combine outputs and errors result.Output = strings.Join(outputs, "\n") if len(errors) > 0 { result.Error = strings.Join(errors, "; ") result.Success = false } else { result.Success = true } g.triggerFsNotify() g.logger.Info("discard completed", zap.Int("total_files", len(paths)), zap.Int("untracked_files", len(untrackedFiles)), zap.Int("tracked_files", len(trackedFiles)), zap.Bool("success", result.Success)) return result, nil } // Abort aborts an in-progress merge or rebase operation. func (g *GitOperator) Abort(ctx context.Context, operation string) (*GitOperationResult, error) { if !g.tryLock("abort") { return nil, ErrOperationInProgress } defer g.unlock() result := &GitOperationResult{ Operation: "abort", } var args []string switch operation { case "merge": args = []string{"merge", "--abort"} case "rebase": args = []string{"rebase", "--abort"} default: result.Error = fmt.Sprintf("unsupported operation to abort: %s (must be 'merge' or 'rebase')", operation) return result, nil } output, err := g.runGitCommand(ctx, args...) result.Output = output if err != nil { result.Error = err.Error() return result, nil } result.Success = true g.logger.Info("abort completed", zap.String("operation", operation)) return result, nil } // CommitDiffResult represents the result of getting a commit's diff. type CommitDiffResult struct { Success bool `json:"success"` CommitSHA string `json:"commit_sha"` Message string `json:"message"` Author string `json:"author"` Date string `json:"date"` Files map[string]interface{} `json:"files"` // FileInfo objects with diff content FilesChanged int `json:"files_changed"` Insertions int `json:"insertions"` Deletions int `json:"deletions"` Error string `json:"error,omitempty"` } // ShowCommit gets the diff for a specific commit using git show. func (g *GitOperator) ShowCommit(ctx context.Context, commitSHA string) (*CommitDiffResult, error) { result := &CommitDiffResult{ CommitSHA: commitSHA, } // Validate commit SHA (basic validation - alphanumeric only) if commitSHA == "" || len(commitSHA) > 64 { result.Error = "invalid commit SHA" return result, nil } for _, c := range commitSHA { isDigit := c >= '0' && c <= '9' isLowerHex := c >= 'a' && c <= 'f' isUpperHex := c >= 'A' && c <= 'F' if !isDigit && !isLowerHex && !isUpperHex { result.Error = "invalid commit SHA: must be hexadecimal" return result, nil } } // Get commit metadata formatOutput, err := g.runGitCommand(ctx, "show", "--no-patch", "--format=%H%n%s%n%an <%ae>%n%aI", commitSHA) if err != nil { result.Error = fmt.Sprintf("failed to get commit info: %s", err.Error()) return result, nil } lines := strings.Split(strings.TrimSpace(formatOutput), "\n") if len(lines) >= 4 { result.CommitSHA = lines[0] result.Message = lines[1] result.Author = lines[2] result.Date = lines[3] } // Get the diff with stats diffOutput, err := g.runGitCommand(ctx, "show", "--format=", "--stat", "--numstat", "-p", commitSHA) if err != nil { result.Error = fmt.Sprintf("failed to get commit diff: %s", err.Error()) return result, nil } // Parse the diff output into files result.Files = g.parseCommitDiff(diffOutput) result.FilesChanged = len(result.Files) // Calculate total insertions/deletions for _, fileInfo := range result.Files { if fi, ok := fileInfo.(map[string]interface{}); ok { if additions, ok := fi["additions"].(int); ok { result.Insertions += additions } if deletions, ok := fi["deletions"].(int); ok { result.Deletions += deletions } } } result.Success = true return result, nil } // parseCommitDiff parses git show output into file info map func (g *GitOperator) parseCommitDiff(output string) map[string]interface{} { files := make(map[string]interface{}) // Split by "diff --git" to get individual file diffs parts := strings.Split(output, "diff --git ") if len(parts) <= 1 { return files } for _, part := range parts[1:] { if part == "" { continue } // Re-add the "diff --git " prefix diffContent := "diff --git " + part // Extract file path from the diff header // Format: diff --git a/path/to/file b/path/to/file lines := strings.SplitN(diffContent, "\n", 2) if len(lines) == 0 { continue } header := lines[0] // Extract path from "diff --git a/path b/path" headerParts := strings.Split(header, " ") if len(headerParts) < 4 { continue } // Get the b/path part and remove the "b/" prefix bPath := headerParts[len(headerParts)-1] filePath := strings.TrimPrefix(bPath, "b/") // Determine file status from diff content status := "modified" if strings.Contains(diffContent, "new file mode") { status = "added" } else if strings.Contains(diffContent, "deleted file mode") { status = "deleted" } else if strings.Contains(diffContent, "rename from") { status = "renamed" } // Count additions and deletions additions := 0 deletions := 0 for _, line := range strings.Split(diffContent, "\n") { if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") { additions++ } else if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") { deletions++ } } files[filePath] = map[string]interface{}{ "status": status, "staged": false, "additions": additions, "deletions": deletions, "diff": diffContent, } } return files } // tryLock attempts to acquire the operation lock without blocking. // Returns true if the lock was acquired, false if an operation is in progress. func (g *GitOperator) tryLock(opName string) bool { g.mu.Lock() defer g.mu.Unlock() if g.inProgress { return false } g.inProgress = true g.currentOp = opName return true } // unlock releases the operation lock and triggers a git status refresh. func (g *GitOperator) unlock() { g.mu.Lock() defer g.mu.Unlock() g.inProgress = false g.currentOp = "" // Trigger fsnotify to refresh git status in the workspace tracker. // This is called after every git operation completes. g.triggerFsNotify() } // PRCreateResult represents the result of a PR creation operation. type PRCreateResult struct { Success bool `json:"success"` PRURL string `json:"pr_url,omitempty"` Output string `json:"output,omitempty"` Error string `json:"error,omitempty"` } // CreatePR creates a pull request using the gh CLI. // It first pushes the current branch to the remote, then creates the PR. func (g *GitOperator) CreatePR(ctx context.Context, title, body, baseBranch string, draft bool) (*PRCreateResult, error) { if !g.tryLock("create-pr") { return nil, ErrOperationInProgress } defer g.unlock() result := &PRCreateResult{} // Get current branch name for --head flag branch, err := g.getCurrentBranch(ctx) if err != nil { result.Error = fmt.Sprintf("failed to get current branch: %s", err.Error()) return result, nil } g.logger.Debug("current branch", zap.String("branch", branch)) // First, push the branch to remote (with --set-upstream in case it's a new branch) pushOutput, err := g.runGitCommand(ctx, "push", "--set-upstream", "origin", "HEAD") if err != nil { result.Error = fmt.Sprintf("failed to push branch: %s", pushOutput) result.Output = pushOutput return result, nil } g.logger.Debug("pushed branch to remote", zap.String("output", pushOutput)) // Now create the PR // Use --head to explicitly specify the branch (helps gh in worktree scenarios) args := []string{"pr", "create", "--title", title, "--body", body, "--head", branch} // Strip remote prefix (e.g., "origin/main" -> "main") since gh expects just the branch name cleanBaseBranch := strings.TrimPrefix(baseBranch, "origin/") if cleanBaseBranch != "" { args = append(args, "--base", cleanBaseBranch) } if draft { args = append(args, "--draft") } cmd := exec.CommandContext(ctx, "gh", args...) cmd.Dir = g.workDir // Clear GIT_DIR and GIT_WORK_TREE to ensure gh uses the worktree's .git file // and correctly detects the current branch. Inherited env vars can confuse gh // when running from a worktree. env := filterGitEnv(os.Environ()) cmd.Env = env var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr g.logger.Debug("executing gh command", zap.Strings("args", args), zap.String("workDir", g.workDir)) err = cmd.Run() output := stdout.String() if stderr.Len() > 0 { if output != "" { output += "\n" } output += stderr.String() } result.Output = output if err != nil { result.Error = fmt.Sprintf("%s: %s", err.Error(), strings.TrimSpace(stderr.String())) return result, nil } // Extract PR URL from output (gh pr create outputs the URL on success) result.PRURL = strings.TrimSpace(stdout.String()) result.Success = true g.logger.Info("PR created", zap.String("url", result.PRURL)) return result, nil } // getCommitStats returns the number of files changed, insertions, and deletions for a commit func (g *GitOperator) getCommitStats(ctx context.Context, commitSHA string) (filesChanged, insertions, deletions int) { // git show --stat --format="" HEAD gives us the stat summary output, err := g.runGitCommand(ctx, "show", "--stat", "--format=", commitSHA) if err != nil { return 0, 0, 0 } // The last non-empty line contains the summary like: // " 3 files changed, 10 insertions(+), 5 deletions(-)" lines := strings.Split(strings.TrimSpace(output), "\n") if len(lines) == 0 { return 0, 0, 0 } summary := lines[len(lines)-1] // Parse: "N files changed, M insertions(+), K deletions(-)" // Some variants may be missing insertions or deletions // Match files changed if idx := strings.Index(summary, " file"); idx > 0 { part := strings.TrimSpace(summary[:idx]) // Get just the number parts := strings.Fields(part) if len(parts) > 0 { _, _ = fmt.Sscanf(parts[len(parts)-1], "%d", &filesChanged) } } // Match insertions if idx := strings.Index(summary, " insertion"); idx > 0 { // Find the number before " insertion" start := strings.LastIndex(summary[:idx], " ") + 1 if start > 0 && start < idx { _, _ = fmt.Sscanf(summary[start:idx], "%d", &insertions) } } // Match deletions if idx := strings.Index(summary, " deletion"); idx > 0 { // Find the number before " deletion" start := strings.LastIndex(summary[:idx], " ") + 1 if start > 0 && start < idx { _, _ = fmt.Sscanf(summary[start:idx], "%d", &deletions) } } return filesChanged, insertions, deletions }