Download
194 lines 7.1 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
package tools

import (
	"context"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"blinker/internal/policy"
)

func TestWithTrailer(t *testing.T) {
	tr := coauthor("gpt-x\n(evil) <a@b>")
	if tr != "Co-authored-by: blinker (gpt-xevil a@b) <blinker@bruce-hill.com>" {
		t.Errorf("coauthor = %q", tr)
	}
	for msg, want := range map[string]string{
		"Fix it":                           "Fix it\n\n" + tr,
		"Fix it\n\nBecause.":               "Fix it\n\nBecause.\n\n" + tr,
		"Fix it\n\nSigned-off-by: A <a@b>": "Fix it\n\nSigned-off-by: A <a@b>\n" + tr,
		"Fix it\n\n" + tr:                  "Fix it\n\n" + tr,
		"Fix: it":                          "Fix: it\n\n" + tr,
		"Fix it\n\nNote: this, and\nmore.": "Fix it\n\nNote: this, and\nmore.\n\n" + tr,
	} {
		if got := withTrailer(msg, tr); got != want {
			t.Errorf("withTrailer(%q) = %q, want %q", msg, got, want)
		}
	}
}

func TestParseBatch(t *testing.T) {
	got, err := parseBatch("0000 missing\nabc blob 3\nhi\n\nabd blob 0\n\n", 3)
	if err != nil || len(got) != 3 || got[0] != "" || got[1] != "hi\n" || got[2] != "" {
		t.Errorf("parseBatch = %q, %v", got, err)
	}
	for _, bad := range []string{"abc blob 9\nhi\n", "abc tree 3\nhi\n\n", "abc blob 3\nhi\n\nextra", "abc blob 2\nhi\n\n"} {
		if _, err := parseBatch(bad, 1); err == nil {
			t.Errorf("parseBatch(%q) succeeded", bad)
		}
	}
}

func TestCommitReview(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not installed")
	}
	f := setup(t, nil, policy.ToolRule{Tool: "Commit", Access: policy.Ask})
	f.tb.Model = "test-model"
	git := func(args ...string) string {
		t.Helper()
		out, err := exec.Command("git", append([]string{"-C", f.project}, args...)...).CombinedOutput()
		if err != nil {
			t.Fatalf("git %v: %v\n%s", args, err, out)
		}
		return string(out)
	}
	git("init", "-q")
	git("config", "user.email", "t@example.com")
	git("config", "user.name", "Test")
	git("add", "main.go")
	git("commit", "-qm", "First")
	write := func(name, text string) {
		t.Helper()
		if err := os.WriteFile(filepath.Join(f.project, name), []byte(text), 0o644); err != nil {
			t.Fatal(err)
		}
	}
	write("main.go", "package main\n\nfunc main() { println(1) }\n")
	write("new.go", "package main\n")
	args := map[string]any{"message": "Print one", "files": []string{"main.go", "new.go"}}

	// Denied with a note: nothing is committed, and the review showed the
	// message with its trailer and the whole diff.
	f.answer = func(q Question) Answer { return Answer{Option: q.Opts[3], Note: "say why"} }
	r := f.run(t, "Commit", args)
	if !r.Failed || !strings.Contains(r.Output, "say why") || len(f.asked) != 1 {
		t.Fatalf("denied: %+v", r)
	}
	q := f.asked[0]
	if !q.Pager || q.Body != "Print one\n\nCo-authored-by: blinker (test-model) <blinker@bruce-hill.com>" ||
		!strings.Contains(q.Diff, "+func main() { println(1) }") || !strings.Contains(q.Diff, "+++ b/new.go") {
		t.Errorf("review: %+v", q)
	}
	if len(q.Edits) != 3 || q.Edits[0].New != q.Body || q.Edits[1].Name != "main.go" || q.Edits[2].Old != "" {
		t.Errorf("edits: %+v", q.Edits)
	}
	if git("rev-list", "--count", "HEAD") != "1\n" {
		t.Error("a denied commit was made")
	}

	// Edited, then committed: the edits are reviewed again, and what is
	// committed is what was shown. What else is staged stays staged.
	write("other.go", "package main\n")
	git("add", "other.go")
	f.asked = nil
	f.answer = func(q Question) Answer {
		if len(f.asked) == 1 {
			return Answer{Option: q.Opts[1], Edited: []string{"Print two\n", q.Edits[1].New, "package main // edited\n"}}
		}
		return Answer{Option: q.Opts[0]}
	}
	r = f.run(t, "Commit", args)
	if r.Failed || len(f.asked) != 2 || !strings.HasPrefix(r.Output, "The user approved the commit, and it is done. Committed ") || !strings.Contains(r.Output, "the message") || !strings.Contains(r.Output, "+package main // edited") {
		t.Fatalf("edited: %+v", r)
	}
	if f.asked[1].Body != "Print two" || !strings.Contains(f.asked[1].Diff, "+package main // edited") {
		t.Errorf("second review: %+v", f.asked[1])
	}
	if got := git("log", "-1", "--format=%B"); got != "Print two\n\n" {
		t.Errorf("message = %q", got)
	}
	if got := git("show", "HEAD:new.go"); got != "package main // edited\n" {
		t.Errorf("new.go in the commit = %q", got)
	}
	if hash := strings.TrimSpace(git("rev-parse", "--short", "HEAD")); !strings.Contains(r.Summary, hash) || !strings.Contains(r.Detail, "Print two") {
		t.Errorf("result: %+v", r)
	}
	if got := git("diff", "--cached", "--name-only"); got != "other.go\n" {
		t.Errorf("staged after the commit: %q", got)
	}
	if got := git("status", "--porcelain", "--", "main.go", "new.go"); got != "" {
		t.Errorf("status after the commit: %q", got)
	}

	// Nothing left to commit.
	if r := f.run(t, "Commit", args); !r.Failed || !strings.Contains(r.Output, "nothing to commit") || !strings.Contains(r.Output, "Print two)") {
		t.Errorf("empty commit: %+v", r)
	}
}

