gito/services/core/internal/gitengine/command.go

279 lines
6.7 KiB
Go

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"
}
}