Files
ForgeBucket/internal/domain/git/binary.go
T

462 lines
13 KiB
Go

package git
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
var ErrPathTraversal = errors.New("repo path outside of configured root")
var repoRoot string
func SetRepoRoot(root string) {
repoRoot = filepath.Clean(root)
}
// run executes a git command inside repoPath with strict safety guarantees:
// - repoPath is validated to be under the configured repoRoot (path traversal guard)
// - args are passed as discrete values — never via shell interpolation
// - the process inherits only a minimal, fixed environment
func run(repoPath string, args ...string) ([]byte, error) {
clean := filepath.Clean(repoPath)
if repoRoot != "" {
root := repoRoot + string(filepath.Separator)
if !strings.HasPrefix(clean+string(filepath.Separator), root) && clean != repoRoot {
return nil, ErrPathTraversal
}
}
cmd := exec.Command("git", args...)
cmd.Dir = clean
cmd.Env = []string{
"GIT_TERMINAL_PROMPT=0",
"HOME=/tmp", // prevent .gitconfig side-effects
}
out, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("git %s: %w: %s", args[0], err, exitErr.Stderr)
}
return nil, fmt.Errorf("git %s: %w", args[0], err)
}
return out, nil
}
// IsEmpty returns true when the repo has no commits on any branch.
// Uses for-each-ref so it doesn't depend on HEAD pointing to a valid branch
// (bare repos created without --initial-branch default to master, but clients
// may push to main, leaving HEAD dangling).
func IsEmpty(repoPath string) bool {
out, err := run(repoPath, "for-each-ref", "--count=1", "refs/heads/")
if err != nil {
return true
}
return len(strings.TrimSpace(string(out))) == 0
}
func Init(path string) error {
cmd := exec.Command("git", "init", "--bare", path)
cmd.Env = []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git init --bare: %w: %s", err, out)
}
env := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
// Point HEAD at main so clients pushing 'main' don't leave a dangling HEAD.
head := exec.Command("git", "-C", path, "symbolic-ref", "HEAD", "refs/heads/main")
head.Env = env
_ = head.Run()
// Allow pushes over HTTP (git http-backend checks this config key).
cfg := exec.Command("git", "-C", path, "config", "http.receivepack", "true")
cfg.Env = env
_ = cfg.Run()
return nil
}
type Commit struct {
Hash string `json:"hash"`
Author string `json:"author"`
Message string `json:"message"`
Date string `json:"date"`
}
func Log(repoPath, branch string, limit int) ([]Commit, error) {
out, err := run(repoPath,
"log", branch,
"--format=%H\x1f%an\x1f%s\x1f%ci",
fmt.Sprintf("--max-count=%d", limit),
)
if err != nil {
return nil, err
}
raw := strings.TrimSpace(string(out))
if raw == "" {
return nil, nil
}
var commits []Commit
for _, line := range strings.Split(raw, "\n") {
parts := strings.SplitN(line, "\x1f", 4)
if len(parts) != 4 {
continue
}
commits = append(commits, Commit{
Hash: parts[0],
Author: parts[1],
Message: parts[2],
Date: parts[3],
})
}
return commits, nil
}
type TreeEntry struct {
Mode string `json:"mode"`
Type string `json:"type"`
Hash string `json:"hash"`
Name string `json:"name"`
Size int64 `json:"size"`
CommitHash string `json:"commitHash"`
CommitMsg string `json:"commitMsg"`
CommitDate string `json:"commitDate"`
}
func TreeLS(repoPath, ref, subPath string) ([]TreeEntry, error) {
if IsEmpty(repoPath) {
return nil, nil
}
treeRef := ref
if subPath != "" {
treeRef = ref + ":" + subPath
}
// -l adds size column: <mode> SP <type> SP <hash> SP <size> TAB <name>
out, err := run(repoPath, "ls-tree", "-l", treeRef)
if err != nil {
return nil, err
}
var entries []TreeEntry
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
tabIdx := strings.Index(line, "\t")
if tabIdx < 0 {
continue
}
name := line[tabIdx+1:]
fields := strings.Fields(line[:tabIdx])
if len(fields) < 4 {
continue
}
e := TreeEntry{
Mode: fields[0],
Type: fields[1],
Hash: fields[2],
Name: name,
}
if fields[3] != "-" {
fmt.Sscanf(fields[3], "%d", &e.Size)
}
entries = append(entries, e)
}
// Fetch last commit info for each entry.
for i, e := range entries {
filePath := e.Name
if subPath != "" {
filePath = subPath + "/" + e.Name
}
commitOut, err := run(repoPath, "log", "-1", "--format=%h\x1f%s\x1f%aI", "--", filePath)
if err == nil {
parts := strings.SplitN(strings.TrimSpace(string(commitOut)), "\x1f", 3)
if len(parts) == 3 {
entries[i].CommitHash = parts[0]
entries[i].CommitMsg = parts[1]
entries[i].CommitDate = parts[2]
}
}
}
return entries, nil
}
// WriteFile writes content to filePath on branch inside a temporary worktree,
// then commits with the given author and message. Uses git plumbing directly
// so it works on bare repositories.
func WriteFile(repoPath, branch, filePath, content, authorName, authorEmail, message string) error {
clean := filepath.Clean(filepath.FromSlash(filePath))
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
return errors.New("invalid file path")
}
tmpDir, err := os.MkdirTemp("", "fb-edit-*")
if err != nil {
return fmt.Errorf("mktemp: %w", err)
}
baseEnv := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
authorEnv := append(baseEnv,
"GIT_AUTHOR_NAME="+authorName,
"GIT_AUTHOR_EMAIL="+authorEmail,
"GIT_COMMITTER_NAME="+authorName,
"GIT_COMMITTER_EMAIL="+authorEmail,
)
addWt := exec.Command("git", "worktree", "add", "--force", tmpDir, branch)
addWt.Dir = filepath.Clean(repoPath)
addWt.Env = baseEnv
if out, err := addWt.CombinedOutput(); err != nil {
os.RemoveAll(tmpDir)
return fmt.Errorf("worktree add: %w: %s", err, out)
}
defer func() {
rmWt := exec.Command("git", "worktree", "remove", "--force", tmpDir)
rmWt.Dir = filepath.Clean(repoPath)
rmWt.Env = baseEnv
rmWt.Run()
os.RemoveAll(tmpDir)
}()
fullPath := filepath.Join(tmpDir, clean)
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
return fmt.Errorf("mkdirall: %w", err)
}
if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil {
return fmt.Errorf("writefile: %w", err)
}
addC := exec.Command("git", "add", clean)
addC.Dir = tmpDir
addC.Env = authorEnv
if out, err := addC.CombinedOutput(); err != nil {
return fmt.Errorf("git add: %w: %s", err, out)
}
commitC := exec.Command("git", "commit", "-m", message)
commitC.Dir = tmpDir
commitC.Env = authorEnv
if out, err := commitC.CombinedOutput(); err != nil {
return fmt.Errorf("git commit: %w: %s", err, out)
}
return nil
}
type Branch struct {
Name string `json:"name"`
}
func Branches(repoPath string) ([]Branch, error) {
if IsEmpty(repoPath) {
return nil, nil
}
out, err := run(repoPath, "branch", "--format=%(refname:short)")
if err != nil {
return nil, err
}
var branches []Branch
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line != "" {
branches = append(branches, Branch{Name: line})
}
}
return branches, nil
}
// RepoSize returns the total byte size of the bare repo directory by walking it.
func RepoSize(repoPath string) int64 {
var total int64
filepath.Walk(repoPath, func(_ string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() {
total += info.Size()
}
return nil
})
return total
}
// RevParse resolves a ref (branch name, tag, or SHA) to its full commit SHA.
func RevParse(repoPath, ref string) (string, error) {
out, err := run(repoPath, "rev-parse", "--verify", ref)
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
// SetDefaultBranch updates HEAD to point at the given branch name.
func SetDefaultBranch(repoPath, branch string) error {
_, err := run(repoPath, "symbolic-ref", "HEAD", "refs/heads/"+branch)
return err
}
// InitWithFiles creates an initial commit in a bare repo by:
// 1. git init in a temp dir → write files → commit → git push file://repoPath.
// Use this right after Init() when initReadme or initGitignore is requested.
func InitWithFiles(repoPath, branch, repoName, authorName, authorEmail string, initReadme, initGitignore bool) error {
tmpDir, err := os.MkdirTemp("", "fb-init-*")
if err != nil {
return fmt.Errorf("mktemp: %w", err)
}
defer os.RemoveAll(tmpDir)
env := []string{
"GIT_TERMINAL_PROMPT=0",
"HOME=/tmp",
"GIT_AUTHOR_NAME=" + authorName,
"GIT_AUTHOR_EMAIL=" + authorEmail,
"GIT_COMMITTER_NAME=" + authorName,
"GIT_COMMITTER_EMAIL=" + authorEmail,
}
initCmd := exec.Command("git", "init", tmpDir)
initCmd.Env = env
if out, err := initCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git init: %w: %s", err, out)
}
// Point the working-tree HEAD at the desired branch before the first commit.
symRef := exec.Command("git", "-C", tmpDir, "symbolic-ref", "HEAD", "refs/heads/"+branch)
symRef.Env = env
symRef.Run()
if initReadme {
if err := os.WriteFile(filepath.Join(tmpDir, "README.md"), []byte(readmeContent(repoName)), 0644); err != nil {
return fmt.Errorf("write README: %w", err)
}
}
if initGitignore {
if err := os.WriteFile(filepath.Join(tmpDir, ".gitignore"), []byte(gitignoreContent()), 0644); err != nil {
return fmt.Errorf("write .gitignore: %w", err)
}
}
addCmd := exec.Command("git", "add", ".")
addCmd.Dir = tmpDir
addCmd.Env = env
if out, err := addCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git add: %w: %s", err, out)
}
commitCmd := exec.Command("git", "commit", "-m", "Initial commit")
commitCmd.Dir = tmpDir
commitCmd.Env = env
if out, err := commitCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git commit: %w: %s", err, out)
}
// Push to the bare repo via file:// — works on all git versions.
pushCmd := exec.Command("git", "push", "file://"+filepath.ToSlash(filepath.Clean(repoPath)), branch)
pushCmd.Dir = tmpDir
pushCmd.Env = env
if out, err := pushCmd.CombinedOutput(); err != nil {
return fmt.Errorf("git push: %w: %s", err, out)
}
return nil
}
// CloneRepo mirrors a remote HTTP/HTTPS git repository into destPath (bare).
func CloneRepo(srcURL, destPath string) error {
if !strings.HasPrefix(srcURL, "http://") && !strings.HasPrefix(srcURL, "https://") {
return errors.New("only http and https source URLs are supported")
}
env := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
cmd := exec.Command("git", "clone", "--mirror", srcURL, destPath)
cmd.Env = env
if out, err := cmd.CombinedOutput(); err != nil {
// Scrub the URL (which may contain credentials) from the error.
msg := strings.ReplaceAll(string(out), srcURL, "<url>")
return fmt.Errorf("git clone: %w: %s", err, msg)
}
// Enable pushes over HTTP.
cfgCmd := exec.Command("git", "-C", destPath, "config", "http.receivepack", "true")
cfgCmd.Env = env
cfgCmd.Run()
return nil
}
func readmeContent(repoName string) string {
return fmt.Sprintf("# %s\n\n## Description\n\nAdd your project description here.\n\n## Getting Started\n\n### Prerequisites\n\nList your prerequisites here.\n\n### Installation\n\n1. Clone the repository\n2. Follow setup instructions\n\n## Usage\n\nDescribe how to use your project.\n\n## Contributing\n\nContributions are welcome! Please open an issue or pull request.\n\n## License\n\nThis project is licensed under the MIT License.\n", repoName)
}
func gitignoreContent() string {
return "# OS\n.DS_Store\nThumbs.db\n\n# Editor\n.vscode/\n.idea/\n*.swp\n*.swo\n\n# Logs\n*.log\nlogs/\n\n# Dependencies\nnode_modules/\nvendor/\n\n# Build artifacts\ndist/\nbuild/\n*.o\n*.a\n*.so\n"
}
func BlobCat(repoPath, ref, filePath string) ([]byte, error) {
return run(repoPath, "show", ref+":"+filePath)
}
// FileDiff represents a single changed file in a diff.
type FileDiff struct {
Path string `json:"path"`
OldPath string `json:"oldPath,omitempty"` // set on renames
Additions int `json:"additions"`
Deletions int `json:"deletions"`
Patch string `json:"patch"` // unified diff hunk(s) for this file
}
// Diff returns per-file diffs between two refs.
func Diff(repoPath, base, head string) ([]FileDiff, error) {
out, err := run(repoPath,
"diff", "--unified=5", "--no-color",
"--diff-filter=ACDMRT", // exclude untracked
base+".."+head,
)
if err != nil {
return nil, err
}
return parseUnifiedDiff(string(out)), nil
}
// parseUnifiedDiff splits a multi-file unified diff into per-file FileDiff entries.
func parseUnifiedDiff(raw string) []FileDiff {
var files []FileDiff
var cur *FileDiff
var patchLines []string
commit := func() {
if cur != nil {
cur.Patch = strings.Join(patchLines, "\n")
files = append(files, *cur)
}
}
for _, line := range strings.Split(raw, "\n") {
if strings.HasPrefix(line, "diff --git ") {
commit()
cur = &FileDiff{}
patchLines = nil
continue
}
if cur == nil {
continue
}
switch {
case strings.HasPrefix(line, "--- a/"):
cur.OldPath = strings.TrimPrefix(line, "--- a/")
case strings.HasPrefix(line, "+++ b/"):
cur.Path = strings.TrimPrefix(line, "+++ b/")
case strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++"):
cur.Additions++
patchLines = append(patchLines, line)
case strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---"):
cur.Deletions++
patchLines = append(patchLines, line)
default:
patchLines = append(patchLines, line)
}
}
commit()
return files
}