// Commit's git sees files as the sandbox shows them: a hidden directory
// as empty. Committing that would delete the hidden files from the
// repository. And its paths are names, not patterns.
func TestCommitMatchesHost(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not installed")
	}
	f := setup(t, nil, policy.ToolRule{Tool: "Commit", Access: policy.Allow})
	git := func(args ...string) string {
		t.Helper()
		out, err := exec.Command("git", append([]string{"-C", f.project}, args...)...).CombinedOutput()
		if err != nil {
			t.Fatalf("git %v: %v\n%s", args, err, out)
		}
		return string(out)
	}
	prod := filepath.Join(f.project, "src", ".envs", "prod")
	if err := os.MkdirAll(filepath.Dir(prod), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(prod, []byte("KEY=1\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	git("init", "-q")
	git("config", "user.email", "t@example.com")
	git("config", "user.name", "Test")
	git("add", "-f", "src", "main.go")
	git("commit", "-qm", "First")
	if err := os.WriteFile(filepath.Join(f.project, "src", "a.go"), []byte("package src // changed\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(filepath.Join(f.project, "main.go"), []byte("package main // changed\n"), 0o644); err != nil {
		t.Fatal(err)
	}

	r := f.run(t, "Commit", map[string]any{"message": "Change src", "files": []string{"src"}})
	if !r.Failed || !strings.Contains(r.Output, ".envs/prod") {
		t.Errorf("committed over a hidden directory: %+v", r)
	}
	r = f.run(t, "Commit", map[string]any{"message": "Change Go files", "files": []string{"*.go"}})
	if !r.Failed {
		t.Errorf("a pattern was taken as paths: %+v", r)
	}
	if got := git("rev-list", "--count", "HEAD"); got != "1\n" {
		t.Errorf("%s commits", strings.TrimSpace(got))
	}
	if got := git("ls-files", "src/.envs"); got != "src/.envs/prod\n" {
		t.Errorf("tracked under src/.envs: %q", got)
	}
}

// Output past the limit is reported as that, however the command dies of
// it.
func TestGitOutputLimit(t *testing.T) {
	f := setup(t, nil)
	_, err := f.tb.git(context.Background(), policy.SafeMode, nil, time.Minute, "", fmt.Sprintf("head -c %d /dev/zero", maxGitOutput+1))
	if err == nil || !strings.Contains(err.Error(), "too much to review") {
		t.Errorf("git = %v", err)
	}
}