package git import ( "fmt" "os" "strconv" "strings" "g.fc.run/misc/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 Parent(dir, commit string) (string, error) { return run(dir, "rev-parse", "--verify", commit+"^") } 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 }