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)
}
|