package gitengine import ( "context" "errors" "fmt" "os/exec" "strings" "time" ) var ErrInvalidGitInput = errors.New("invalid git input") const DefaultCommandTimeout = 2 * time.Minute type CommandRunner interface { Run(workdir string, args ...string) (string, error) } type CLI struct { Timeout time.Duration } func (c CLI) Run(workdir string, args ...string) (string, error) { if len(args) == 0 { return "", ErrInvalidGitInput } timeout := c.Timeout if timeout <= 0 { timeout = DefaultCommandTimeout } ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() cmd := exec.CommandContext(ctx, "git", args...) if strings.TrimSpace(workdir) != "" { cmd.Dir = workdir } out, err := cmd.CombinedOutput() if errors.Is(ctx.Err(), context.DeadlineExceeded) { return string(out), fmt.Errorf("git command timed out after %s: %w", timeout, ctx.Err()) } 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 DiffOptions struct { Staged bool Pathspecs []string } 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 } type CheckoutOptions struct { Branch string StartPoint string Reset bool } func Checkout(runner CommandRunner, workdir, branch string) error { return CheckoutBranch(runner, workdir, CheckoutOptions{Branch: branch}) } func CheckoutBranch(runner CommandRunner, workdir string, opts CheckoutOptions) error { workdir = strings.TrimSpace(workdir) branch := strings.TrimSpace(opts.Branch) if workdir == "" || branch == "" { return ErrInvalidGitInput } startPoint := strings.TrimSpace(opts.StartPoint) if startPoint == "" { _, err := runner.Run(workdir, "checkout", branch) return err } flag := "-b" if opts.Reset { flag = "-B" } _, err := runner.Run(workdir, "checkout", flag, branch, startPoint) 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 Diff(runner CommandRunner, workdir string, opts DiffOptions) (string, error) { if strings.TrimSpace(workdir) == "" { return "", ErrInvalidGitInput } args := []string{"diff"} if opts.Staged { args = append(args, "--cached") } pathspecs := make([]string, 0, len(opts.Pathspecs)) for _, pathspec := range opts.Pathspecs { pathspec = strings.TrimSpace(pathspec) if pathspec != "" { pathspecs = append(pathspecs, pathspec) } } if len(pathspecs) > 0 { args = append(args, "--") args = append(args, pathspecs...) } return runner.Run(workdir, args...) } 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) { line = strings.TrimSuffix(line, "\r") if strings.TrimSpace(line) == "" { return ChangedFile{}, false } fields := strings.Split(line, "\t") if len(fields) < 2 { return ChangedFile{}, false } status := strings.TrimSpace(fields[0]) if status == "" { return ChangedFile{}, false } changeType := changeTypeFromGitStatus(status) 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" } }