Bold questions

This commit is contained in:
Ian Gulliver
2026-10-14 19:35:34 -07:00
parent ab4c3d2c6f
commit 1ea055682b
54 changed files with 3696 additions and 762 deletions
+144
View File
@@ -0,0 +1,144 @@
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) {
base := since
if base == "" {
mb, err := run(dir, "merge-base", "HEAD", defaultBranch)
if err != nil {
return api.TaskChanges{}, err
}
base = mb
}
tree, err := Snapshot(dir)
if err != nil {
return api.TaskChanges{}, err
}
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
}
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
}
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
}
+144
View File
@@ -0,0 +1,144 @@
package git
import (
"os"
"path/filepath"
"strings"
"testing"
"g.fc.run/misc/byre/internal/api"
)
func TestChanges(t *testing.T) {
dir := t.TempDir()
git := func(args ...string) string {
t.Helper()
out, err := run(dir, args...)
if err != nil {
t.Fatal(err)
}
return out
}
write := func(name, content string) {
t.Helper()
err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)
if err != nil {
t.Fatal(err)
}
}
git("init", "--quiet", "--initial-branch=main")
git("config", "user.email", "test@example.com")
git("config", "user.name", "test")
write(".gitignore", "build/\n")
write("keep.txt", "one\ntwo\nthree\n")
write("gone.txt", "bye\n")
write("old.txt", "a\nb\nc\nd\ne\nf\ng\nh\n")
git("add", ".")
git("commit", "--quiet", "-m", "base")
git("switch", "--quiet", "-c", "task")
write("committed.txt", "c\n")
git("add", "committed.txt")
git("commit", "--quiet", "-m", "task work")
write("keep.txt", "one\nTWO\nthree\nfour\n")
write("untracked.txt", "u\n")
write("blob.bin", "\x00\x01\x02")
os.MkdirAll(filepath.Join(dir, "build"), 0o755)
write("build/out.txt", "ignored\n")
os.Remove(filepath.Join(dir, "gone.txt"))
git("mv", "old.txt", "new.txt")
indexBefore, err := os.ReadFile(filepath.Join(dir, ".git", "index"))
if err != nil {
t.Fatal(err)
}
changes, err := Changes(dir, "main", "")
if err != nil {
t.Fatal(err)
}
indexAfter, err := os.ReadFile(filepath.Join(dir, ".git", "index"))
if err != nil {
t.Fatal(err)
}
if string(indexBefore) != string(indexAfter) {
t.Fatalf("Changes modified the worktree's index")
}
got := map[string]api.FileChange{}
for _, f := range changes.Files {
got[f.Path] = f
}
want := map[string]api.FileChange{
"committed.txt": {Status: api.Added, Additions: 1},
"keep.txt": {Status: api.Modified, Additions: 2, Deletions: 1},
"untracked.txt": {Status: api.Added, Additions: 1},
"blob.bin": {Status: api.Added, Binary: true},
"gone.txt": {Status: api.Deleted, Deletions: 1},
"new.txt": {Status: api.Renamed},
}
if len(got) != len(want) {
t.Fatalf("got %d files %+v, want %d", len(got), changes.Files, len(want))
}
for path, w := range want {
g, ok := got[path]
if !ok {
t.Fatalf("missing %s in %+v", path, changes.Files)
}
if g.Status != w.Status || g.Additions != w.Additions || g.Deletions != w.Deletions || g.Binary != w.Binary {
t.Fatalf("%s: got %+v, want %+v", path, g, w)
}
}
if got["new.txt"].OldPath == nil || *got["new.txt"].OldPath != "old.txt" {
t.Fatalf("rename lost its old path: %+v", got["new.txt"])
}
head, err := Head(dir)
if err != nil {
t.Fatal(err)
}
sinceHead, err := Changes(dir, "main", head)
if err != nil {
t.Fatal(err)
}
for _, f := range sinceHead.Files {
if f.Path == "committed.txt" {
t.Fatalf("changes since HEAD include the already committed file: %+v", sinceHead.Files)
}
}
if len(sinceHead.Files) != len(want)-1 {
t.Fatalf("changes since HEAD: got %+v, want every change but committed.txt", sinceHead.Files)
}
patch, err := FileDiff(dir, changes.Base, changes.Tree, "keep.txt", "")
if err != nil {
t.Fatal(err)
}
for _, line := range []string{"-two", "+TWO", "+four"} {
if !strings.Contains(patch, "\n"+line+"\n") && !strings.HasSuffix(patch, "\n"+line) {
t.Fatalf("patch missing %q:\n%s", line, patch)
}
}
sha, err := CommitAll(dir, "turn work", "Turn Author", "turn@example.com")
if err != nil {
t.Fatal(err)
}
if sha == "" || sha == head {
t.Fatalf("CommitAll made no commit: %q", sha)
}
if got := git("log", "-1", "--format=%an <%ae>|%cn <%ce>|%s"); got != "Turn Author <turn@example.com>|Turn Author <turn@example.com>|turn work" {
t.Fatalf("unexpected commit: %s", got)
}
if status := git("status", "--porcelain", "--untracked-files=all"); status != "" {
t.Fatalf("worktree still dirty after CommitAll:\n%s", status)
}
again, err := CommitAll(dir, "nothing", "Turn Author", "turn@example.com")
if err != nil || again != "" {
t.Fatalf("CommitAll on a clean tree: %q %v", again, err)
}
}
+189 -4
View File
@@ -1,9 +1,12 @@
package git
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
@@ -75,12 +78,194 @@ func RemoveWorktree(repo, path, branch string) error {
return err
}
type Unmerged struct {
Commits int
Files int
Insertions int
Deletions int
Uncommitted int
}
type ConflictError struct {
Files []string
}
func (e *ConflictError) Error() string {
return "merge conflicts in " + strings.Join(e.Files, ", ")
}
func UnmergedChanges(worktree, target string) (Unmerged, error) {
u := Unmerged{}
count, err := run(worktree, "rev-list", "--count", target+"..HEAD")
if err != nil {
return u, err
}
u.Commits, err = strconv.Atoi(count)
if err != nil {
return u, err
}
status, err := run(worktree, "status", "--porcelain", "--untracked-files=all")
if err != nil {
return u, err
}
if status != "" {
u.Uncommitted = len(strings.Split(status, "\n"))
}
tree, err := Snapshot(worktree)
if err != nil {
return u, err
}
base, err := run(worktree, "merge-base", target, "HEAD")
if err != nil {
return u, err
}
numstat, err := run(worktree, "diff", "--numstat", base, tree)
if err != nil {
return u, err
}
for _, line := range strings.Split(numstat, "\n") {
if line == "" {
continue
}
fields := strings.SplitN(line, "\t", 3)
u.Files++
adds, err := strconv.Atoi(fields[0])
if err != nil {
continue
}
dels, err := strconv.Atoi(fields[1])
if err != nil {
continue
}
u.Insertions += adds
u.Deletions += dels
}
return u, nil
}
func SquashMerge(repo, worktree, target, message string, env []string) (string, error) {
checkedOut, err := run(repo, "symbolic-ref", "--short", "HEAD")
if err != nil {
return "", err
}
if checkedOut != target {
return "", fmt.Errorf("%s has %s checked out, not %s", repo, checkedOut, target)
}
tree, err := Snapshot(worktree)
if err != nil {
return "", err
}
base, err := run(worktree, "merge-base", target, "HEAD")
if err != nil {
return "", err
}
head, err := run(repo, "rev-parse", target)
if err != nil {
return "", err
}
out, stderr, code, err := runCode(repo, nil, "merge-tree", "--write-tree", "--name-only", "--merge-base="+base, head, tree)
if err != nil {
return "", err
}
lines := strings.Split(out, "\n")
if code == 1 {
conflict := &ConflictError{}
for _, l := range lines[1:] {
if l == "" {
break
}
conflict.Files = append(conflict.Files, l)
}
return "", conflict
}
if code != 0 {
return "", fmt.Errorf("git merge-tree exited %d: %s", code, stderr)
}
headTree, err := run(repo, "rev-parse", head+"^{tree}")
if err != nil {
return "", err
}
if lines[0] == headTree {
return "", nil
}
commit, err := runEnv(repo, env, "commit-tree", lines[0], "-p", head, "-m", message)
if err != nil {
return "", err
}
_, err = run(repo, "merge", "--ff-only", commit)
if err != nil {
return "", err
}
return commit, nil
}
func Head(dir string) (string, error) {
return run(dir, "rev-parse", "HEAD")
}
func CommitAll(dir, message, name, email string) (string, error) {
status, err := run(dir, "status", "--porcelain", "--untracked-files=all")
if err != nil {
return "", err
}
if status == "" {
return "", nil
}
env := []string{
"GIT_AUTHOR_NAME=" + name,
"GIT_AUTHOR_EMAIL=" + email,
"GIT_COMMITTER_NAME=" + name,
"GIT_COMMITTER_EMAIL=" + email,
}
_, err = runEnv(dir, env, "add", "--all")
if err != nil {
return "", err
}
_, err = runEnv(dir, env, "commit", "--quiet", "--message", message)
if err != nil {
return "", err
}
return Head(dir)
}
func run(dir string, args ...string) (string, error) {
return runEnv(dir, nil, args...)
}
func runEnv(dir string, env []string, args ...string) (string, error) {
out, stderr, code, err := runCode(dir, env, args...)
if err != nil {
return "", err
}
if code != 0 {
return "", fmt.Errorf("git %s: exit status %d: %s", strings.Join(args, " "), code, stderr)
}
return out, nil
}
func runCode(dir string, env []string, args ...string) (string, string, int, error) {
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
cmd.Env = append(os.Environ(), env...)
stderr := &strings.Builder{}
cmd.Stderr = stderr
out, err := cmd.Output()
exit := &exec.ExitError{}
if errors.As(err, &exit) {
return strings.TrimSpace(string(out)), strings.TrimSpace(stderr.String()), exit.ExitCode(), nil
}
return strings.TrimSpace(string(out)), nil
if err != nil {
return "", "", 0, fmt.Errorf("git %s: %w", strings.Join(args, " "), err)
}
return strings.TrimSpace(string(out)), strings.TrimSpace(stderr.String()), 0, nil
}