785 lines
23 KiB
Go
785 lines
23 KiB
Go
package git
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"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
|
|
}
|
|
|
|
// FileUpload holds a file path and its content for a batch commit.
|
|
type FileUpload struct {
|
|
Path string // repo-relative path, e.g. "src/main.go"
|
|
Content []byte
|
|
}
|
|
|
|
// WriteManyFiles commits all files in a single commit to branch. Each file path
|
|
// must be a clean relative path — no ".." or absolute paths allowed.
|
|
func WriteManyFiles(repoPath, branch, message, authorName, authorEmail string, files []FileUpload) error {
|
|
if len(files) == 0 {
|
|
return errors.New("no files to commit")
|
|
}
|
|
|
|
// Validate all paths before touching the filesystem.
|
|
for _, f := range files {
|
|
clean := filepath.Clean(filepath.FromSlash(f.Path))
|
|
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
|
|
return fmt.Errorf("invalid file path: %s", f.Path)
|
|
}
|
|
}
|
|
|
|
tmpDir, err := os.MkdirTemp("", "fb-upload-*")
|
|
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)
|
|
}()
|
|
|
|
for _, f := range files {
|
|
clean := filepath.Clean(filepath.FromSlash(f.Path))
|
|
fullPath := filepath.Join(tmpDir, clean)
|
|
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
|
|
return fmt.Errorf("mkdirall %s: %w", clean, err)
|
|
}
|
|
if err := os.WriteFile(fullPath, f.Content, 0644); err != nil {
|
|
return fmt.Errorf("writefile %s: %w", clean, err)
|
|
}
|
|
}
|
|
|
|
addC := exec.Command("git", "add", ".")
|
|
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
|
|
}
|
|
|
|
// Run executes a git command in repoPath with discrete arguments and returns
|
|
// the raw stdout. WARNING: args must be constant literals or strictly validated
|
|
// — no user-controlled values belong here. This is the public equivalent of the
|
|
// internal run() helper and carries the same safety guarantees.
|
|
func Run(repoPath string, args ...string) ([]byte, error) {
|
|
return run(repoPath, args...)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// ArchiveStream writes a git archive of ref in the requested format to w.
|
|
// format must be one of "zip", "tar.gz", or "bundle".
|
|
// Output is streamed directly to w without buffering.
|
|
func ArchiveStream(repoPath string, ref string, format string, w io.Writer) error {
|
|
clean := filepath.Clean(repoPath)
|
|
if repoRoot != "" {
|
|
root := repoRoot + string(filepath.Separator)
|
|
if !strings.HasPrefix(clean+string(filepath.Separator), root) && clean != repoRoot {
|
|
return ErrPathTraversal
|
|
}
|
|
}
|
|
|
|
baseEnv := []string{"GIT_TERMINAL_PROMPT=0", "HOME=/tmp"}
|
|
|
|
var cmd *exec.Cmd
|
|
switch format {
|
|
case "zip", "tar.gz":
|
|
cmd = exec.Command("git", "archive", "--format="+format, ref)
|
|
case "bundle":
|
|
cmd = exec.Command("git", "bundle", "create", "-", "--all")
|
|
default:
|
|
return fmt.Errorf("git archive: unsupported format %q", format)
|
|
}
|
|
|
|
cmd.Dir = clean
|
|
cmd.Env = baseEnv
|
|
cmd.Stdout = w
|
|
|
|
var errBuf strings.Builder
|
|
cmd.Stderr = &errBuf
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
if errBuf.Len() > 0 {
|
|
return fmt.Errorf("git archive: %w: %s", err, errBuf.String())
|
|
}
|
|
return fmt.Errorf("git archive: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ── Language stats ─────────────────────────────────────────────────────────────
|
|
|
|
// LangStat holds the aggregate file-count and percentage for one language.
|
|
type LangStat struct {
|
|
Name string `json:"name"`
|
|
Color string `json:"color"`
|
|
Count int `json:"count"`
|
|
Pct float64 `json:"pct"`
|
|
}
|
|
|
|
// extLang maps file extensions to (display name, hex color).
|
|
var extLang = map[string][2]string{
|
|
".go": {"Go", "#00ADD8"},
|
|
".ts": {"TypeScript", "#3178C6"},
|
|
".tsx": {"TypeScript", "#3178C6"},
|
|
".js": {"JavaScript", "#F7DF1E"},
|
|
".jsx": {"JavaScript", "#F7DF1E"},
|
|
".mjs": {"JavaScript", "#F7DF1E"},
|
|
".py": {"Python", "#3572A5"},
|
|
".rb": {"Ruby", "#CC342D"},
|
|
".rs": {"Rust", "#DEA584"},
|
|
".java": {"Java", "#B07219"},
|
|
".cs": {"C#", "#178600"},
|
|
".cpp": {"C++", "#F34B7D"},
|
|
".cc": {"C++", "#F34B7D"},
|
|
".c": {"C", "#555555"},
|
|
".h": {"C", "#555555"},
|
|
".swift": {"Swift", "#F05138"},
|
|
".kt": {"Kotlin", "#A97BFF"},
|
|
".php": {"PHP", "#4F5D95"},
|
|
".html": {"HTML", "#E34C26"},
|
|
".css": {"CSS", "#563D7C"},
|
|
".scss": {"SCSS", "#C6538C"},
|
|
".sql": {"SQL", "#e38c00"},
|
|
".sh": {"Shell", "#89E051"},
|
|
".bash": {"Shell", "#89E051"},
|
|
".yaml": {"YAML", "#CB171E"},
|
|
".yml": {"YAML", "#CB171E"},
|
|
".json": {"JSON", "#292929"},
|
|
".md": {"Markdown", "#083FA1"},
|
|
".tf": {"HCL", "#844FBA"},
|
|
".proto": {"Protobuf", "#5F5CE9"},
|
|
".dart": {"Dart", "#00B4AB"},
|
|
".lua": {"Lua", "#000080"},
|
|
".r": {"R", "#198CE7"},
|
|
}
|
|
|
|
// LanguageStats analyses the file tree at ref and returns language breakdown
|
|
// sorted by file count descending. Languages under 3% are collapsed into "Other".
|
|
func LanguageStats(repoPath, ref string) ([]LangStat, error) {
|
|
if ref == "" {
|
|
ref = "HEAD"
|
|
}
|
|
out, err := run(repoPath, "ls-tree", "-r", "--name-only", ref)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
counts := map[string]int{}
|
|
total := 0
|
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
ext := strings.ToLower(filepath.Ext(line))
|
|
lang, ok := extLang[ext]
|
|
if ok {
|
|
counts[lang[0]]++
|
|
} else {
|
|
counts["Other"]++
|
|
}
|
|
total++
|
|
}
|
|
if total == 0 {
|
|
return []LangStat{}, nil
|
|
}
|
|
|
|
// Aggregate by name with color lookup.
|
|
colorOf := map[string]string{"Other": "#8B8B8B"}
|
|
for _, v := range extLang {
|
|
colorOf[v[0]] = v[1]
|
|
}
|
|
|
|
var stats []LangStat
|
|
otherCount := 0
|
|
for name, count := range counts {
|
|
pct := float64(count) / float64(total) * 100
|
|
if name == "Other" || pct < 3.0 {
|
|
otherCount += count
|
|
continue
|
|
}
|
|
stats = append(stats, LangStat{Name: name, Color: colorOf[name], Count: count, Pct: pct})
|
|
}
|
|
sort.Slice(stats, func(i, j int) bool { return stats[i].Count > stats[j].Count })
|
|
if len(stats) > 10 {
|
|
for _, s := range stats[10:] {
|
|
otherCount += s.Count
|
|
}
|
|
stats = stats[:10]
|
|
}
|
|
if otherCount > 0 {
|
|
stats = append(stats, LangStat{
|
|
Name: "Other",
|
|
Color: "#8B8B8B",
|
|
Count: otherCount,
|
|
Pct: float64(otherCount) / float64(total) * 100,
|
|
})
|
|
}
|
|
return stats, nil
|
|
}
|
|
|
|
// ── Contributor stats ─────────────────────────────────────────────────────────
|
|
|
|
// Contributor holds a commit author name and their commit count.
|
|
type Contributor struct {
|
|
Name string `json:"name"`
|
|
Commits int `json:"commits"`
|
|
}
|
|
|
|
// Contributors returns the top limit commit authors sorted by commit count.
|
|
// Uses git shortlog which is fast even on large repos.
|
|
func Contributors(repoPath string, limit int) ([]Contributor, error) {
|
|
out, err := run(repoPath, "shortlog", "-sn", "--no-merges", "HEAD")
|
|
if err != nil {
|
|
return []Contributor{}, nil // empty repo or detached HEAD — not an error
|
|
}
|
|
|
|
var result []Contributor
|
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
// Format: " 42\tAlice Wang"
|
|
idx := strings.Index(line, "\t")
|
|
if idx < 0 {
|
|
continue
|
|
}
|
|
countStr := strings.TrimSpace(line[:idx])
|
|
name := strings.TrimSpace(line[idx+1:])
|
|
n, err := strconv.Atoi(countStr)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
result = append(result, Contributor{Name: name, Commits: n})
|
|
if len(result) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// CommitCount returns the total number of commits reachable from HEAD.
|
|
func CommitCount(repoPath string) (int, error) {
|
|
out, err := run(repoPath, "rev-list", "--count", "HEAD")
|
|
if err != nil {
|
|
return 0, nil // empty repo
|
|
}
|
|
n, err := strconv.Atoi(strings.TrimSpace(string(out)))
|
|
if err != nil {
|
|
return 0, nil
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
// SearchFiles returns file paths matching query (case-insensitive substring)
|
|
// in the repository tree at ref, capped at limit results.
|
|
func SearchFiles(repoPath, ref, query string, limit int) ([]string, error) {
|
|
if ref == "" {
|
|
ref = "HEAD"
|
|
}
|
|
out, err := run(repoPath, "ls-tree", "-r", "--name-only", ref)
|
|
if err != nil {
|
|
return []string{}, nil // empty repo
|
|
}
|
|
|
|
lower := strings.ToLower(query)
|
|
var results []string
|
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if strings.Contains(strings.ToLower(line), lower) {
|
|
results = append(results, line)
|
|
if len(results) >= limit {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return results, nil
|
|
}
|