Download
395 lines 13.6 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
package tools

import (
	"context"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"
	"unicode"
	"unicode/utf8"

	"blinker/internal/diff"
	"blinker/internal/policy"
	"blinker/internal/sandbox"
	"blinker/internal/tooldef"
)

// The built-in Commit tool runs here rather than as a script, so the user
// can review a commit before it is made. The files are staged in a
// temporary index, starting from HEAD; the user sees the message and the
// diff of that tree; and then exactly that tree is committed, so nothing
// that changes in between gets in. Every git command runs in the sandbox
// with the tool's access, as fixed scripts given their arguments as argv.

// gitFunc runs git with nothing from the repository's config that runs a
// program: the tool may write .git. Paths are literal, not patterns, so
// "*.go" is a file of that name and not every Go file below.
const gitFunc = `g() { GIT_LITERAL_PATHSPECS=1 git -c core.hooksPath=/dev/null -c core.fsmonitor=false -c commit.gpgSign=false "$@"; }
`

// prepareScript stages the files ($@) in a temporary index and prints the
// repository's top directory, HEAD (empty before the first commit), HEAD's
// tree and the new tree.
const prepareScript = gitFunc + `dir=$(mktemp -d "$TMPDIR/commit.XXXXXX") || exit
trap 'rm -rf "$dir"' EXIT
export GIT_INDEX_FILE=$dir/index
top=$(g rev-parse --show-toplevel) || exit
if head=$(g rev-parse -q --verify 'HEAD^{commit}'); then
	base=$(g rev-parse "$head^{tree}") && g read-tree "$head" || exit
else
	head= base=$(g hash-object -t tree /dev/null) || exit
fi
g add -A -- "$@" && tree=$(g write-tree) || exit
printf '%s\n' "$top" "$head" "$base" "$tree"
`

// diffScript prints the diff from tree $1 to tree $2, with no external
// diff programs or text conversion.
const diffScript = gitFunc + `g diff-tree -r -p -M --no-ext-diff --no-textconv --no-color "$1" "$2"`

// headScript prints commit $1's short hash and subject.
const headScript = gitFunc + `g log -1 --format='%h %s' "$1"`

// rawScript lists what changed from tree $1 to tree $2, NUL-separated.
const rawScript = gitFunc + `g diff-tree -r -z --no-renames "$1" "$2"`

// catScript prints the objects named on stdin.
const catScript = gitFunc + `g cat-file --batch`

// finishScript commits tree $2 with parent $1 (none if empty) and message
// $3, moves HEAD from $1 to it, and resets the files ($@ after those) in
// the index to it, as git commit -- FILES does. It prints the short hash.
const finishScript = gitFunc + `head=$1 tree=$2 msg=$3
shift 3
parent=()
if [ -n "$head" ]; then parent=(-p "$head"); fi
new=$(printf '%s\n' "$msg" | g commit-tree "${parent[@]}" "$tree") &&
	g update-ref -m "commit: ${msg%%$'\n'*}" HEAD "$new" "$head" &&
	g reset -q -- "$@" &&
	g rev-parse --short "$new"
`

// maxGitOutput bounds what a git command may print: a diff the user must
// read in full, or the files to edit.
const maxGitOutput = 16 << 20

// commitOpts are the answers to a commit's review.
var commitOpts = []Option{
	{Label: "Commit", Allow: true},
	{Label: "Edit the message and changes in vimdiff, then review again", Allow: true, Edit: true},
	{Label: "Deny"},
	{Label: "Deny, and tell the model what to change", Explain: true},
}

