package git import ( "bytes" "fmt" "os" "os/exec" "path/filepath" "strconv" "strings" "sync" ) // Repo wraps a bare directory that is a Git repository. type Repo struct { root string // mu serialises whole operations (a background sync racing a user // save would otherwise corrupt index/merge state). mu sync.Mutex // sshCommand is exported to git as GIT_SSH_COMMAND when set, so // remotes can be reached with a dedicated key and known_hosts file // even though the server runs without a home directory. sshCommand string } // Open returns a Repo for an existing directory, initialising Git if needed. func Open(root string) (*Repo, error) { r := &Repo{root: root} if err := r.initIfNeeded(); err != nil { return nil, err } return r, nil } func (r *Repo) initIfNeeded() error { out, _ := r.run("rev-parse", "--is-inside-work-tree") if strings.TrimSpace(out) == "true" { return nil } _, err := r.run("init") return err } // Commit writes data to path and creates a Git commit authored by author. func (r *Repo) Commit(path, content, message, author, email string) error { r.mu.Lock() defer r.mu.Unlock() full := filepath.Join(r.root, path) if err := writeFile(full, content); err != nil { return err } if _, err := r.run("add", path); err != nil { return err } _, err := r.run( "-c", fmt.Sprintf("user.name=%s", author), "-c", fmt.Sprintf("user.email=%s", email), "commit", "-m", message, ) return err } // Log returns the commit history for a file, including added/removed line counts. func (r *Repo) Log(path string) ([]LogEntry, error) { r.mu.Lock() defer r.mu.Unlock() out, err := r.run("log", "--format=COMMIT|%H|%an|%ae|%ai|%s", "--numstat", "--", path) if err != nil { return nil, err } var entries []LogEntry var current *LogEntry for _, line := range strings.Split(strings.TrimSpace(out), "\n") { if strings.HasPrefix(line, "COMMIT|") { if current != nil { entries = append(entries, *current) } parts := strings.SplitN(strings.TrimPrefix(line, "COMMIT|"), "|", 5) if len(parts) == 5 { current = &LogEntry{ Hash: parts[0], Author: parts[1], Email: parts[2], Date: parts[3], Subject: parts[4], } } } else if current != nil && strings.Contains(line, "\t") { // numstat line: "added\tremoved\tfilename" fields := strings.SplitN(line, "\t", 3) if len(fields) == 3 { added, _ := strconv.Atoi(fields[0]) removed, _ := strconv.Atoi(fields[1]) current.Added += added current.Removed += removed } } } if current != nil { entries = append(entries, *current) } return entries, nil } // Diff returns the unified diff between two commits for a file. func (r *Repo) Diff(path, fromHash, toHash string) (string, error) { r.mu.Lock() defer r.mu.Unlock() out, err := r.run("diff", fromHash, toHash, "--", path) return out, err } // Show returns the file content at a specific commit. func (r *Repo) Show(hash, path string) (string, error) { r.mu.Lock() defer r.mu.Unlock() return r.run("show", fmt.Sprintf("%s:%s", hash, path)) } // HasUncommitted returns true if the working tree contains untracked or // modified files that have not yet been committed. func (r *Repo) HasUncommitted() bool { r.mu.Lock() defer r.mu.Unlock() out, err := r.run("status", "--porcelain") return err == nil && strings.TrimSpace(out) != "" } // CommitAll stages every file in the repo root and creates a commit. func (r *Repo) CommitAll(message, author, email string) error { r.mu.Lock() defer r.mu.Unlock() if _, err := r.run("add", "-A"); err != nil { return err } _, err := r.run( "-c", fmt.Sprintf("user.name=%s", author), "-c", fmt.Sprintf("user.email=%s", email), "commit", "-m", message, ) return err } // Move renames a file from oldPath to newPath and creates a Git commit. func (r *Repo) Move(oldPath, newPath, message, author, email string) error { r.mu.Lock() defer r.mu.Unlock() if _, err := r.run("mv", oldPath, newPath); err != nil { return err } _, err := r.run( "-c", fmt.Sprintf("user.name=%s", author), "-c", fmt.Sprintf("user.email=%s", email), "commit", "-m", message, ) return err } type LogEntry struct { Hash string Author string Email string Date string Subject string Added int Removed int } func writeFile(path, content string) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } return os.WriteFile(path, []byte(content), 0644) } func (r *Repo) run(args ...string) (string, error) { cmd := exec.Command("git", append([]string{"-C", r.root}, args...)...) cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") if r.sshCommand != "" { cmd.Env = append(cmd.Env, "GIT_SSH_COMMAND="+r.sshCommand) } var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return "", fmt.Errorf("git %v: %w — %s", args, err, stderr.String()) } return stdout.String(), nil }