157 lines
3.5 KiB
Go
157 lines
3.5 KiB
Go
package git
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"g.fc.run/ai/byre/internal/api"
|
|
)
|
|
|
|
var statuses = map[byte]api.FileChangeStatus{
|
|
'A': api.Added,
|
|
'M': api.Modified,
|
|
'D': api.Deleted,
|
|
'R': api.Renamed,
|
|
'T': api.TypeChanged,
|
|
}
|
|
|
|
func Changes(dir, defaultBranch, since string) (api.TaskChanges, error) {
|
|
tree, err := Snapshot(dir)
|
|
if err != nil {
|
|
return api.TaskChanges{}, err
|
|
}
|
|
return Compare(dir, defaultBranch, "HEAD", tree, since)
|
|
}
|
|
|
|
func Compare(dir, defaultBranch, head, tree, since string) (api.TaskChanges, error) {
|
|
base := since
|
|
if base == "" {
|
|
mb, err := run(dir, "merge-base", head, defaultBranch)
|
|
if err != nil {
|
|
return api.TaskChanges{}, err
|
|
}
|
|
base = mb
|
|
}
|
|
|
|
files, err := stats(dir, base, tree)
|
|
if err != nil {
|
|
return api.TaskChanges{}, err
|
|
}
|
|
|
|
return api.TaskChanges{Base: base, Tree: tree, Files: files}, nil
|
|
}
|
|
|
|
func Snapshot(dir string) (string, error) {
|
|
index, err := run(dir, "rev-parse", "--path-format=absolute", "--git-path", "index")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
info, err := os.Stat(index)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
data, err := os.ReadFile(index)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
tmp, err := os.CreateTemp("", "byre-index-*")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer os.Remove(tmp.Name())
|
|
_, err = tmp.Write(data)
|
|
if err != nil {
|
|
tmp.Close()
|
|
return "", err
|
|
}
|
|
err = tmp.Close()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
// git trusts cached stat data for entries older than the index file; a fresh mtime would hide same-size edits.
|
|
err = os.Chtimes(tmp.Name(), info.ModTime(), info.ModTime())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
env := []string{"GIT_INDEX_FILE=" + tmp.Name()}
|
|
_, err = runEnv(dir, env, "add", "--all")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return runEnv(dir, env, "write-tree")
|
|
}
|
|
|
|
func FileDiff(dir, base, tree, path, oldPath string) (string, error) {
|
|
args := []string{"diff", "--find-renames", "--no-ext-diff", "--no-color", base, tree, "--", path}
|
|
if oldPath != "" {
|
|
args = append(args, oldPath)
|
|
}
|
|
return run(dir, args...)
|
|
}
|
|
|
|
func stats(dir, base, tree string) ([]api.FileChange, error) {
|
|
nameStatus, err := run(dir, "diff", "--find-renames", "--name-status", "-z", base, tree)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
numstat, err := run(dir, "diff", "--find-renames", "--numstat", "-z", base, tree)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
counts := map[string]api.FileChange{}
|
|
tokens := strings.Split(numstat, "\x00")
|
|
for i := 0; i < len(tokens); i++ {
|
|
fields := strings.SplitN(tokens[i], "\t", 3)
|
|
if len(fields) != 3 {
|
|
continue
|
|
}
|
|
path := fields[2]
|
|
if path == "" && i+2 < len(tokens) {
|
|
path = tokens[i+2]
|
|
i += 2
|
|
}
|
|
c := api.FileChange{Binary: fields[0] == "-"}
|
|
c.Additions, _ = strconv.Atoi(fields[0])
|
|
c.Deletions, _ = strconv.Atoi(fields[1])
|
|
counts[path] = c
|
|
}
|
|
|
|
files := []api.FileChange{}
|
|
tokens = strings.Split(nameStatus, "\x00")
|
|
for i := 0; i < len(tokens); i++ {
|
|
code := tokens[i]
|
|
if code == "" {
|
|
continue
|
|
}
|
|
status, ok := statuses[code[0]]
|
|
if !ok {
|
|
return nil, fmt.Errorf("unknown diff status %q", code)
|
|
}
|
|
f := api.FileChange{Status: status}
|
|
if status == api.Renamed {
|
|
if i+2 >= len(tokens) {
|
|
return nil, fmt.Errorf("truncated rename in name-status output")
|
|
}
|
|
old := tokens[i+1]
|
|
f.OldPath = &old
|
|
f.Path = tokens[i+2]
|
|
i += 2
|
|
} else {
|
|
if i+1 >= len(tokens) {
|
|
return nil, fmt.Errorf("truncated name-status output")
|
|
}
|
|
f.Path = tokens[i+1]
|
|
i++
|
|
}
|
|
c := counts[f.Path]
|
|
f.Additions, f.Deletions, f.Binary = c.Additions, c.Deletions, c.Binary
|
|
files = append(files, f)
|
|
}
|
|
return files, nil
|
|
}
|