// commit runs a call of the built-in Commit tool.
func (t *Toolbox) commit(ctx context.Context, tool tooldef.Def, args string) (Result, error) {
	r := Result{Summary: "Commit"}
	mode := tool.Mode()
	pol, params, _, err := t.toolArgs(tool, args)
	if err != nil {
		return r, err
	}
	if len(params) < 2 {
		return r, errors.New("no files given")
	}
	timeout := t.timeout(tool)
	git := func(stdin, script string, args ...string) (string, error) {
		return t.git(ctx, mode, pol, timeout, stdin, script, args...)
	}
	msg, files := strings.TrimSpace(params[0]), params[1:]
	if msg == "" {
		return r, errors.New("the commit message is empty")
	}
	msg = withTrailer(msg, coauthor(t.Model))
	r.Summary = "Commit: " + subject(msg)
	var changes []string // what the user changed, for the model
	for {
		out, err := git("", prepareScript, files...)
		if err != nil {
			return r, err
		}
		lines := strings.Split(strings.TrimSuffix(out, "\n"), "\n")
		if len(lines) != 4 || !filepath.IsAbs(lines[0]) {
			return r, fmt.Errorf("git printed something unexpected: %q", out)
		}
		top, head, base, tree := lines[0], lines[1], lines[2], lines[3]
		if base == tree {
			r.Summary = "Commit: nothing to commit"
			last := ""
			if head != "" {
				if last, err = git("", headScript, head); err != nil {
					return r, err
				}
				last = " (" + strings.TrimSpace(last) + ")"
			}
			return r, fmt.Errorf("nothing to commit: the files are the same as in the last commit%s. If you just committed them, that commit is done", last)
		}
		raw, err := git("", rawScript, base, tree)
		if err != nil {
			return r, err
		}
		changed, err := parseRaw(raw)
		if err != nil {
			return r, err
		}
		if err := t.checkHost(top, changed); err != nil {
			return r, err
		}
		if tool.Access == policy.Ask {
			d, err := git("", diffScript, base, tree)
			if err != nil {
				return r, err
			}
			editable, err := t.commitFiles(git, changed)
			if err != nil {
				return r, err
			}
			edits := append([]Edit{{Name: "COMMIT_EDITMSG", Old: msg, New: msg}}, editable...)
			a, ok, err := t.ask(ctx, Question{
				Tool: tool.Name, Title: "The Commit tool wants to make this commit",
				Body: msg, Diff: d, Edits: edits, Pager: true, Opts: commitOpts,
			}, &r, "The user denied this commit; nothing was committed.")
			if !ok {
				return r, err
			}
			if a.Option.Edit {
				if len(a.Edited) != len(edits) {
					return r, errors.New("the editor returned the wrong number of files; nothing was committed")
				}
				if edited := strings.TrimSpace(a.Edited[0]); edited != msg {
					if edited == "" {
						r.Output, r.Failed, r.Detail = "The user emptied the commit message, which cancels the commit; nothing was committed.", true, "canceled"
						return r, nil
					}
					msg = edited
					r.Summary = "Commit: " + subject(msg)
					changes = append(changes, "the message")
				}
				for i, f := range editable {
					text := a.Edited[i+1]
					if text == f.New {
						continue
					}
					if err := t.SB.WriteFile(ctx, mode, t.SB.HostPath(filepath.Join(top, f.Name)), []byte(text)); err != nil {
						return r, fmt.Errorf("writing the user's edit: %w", err)
					}
					changes = append(changes, f.Name+", by:\n"+diff.Unified(f.Name, f.New, text, 3))
				}
				continue // to review the edited commit
			}
		}
		hash, err := git("", finishScript, append([]string{head, tree, msg}, files...)...)
		if err != nil {
			return r, err
		}
		hash = strings.TrimSpace(hash)
		r.Summary = "Committed " + hash
		r.Detail = msg
		r.Output = fmt.Sprintf("Committed %s with this message:\n%s", hash, msg)
		if tool.Access == policy.Ask {
			r.Output = "The user approved the commit, and it is done. " + r.Output
		}
		if len(changes) > 0 {
			r.Output += "\nThe user edited the commit before approving it. They changed " + strings.Join(changes, "\nand ")
		}
		return r, nil
	}
}

// change is one entry of git diff-tree -r -z --no-renames.
type change struct {
	oldMode, newMode, oldOID, newOID, status string
	path                                     string // relative to the repository's top
}

// parseRaw reads what rawScript prints: each change is ":OLDMODE NEWMODE
// OLDOID NEWOID STATUS" and the path, NUL-terminated.
func parseRaw(raw string) ([]change, error) {
	if raw == "" {
		return nil, nil
	}
	parts := strings.Split(strings.TrimSuffix(raw, "\x00"), "\x00")
	if len(parts)%2 != 0 {
		return nil, fmt.Errorf("git listed the changes unexpectedly: %q", raw)
	}
	var out []change
	for i := 0; i < len(parts); i += 2 {
		fields := strings.Fields(strings.TrimPrefix(parts[i], ":"))
		path := parts[i+1]
		if !strings.HasPrefix(parts[i], ":") || len(fields) != 5 || !filepath.IsLocal(path) {
			return nil, fmt.Errorf("git listed a change unexpectedly: %q", parts[i]+" "+path)
		}
		out = append(out, change{fields[0], fields[1], fields[2], fields[3], fields[4], path})
	}
	return out, nil
}

// checkHost refuses a commit that does not match the files on the host.
// git runs in the sandbox, which shows a hidden directory as empty and a
// protected path that does not exist as an empty file: committing what it
// sees would delete the hidden files from the repository, or add files
// that are not there. Only whether each path exists is checked, never
// what it holds.
func (t *Toolbox) checkHost(top string, changes []change) error {
	for _, c := range changes {
		host := t.SB.HostPath(filepath.Join(top, c.path))
		_, err := os.Lstat(host)
		switch {
		case c.status == "D" && err == nil:
			return fmt.Errorf("the commit would delete %s, which the sandbox hides from the tool but which exists; nothing was committed", c.path)
		case c.status == "D" && !errors.Is(err, fs.ErrNotExist):
			return fmt.Errorf("checking %s: %w", c.path, err)
		case c.status != "D" && err != nil:
			return fmt.Errorf("the commit would add %s, which the sandbox shows the tool but which does not exist; nothing was committed", c.path)
		}
	}
	return nil
}

