package gitengine import ( "errors" "os/exec" "strings" ) var ErrInvalidGitInput = errors.New("invalid git input") type CommandRunner interface { Run(workdir string, args ...string) (string, error) } type CLI struct{} func (CLI) Run(workdir string, args ...string) (string, error) { if len(args) == 0 { return "", ErrInvalidGitInput } cmd := exec.Command("git", args...) if strings.TrimSpace(workdir) != "" { cmd.Dir = workdir } out, err := cmd.CombinedOutput() return string(out), err } type StatusResult struct { Clean bool Output string } type CloneOptions struct { RemoteURL string WorktreePath string Branch string } type CommitOptions struct { Message string All bool } type ChangedFile struct { Path string ChangeType string } func Clone(runner CommandRunner, opts CloneOptions) error { remoteURL := strings.TrimSpace(opts.RemoteURL) worktreePath := strings.TrimSpace(opts.WorktreePath) if remoteURL == "" || worktreePath == "" { return ErrInvalidGitInput } args := []string{"clone"} if branch := strings.TrimSpace(opts.Branch); branch != "" { args = append(args, "--branch", branch) } args = append(args, remoteURL, worktreePath) _, err := runner.Run("", args...) return err } func Fetch(runner CommandRunner, workdir, remote string) error { if strings.TrimSpace(workdir) == "" { return ErrInvalidGitInput } remote = strings.TrimSpace(remote) if remote == "" { remote = "origin" } _, err := runner.Run(workdir, "fetch", "--prune", remote) return err } func Checkout(runner CommandRunner, workdir, branch string) error { if strings.TrimSpace(workdir) == "" || strings.TrimSpace(branch) == "" { return ErrInvalidGitInput } _, err := runner.Run(workdir, "checkout", branch) return err } func Status(runner CommandRunner, workdir string) (StatusResult, error) { if strings.TrimSpace(workdir) == "" { return StatusResult{}, ErrInvalidGitInput } output, err := runner.Run(workdir, "status", "--porcelain") if err != nil { return StatusResult{}, err } return StatusResult{ Clean: strings.TrimSpace(output) == "", Output: output, }, nil } func Commit(runner CommandRunner, workdir string, opts CommitOptions) error { if strings.TrimSpace(workdir) == "" || strings.TrimSpace(opts.Message) == "" { return ErrInvalidGitInput } if opts.All { if _, err := runner.Run(workdir, "add", "-A"); err != nil { return err } } _, err := runner.Run(workdir, "commit", "-m", opts.Message) return err } func Push(runner CommandRunner, workdir, remote, branch string) error { if strings.TrimSpace(workdir) == "" || strings.TrimSpace(branch) == "" { return ErrInvalidGitInput } remote = strings.TrimSpace(remote) if remote == "" { remote = "origin" } _, err := runner.Run(workdir, "push", remote, branch) return err } func HeadRevision(runner CommandRunner, workdir string) (string, error) { if strings.TrimSpace(workdir) == "" { return "", ErrInvalidGitInput } output, err := runner.Run(workdir, "rev-parse", "HEAD") if err != nil { return "", err } revision := strings.TrimSpace(output) if revision == "" { return "", ErrInvalidGitInput } return revision, nil } func ChangedFiles(runner CommandRunner, workdir, before, after string) ([]string, error) { before = strings.TrimSpace(before) after = strings.TrimSpace(after) if strings.TrimSpace(workdir) == "" || before == "" || after == "" { return nil, ErrInvalidGitInput } output, err := runner.Run(workdir, "diff", "--name-only", before+".."+after) if err != nil { return nil, err } lines := strings.Split(output, "\n") files := make([]string, 0, len(lines)) for _, line := range lines { line = strings.TrimSpace(line) if line != "" { files = append(files, line) } } return files, nil } func ChangedFilesWithStatus(runner CommandRunner, workdir, before, after string) ([]ChangedFile, error) { before = strings.TrimSpace(before) after = strings.TrimSpace(after) if strings.TrimSpace(workdir) == "" || before == "" || after == "" { return nil, ErrInvalidGitInput } output, err := runner.Run(workdir, "diff", "--name-status", before+".."+after) if err != nil { return nil, err } lines := strings.Split(output, "\n") files := make([]ChangedFile, 0, len(lines)) for _, line := range lines { if file, ok := parseNameStatus(line); ok { files = append(files, file) } } return files, nil } func parseNameStatus(line string) (ChangedFile, bool) { fields := strings.Fields(strings.TrimSpace(line)) if len(fields) < 2 { return ChangedFile{}, false } changeType := changeTypeFromGitStatus(fields[0]) path := fields[len(fields)-1] if path == "" { return ChangedFile{}, false } return ChangedFile{Path: path, ChangeType: changeType}, true } func changeTypeFromGitStatus(status string) string { switch { case strings.HasPrefix(status, "A"): return "added" case strings.HasPrefix(status, "D"): return "deleted" case strings.HasPrefix(status, "R"): return "renamed" case strings.HasPrefix(status, "C"): return "copied" default: return "modified" } }