Add git implementation
Change-Id: I3bb5986fe244b310038b7b2ec4359d8439a158de
diff --git a/server/git/git.go b/server/git/git.go
new file mode 100644
index 0000000..bf95f7c
--- /dev/null
+++ b/server/git/git.go
@@ -0,0 +1,788 @@
+package git
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// GitInterface defines the contract for Git operations
+type GitInterface interface {
+ // Repository operations
+ Init(ctx context.Context, path string) error
+ Clone(ctx context.Context, url, path string) error
+ IsRepository(ctx context.Context, path string) (bool, error)
+
+ // Status and information
+ Status(ctx context.Context) (*Status, error)
+ Log(ctx context.Context, options LogOptions) ([]Commit, error)
+ Show(ctx context.Context, ref string) (*Commit, error)
+
+ // Branch operations
+ ListBranches(ctx context.Context) ([]Branch, error)
+ CreateBranch(ctx context.Context, name string, startPoint string) error
+ Checkout(ctx context.Context, ref string) error
+ DeleteBranch(ctx context.Context, name string, force bool) error
+ GetCurrentBranch(ctx context.Context) (string, error)
+
+ // Commit operations
+ Add(ctx context.Context, paths []string) error
+ AddAll(ctx context.Context) error
+ Commit(ctx context.Context, message string, options CommitOptions) error
+
+ // Remote operations
+ ListRemotes(ctx context.Context) ([]Remote, error)
+ AddRemote(ctx context.Context, name, url string) error
+ RemoveRemote(ctx context.Context, name string) error
+ Fetch(ctx context.Context, remote string, options FetchOptions) error
+ Pull(ctx context.Context, remote, branch string) error
+ Push(ctx context.Context, remote, branch string, options PushOptions) error
+
+ // Merge operations
+ Merge(ctx context.Context, ref string, options MergeOptions) error
+ MergeBase(ctx context.Context, ref1, ref2 string) (string, error)
+
+ // Configuration
+ GetConfig(ctx context.Context, key string) (string, error)
+ SetConfig(ctx context.Context, key, value string) error
+ GetUserConfig(ctx context.Context) (*UserConfig, error)
+ SetUserConfig(ctx context.Context, config UserConfig) error
+}
+
+// Status represents the current state of the repository
+type Status struct {
+ Branch string
+ IsClean bool
+ Staged []FileStatus
+ Unstaged []FileStatus
+ Untracked []string
+ Conflicts []string
+}
+
+// FileStatus represents the status of a file
+type FileStatus struct {
+ Path string
+ Status string // "modified", "added", "deleted", "renamed", etc.
+ Staged bool
+}
+
+// Commit represents a Git commit
+type Commit struct {
+ Hash string
+ Author Author
+ Committer Author
+ Message string
+ Parents []string
+ Timestamp time.Time
+ Files []CommitFile
+}
+
+// Author represents a Git author or committer
+type Author struct {
+ Name string
+ Email string
+ Time time.Time
+}
+
+// CommitFile represents a file in a commit
+type CommitFile struct {
+ Path string
+ Status string // "added", "modified", "deleted", "renamed"
+ Additions int
+ Deletions int
+}
+
+// Branch represents a Git branch
+type Branch struct {
+ Name string
+ IsCurrent bool
+ IsRemote bool
+ Commit string
+ Message string
+}
+
+// Remote represents a Git remote
+type Remote struct {
+ Name string
+ URL string
+}
+
+// UserConfig represents Git user configuration
+type UserConfig struct {
+ Name string
+ Email string
+}
+
+// LogOptions defines options for log operations
+type LogOptions struct {
+ MaxCount int
+ Since time.Time
+ Until time.Time
+ Author string
+ Path string
+ Oneline bool
+}
+
+// CommitOptions defines options for commit operations
+type CommitOptions struct {
+ Author *Author
+ Committer *Author
+ Sign bool
+ AllowEmpty bool
+}
+
+// FetchOptions defines options for fetch operations
+type FetchOptions struct {
+ All bool
+ Tags bool
+ Depth int
+ Prune bool
+}
+
+// PushOptions defines options for push operations
+type PushOptions struct {
+ Force bool
+ Tags bool
+ SetUpstream bool
+}
+
+// MergeOptions defines options for merge operations
+type MergeOptions struct {
+ NoFF bool
+ Message string
+ Strategy string
+}
+
+// GitError represents a Git-specific error
+type GitError struct {
+ Command string
+ Output string
+ Err error
+}
+
+func (e *GitError) Error() string {
+ if e.Err != nil {
+ return fmt.Sprintf("git %s failed: %v\nOutput: %s", e.Command, e.Err, e.Output)
+ }
+ return fmt.Sprintf("git %s failed\nOutput: %s", e.Command, e.Output)
+}
+
+func (e *GitError) Unwrap() error {
+ return e.Err
+}
+
+// Git implementation using os/exec to call git commands
+type Git struct {
+ repoPath string
+ config GitConfig
+}
+
+// GitConfig holds configuration for Git operations
+type GitConfig struct {
+ Timeout time.Duration
+ Env map[string]string
+}
+
+// NewGit creates a new Git instance
+func NewGit(repoPath string, config GitConfig) GitInterface {
+ if config.Timeout == 0 {
+ config.Timeout = 30 * time.Second
+ }
+
+ return &Git{
+ repoPath: repoPath,
+ config: config,
+ }
+}
+
+// DefaultGit creates a Git instance with default configuration
+func DefaultGit(repoPath string) GitInterface {
+ return NewGit(repoPath, GitConfig{
+ Timeout: 30 * time.Second,
+ Env: make(map[string]string),
+ })
+}
+
+// Ensure Git implements GitInterface
+var _ GitInterface = (*Git)(nil)
+
+// Init initializes a new Git repository
+func (g *Git) Init(ctx context.Context, path string) error {
+ cmd := exec.CommandContext(ctx, "git", "init")
+ cmd.Dir = path
+ return g.runCommand(cmd, "init")
+}
+
+// Clone clones a repository from URL to path
+func (g *Git) Clone(ctx context.Context, url, path string) error {
+ cmd := exec.CommandContext(ctx, "git", "clone", url, path)
+ return g.runCommand(cmd, "clone")
+}
+
+// IsRepository checks if a path is a valid Git repository
+func (g *Git) IsRepository(ctx context.Context, path string) (bool, error) {
+ return g.isValidRepo(path), nil
+}
+
+// Status returns the current status of the repository
+func (g *Git) Status(ctx context.Context) (*Status, error) {
+ cmd := exec.CommandContext(ctx, "git", "status", "--porcelain", "--branch")
+ cmd.Dir = g.repoPath
+ output, err := g.runCommandWithOutput(cmd, "status")
+ if err != nil {
+ return nil, err
+ }
+
+ return g.parseStatus(output)
+}
+
+// Log returns commit history
+func (g *Git) Log(ctx context.Context, options LogOptions) ([]Commit, error) {
+ args := []string{"log", "--format=%H%n%an%n%ae%n%at%n%s%n%cn%n%ce%n%ct%n%P"}
+
+ if options.MaxCount > 0 {
+ args = append(args, fmt.Sprintf("-%d", options.MaxCount))
+ }
+
+ if !options.Since.IsZero() {
+ args = append(args, fmt.Sprintf("--since=%s", options.Since.Format(time.RFC3339)))
+ }
+
+ if !options.Until.IsZero() {
+ args = append(args, fmt.Sprintf("--until=%s", options.Until.Format(time.RFC3339)))
+ }
+
+ if options.Author != "" {
+ args = append(args, fmt.Sprintf("--author=%s", options.Author))
+ }
+
+ if options.Path != "" {
+ args = append(args, "--", options.Path)
+ }
+
+ if options.Oneline {
+ args = append(args, "--oneline")
+ }
+
+ cmd := exec.CommandContext(ctx, "git", args...)
+ cmd.Dir = g.repoPath
+ output, err := g.runCommandWithOutput(cmd, "log")
+ if err != nil {
+ return nil, err
+ }
+
+ return g.parseLog(output)
+}
+
+// Show shows commit details
+func (g *Git) Show(ctx context.Context, ref string) (*Commit, error) {
+ cmd := exec.CommandContext(ctx, "git", "show", "--format=json", ref)
+ cmd.Dir = g.repoPath
+ output, err := g.runCommandWithOutput(cmd, "show")
+ if err != nil {
+ return nil, err
+ }
+
+ commits, err := g.parseLog(output)
+ if err != nil || len(commits) == 0 {
+ return nil, &GitError{Command: "show", Output: "failed to parse commit"}
+ }
+
+ return &commits[0], nil
+}
+
+// ListBranches returns all branches
+func (g *Git) ListBranches(ctx context.Context) ([]Branch, error) {
+ cmd := exec.CommandContext(ctx, "git", "branch", "-a", "--format=%(refname:short)%09%(objectname)%09%(contents:subject)")
+ cmd.Dir = g.repoPath
+ output, err := g.runCommandWithOutput(cmd, "branch")
+ if err != nil {
+ return nil, err
+ }
+
+ return g.parseBranches(output)
+}
+
+// CreateBranch creates a new branch
+func (g *Git) CreateBranch(ctx context.Context, name string, startPoint string) error {
+ args := []string{"branch", name}
+ if startPoint != "" {
+ args = append(args, startPoint)
+ }
+
+ cmd := exec.CommandContext(ctx, "git", args...)
+ cmd.Dir = g.repoPath
+ return g.runCommand(cmd, "branch")
+}
+
+// Checkout switches to a branch or commit
+func (g *Git) Checkout(ctx context.Context, ref string) error {
+ cmd := exec.CommandContext(ctx, "git", "checkout", ref)
+ cmd.Dir = g.repoPath
+ return g.runCommand(cmd, "checkout")
+}
+
+// DeleteBranch deletes a branch
+func (g *Git) DeleteBranch(ctx context.Context, name string, force bool) error {
+ args := []string{"branch"}
+ if force {
+ args = append(args, "-D")
+ } else {
+ args = append(args, "-d")
+ }
+ args = append(args, name)
+
+ cmd := exec.CommandContext(ctx, "git", args...)
+ cmd.Dir = g.repoPath
+ return g.runCommand(cmd, "branch")
+}
+
+// GetCurrentBranch returns the current branch name
+func (g *Git) GetCurrentBranch(ctx context.Context) (string, error) {
+ cmd := exec.CommandContext(ctx, "git", "rev-parse", "--abbrev-ref", "HEAD")
+ cmd.Dir = g.repoPath
+ output, err := g.runCommandWithOutput(cmd, "rev-parse")
+ if err != nil {
+ return "", err
+ }
+
+ return strings.TrimSpace(output), nil
+}
+
+// Add stages files for commit
+func (g *Git) Add(ctx context.Context, paths []string) error {
+ args := append([]string{"add"}, paths...)
+ cmd := exec.CommandContext(ctx, "git", args...)
+ cmd.Dir = g.repoPath
+ return g.runCommand(cmd, "add")
+}
+
+// AddAll stages all changes
+func (g *Git) AddAll(ctx context.Context) error {
+ cmd := exec.CommandContext(ctx, "git", "add", "-A")
+ cmd.Dir = g.repoPath
+ return g.runCommand(cmd, "add")
+}
+
+// Commit creates a new commit
+func (g *Git) Commit(ctx context.Context, message string, options CommitOptions) error {
+ args := []string{"commit", "-m", message}
+
+ if options.Author != nil {
+ args = append(args, fmt.Sprintf("--author=%s <%s>", options.Author.Name, options.Author.Email))
+ }
+
+ if options.Sign {
+ args = append(args, "-S")
+ }
+
+ if options.AllowEmpty {
+ args = append(args, "--allow-empty")
+ }
+
+ cmd := exec.CommandContext(ctx, "git", args...)
+ cmd.Dir = g.repoPath
+ return g.runCommand(cmd, "commit")
+}
+
+// ListRemotes returns all remotes
+func (g *Git) ListRemotes(ctx context.Context) ([]Remote, error) {
+ cmd := exec.CommandContext(ctx, "git", "remote", "-v")
+ cmd.Dir = g.repoPath
+ output, err := g.runCommandWithOutput(cmd, "remote")
+ if err != nil {
+ return nil, err
+ }
+
+ return g.parseRemotes(output)
+}
+
+// AddRemote adds a new remote
+func (g *Git) AddRemote(ctx context.Context, name, url string) error {
+ cmd := exec.CommandContext(ctx, "git", "remote", "add", name, url)
+ cmd.Dir = g.repoPath
+ return g.runCommand(cmd, "remote")
+}
+
+// RemoveRemote removes a remote
+func (g *Git) RemoveRemote(ctx context.Context, name string) error {
+ cmd := exec.CommandContext(ctx, "git", "remote", "remove", name)
+ cmd.Dir = g.repoPath
+ return g.runCommand(cmd, "remote")
+}
+
+// Fetch fetches from a remote
+func (g *Git) Fetch(ctx context.Context, remote string, options FetchOptions) error {
+ args := []string{"fetch"}
+
+ if options.All {
+ args = append(args, "--all")
+ } else if remote != "" {
+ args = append(args, remote)
+ }
+
+ if options.Tags {
+ args = append(args, "--tags")
+ }
+
+ if options.Depth > 0 {
+ args = append(args, fmt.Sprintf("--depth=%d", options.Depth))
+ }
+
+ if options.Prune {
+ args = append(args, "--prune")
+ }
+
+ cmd := exec.CommandContext(ctx, "git", args...)
+ cmd.Dir = g.repoPath
+ return g.runCommand(cmd, "fetch")
+}
+
+// Pull pulls from a remote
+func (g *Git) Pull(ctx context.Context, remote, branch string) error {
+ args := []string{"pull"}
+ if remote != "" {
+ args = append(args, remote)
+ if branch != "" {
+ args = append(args, branch)
+ }
+ }
+
+ cmd := exec.CommandContext(ctx, "git", args...)
+ cmd.Dir = g.repoPath
+ return g.runCommand(cmd, "pull")
+}
+
+// Push pushes to a remote
+func (g *Git) Push(ctx context.Context, remote, branch string, options PushOptions) error {
+ args := []string{"push"}
+
+ if options.Force {
+ args = append(args, "--force")
+ }
+
+ if options.Tags {
+ args = append(args, "--tags")
+ }
+
+ if options.SetUpstream {
+ args = append(args, "--set-upstream")
+ }
+
+ if remote != "" {
+ args = append(args, remote)
+ if branch != "" {
+ args = append(args, branch)
+ }
+ }
+
+ cmd := exec.CommandContext(ctx, "git", args...)
+ cmd.Dir = g.repoPath
+ return g.runCommand(cmd, "push")
+}
+
+// Merge merges a branch into current branch
+func (g *Git) Merge(ctx context.Context, ref string, options MergeOptions) error {
+ args := []string{"merge"}
+
+ if options.NoFF {
+ args = append(args, "--no-ff")
+ }
+
+ if options.Message != "" {
+ args = append(args, "-m", options.Message)
+ }
+
+ if options.Strategy != "" {
+ args = append(args, "-s", options.Strategy)
+ }
+
+ args = append(args, ref)
+
+ cmd := exec.CommandContext(ctx, "git", args...)
+ cmd.Dir = g.repoPath
+ return g.runCommand(cmd, "merge")
+}
+
+// MergeBase finds the common ancestor of two commits
+func (g *Git) MergeBase(ctx context.Context, ref1, ref2 string) (string, error) {
+ cmd := exec.CommandContext(ctx, "git", "merge-base", ref1, ref2)
+ cmd.Dir = g.repoPath
+ output, err := g.runCommandWithOutput(cmd, "merge-base")
+ if err != nil {
+ return "", err
+ }
+
+ return strings.TrimSpace(output), nil
+}
+
+// GetConfig gets a Git configuration value
+func (g *Git) GetConfig(ctx context.Context, key string) (string, error) {
+ cmd := exec.CommandContext(ctx, "git", "config", "--get", key)
+ cmd.Dir = g.repoPath
+ output, err := g.runCommandWithOutput(cmd, "config")
+ if err != nil {
+ return "", err
+ }
+
+ return strings.TrimSpace(output), nil
+}
+
+// SetConfig sets a Git configuration value
+func (g *Git) SetConfig(ctx context.Context, key, value string) error {
+ cmd := exec.CommandContext(ctx, "git", "config", key, value)
+ cmd.Dir = g.repoPath
+ return g.runCommand(cmd, "config")
+}
+
+// GetUserConfig gets user configuration
+func (g *Git) GetUserConfig(ctx context.Context) (*UserConfig, error) {
+ name, err := g.GetConfig(ctx, "user.name")
+ if err != nil {
+ return nil, err
+ }
+
+ email, err := g.GetConfig(ctx, "user.email")
+ if err != nil {
+ return nil, err
+ }
+
+ return &UserConfig{
+ Name: name,
+ Email: email,
+ }, nil
+}
+
+// SetUserConfig sets user configuration
+func (g *Git) SetUserConfig(ctx context.Context, config UserConfig) error {
+ if err := g.SetConfig(ctx, "user.name", config.Name); err != nil {
+ return err
+ }
+
+ return g.SetConfig(ctx, "user.email", config.Email)
+}
+
+// Helper methods
+
+func (g *Git) runCommand(cmd *exec.Cmd, command string) error {
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return &GitError{
+ Command: command,
+ Output: string(output),
+ Err: err,
+ }
+ }
+ return nil
+}
+
+func (g *Git) runCommandWithOutput(cmd *exec.Cmd, command string) (string, error) {
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return "", &GitError{
+ Command: command,
+ Output: string(output),
+ Err: err,
+ }
+ }
+ return string(output), nil
+}
+
+func (g *Git) isValidRepo(path string) bool {
+ gitDir := filepath.Join(path, ".git")
+ info, err := os.Stat(gitDir)
+ return err == nil && info.IsDir()
+}
+
+func (g *Git) parseStatus(output string) (*Status, error) {
+ lines := strings.Split(strings.TrimSpace(output), "\n")
+ status := &Status{
+ Staged: []FileStatus{},
+ Unstaged: []FileStatus{},
+ Untracked: []string{},
+ Conflicts: []string{},
+ }
+
+ for _, line := range lines {
+ if strings.HasPrefix(line, "## ") {
+ // Parse branch info
+ parts := strings.Fields(line[3:])
+ if len(parts) > 0 {
+ status.Branch = parts[0]
+ }
+ continue
+ }
+
+ if len(line) < 3 {
+ continue
+ }
+
+ // Parse file status
+ staged := line[0:1]
+ unstaged := line[1:2]
+ path := strings.TrimSpace(line[3:])
+
+ if staged != " " {
+ status.Staged = append(status.Staged, FileStatus{
+ Path: path,
+ Status: g.parseStatusCode(staged),
+ Staged: true,
+ })
+ }
+
+ if unstaged != " " {
+ status.Unstaged = append(status.Unstaged, FileStatus{
+ Path: path,
+ Status: g.parseStatusCode(unstaged),
+ Staged: false,
+ })
+ }
+
+ if staged == " " && unstaged == "?" {
+ status.Untracked = append(status.Untracked, path)
+ }
+ }
+
+ status.IsClean = len(status.Staged) == 0 && len(status.Unstaged) == 0 && len(status.Untracked) == 0
+ return status, nil
+}
+
+func (g *Git) parseStatusCode(code string) string {
+ switch code {
+ case "M":
+ return "modified"
+ case "A":
+ return "added"
+ case "D":
+ return "deleted"
+ case "R":
+ return "renamed"
+ case "C":
+ return "copied"
+ case "U":
+ return "unmerged"
+ default:
+ return "unknown"
+ }
+}
+
+func (g *Git) parseLog(output string) ([]Commit, error) {
+ lines := strings.Split(strings.TrimSpace(output), "\n")
+ var commits []Commit
+
+ // Each commit takes 9 lines in the format:
+ // Hash, AuthorName, AuthorEmail, AuthorTime, Subject, CommitterName, CommitterEmail, CommitterTime, Parents
+ for i := 0; i < len(lines); i += 9 {
+ if i+8 >= len(lines) {
+ break
+ }
+
+ hash := lines[i]
+ authorName := lines[i+1]
+ authorEmail := lines[i+2]
+ authorTimeStr := lines[i+3]
+ subject := lines[i+4]
+ committerName := lines[i+5]
+ committerEmail := lines[i+6]
+ committerTimeStr := lines[i+7]
+ parentsStr := lines[i+8]
+
+ // Parse timestamps
+ authorTime, _ := strconv.ParseInt(authorTimeStr, 10, 64)
+ committerTime, _ := strconv.ParseInt(committerTimeStr, 10, 64)
+
+ // Parse parents
+ var parents []string
+ if parentsStr != "" {
+ parents = strings.Fields(parentsStr)
+ }
+
+ commit := Commit{
+ Hash: hash,
+ Author: Author{
+ Name: authorName,
+ Email: authorEmail,
+ Time: time.Unix(authorTime, 0),
+ },
+ Committer: Author{
+ Name: committerName,
+ Email: committerEmail,
+ Time: time.Unix(committerTime, 0),
+ },
+ Message: subject,
+ Parents: parents,
+ Timestamp: time.Unix(authorTime, 0),
+ }
+
+ commits = append(commits, commit)
+ }
+
+ return commits, nil
+}
+
+func (g *Git) parseBranches(output string) ([]Branch, error) {
+ lines := strings.Split(strings.TrimSpace(output), "\n")
+ var branches []Branch
+
+ for _, line := range lines {
+ if strings.TrimSpace(line) == "" {
+ continue
+ }
+
+ parts := strings.Split(line, "\t")
+ if len(parts) < 3 {
+ continue
+ }
+
+ branch := Branch{
+ Name: parts[0],
+ Commit: parts[1],
+ Message: parts[2],
+ IsRemote: strings.HasPrefix(parts[0], "remotes/"),
+ }
+
+ // Check if this is the current branch
+ if !branch.IsRemote {
+ branch.IsCurrent = strings.HasPrefix(line, "*")
+ }
+
+ branches = append(branches, branch)
+ }
+
+ return branches, nil
+}
+
+func (g *Git) parseRemotes(output string) ([]Remote, error) {
+ lines := strings.Split(strings.TrimSpace(output), "\n")
+ var remotes []Remote
+ seen := make(map[string]bool)
+
+ for _, line := range lines {
+ if strings.TrimSpace(line) == "" {
+ continue
+ }
+
+ parts := strings.Fields(line)
+ if len(parts) < 3 {
+ continue
+ }
+
+ name := parts[0]
+ if seen[name] {
+ continue
+ }
+
+ remotes = append(remotes, Remote{
+ Name: name,
+ URL: parts[1],
+ })
+ seen[name] = true
+ }
+
+ return remotes, nil
+}