// commitFiles reads the text files the commit changes, before and after,
// for the user to edit. Others (binary, symlinks, deleted, submodules) are
// left out.
func (t *Toolbox) commitFiles(git func(stdin, script string, args ...string) (string, error), changes []change) ([]Edit, error) {
	regular := func(mode string) bool { return mode == "100644" || mode == "100755" }
	var paths, oids []string
	for _, c := range changes {
		if !regular(c.newMode) || !(regular(c.oldMode) || c.oldMode == "000000") {
			continue
		}
		paths = append(paths, c.path)
		oids = append(oids, c.oldOID, c.newOID)
	}
	if len(paths) == 0 {
		return nil, nil
	}
	out, err := git(strings.Join(oids, "\n")+"\n", catScript)
	if err != nil {
		return nil, err
	}
	blobs, err := parseBatch(out, len(oids))
	if err != nil {
		return nil, err
	}
	var editable []Edit
	for i, path := range paths {
		oldText, newText := blobs[2*i], blobs[2*i+1]
		if isText(oldText) && isText(newText) {
			editable = append(editable, Edit{Name: path, Old: oldText, New: newText})
		}
	}
	return editable, nil
}

// parseBatch reads n objects from git cat-file --batch: each is a line
// "OID TYPE SIZE" and SIZE bytes then a newline, or "NAME missing". A
// missing object (the all-zero one, for a new file) is empty.
func parseBatch(out string, n int) ([]string, error) {
	var blobs []string
	for range n {
		header, rest, ok := strings.Cut(out, "\n")
		if !ok {
			return nil, errors.New("git cat-file stopped early")
		}
		if strings.HasSuffix(header, " missing") {
			blobs, out = append(blobs, ""), rest
			continue
		}
		fields := strings.Fields(header)
		if len(fields) != 3 || fields[1] != "blob" {
			return nil, fmt.Errorf("git cat-file printed %q", header)
		}
		size, err := strconv.Atoi(fields[2])
		if err != nil || size < 0 || size+1 > len(rest) || rest[size] != '\n' {
			return nil, fmt.Errorf("git cat-file printed %q", header)
		}
		blobs, out = append(blobs, rest[:size]), rest[size+1:]
	}
	if out != "" {
		return nil, errors.New("git cat-file printed more than asked")
	}
	return blobs, nil
}

func isText(s string) bool { return utf8.ValidString(s) && !strings.ContainsRune(s, 0) }

// coauthor is the trailer for commits blinker makes. The model's name
// comes from the config; it is kept to one plain line.
func coauthor(model string) string {
	model = strings.Map(func(r rune) rune {
		if unicode.IsControl(r) || r == '<' || r == '>' || r == '(' || r == ')' {
			return -1
		}
		return r
	}, model)
	if model = strings.TrimSpace(model); model == "" {
		return "Co-authored-by: blinker <blinker@bruce-hill.com>"
	}
	return "Co-authored-by: blinker (" + model + ") <blinker@bruce-hill.com>"
}

// withTrailer adds a trailer to a message, unless it has it: in the last
// paragraph if that is already trailers, or else in one of its own.
func withTrailer(msg, trailer string) string {
	paragraphs := strings.Split(msg, "\n\n")
	last := paragraphs[len(paragraphs)-1]
	allTrailers := len(paragraphs) > 1
	for _, line := range strings.Split(last, "\n") {
		if line == trailer {
			return msg
		}
		key, _, ok := strings.Cut(line, ": ")
		if !ok || key == "" || strings.ContainsFunc(key, func(r rune) bool {
			return !(r == '-' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9')
		}) {
			allTrailers = false
		}
	}
	if allTrailers {
		return msg + "\n" + trailer
	}
	return msg + "\n\n" + trailer
}

// git runs one of the scripts above in the sandbox, as Commit, and returns
// what it printed. A failure is an error with git's complaint.
func (t *Toolbox) git(ctx context.Context, mode policy.Mode, pol *policy.Compiled, timeout time.Duration, stdin, script string, args ...string) (string, error) {
	stdout := &limitWriter{max: maxGitOutput}
	stderr := &capWriter{}
	res, err := t.SB.Run(ctx, sandbox.Cmd{
		Mode:    mode,
		Argv:    append([]string{t.SB.Shell(), "-c", script, "Commit"}, args...),
		Stdin:   strings.NewReader(stdin),
		Stdout:  stdout,
		Stderr:  stderr,
		Timeout: timeout,
		Policy:  pol,
		Model:   t.Model,
	})
	// Past the limit, the copy fails, and git with it, however it says so.
	switch {
	case stdout.over:
		return "", fmt.Errorf("git printed over %d MB, too much to review", maxGitOutput>>20)
	case err != nil:
		return "", err
	case res.TimedOut:
		return "", fmt.Errorf("git timed out after %s", timeout)
	case res.ExitCode != 0:
		msg := strings.TrimSpace(stderr.buf.String())
		if msg == "" {
			msg = fmt.Sprintf("git failed (exit code %d)", res.ExitCode)
		}
		return "", errors.New(msg)
	}
	return stdout.buf.String(), nil
}

// subject is a commit message's first line, cut to fit a summary.
func subject(msg string) string {
	line, _, _ := strings.Cut(msg, "\n")
	return firstLine(line)
}