Files
mesh-catalog/modules/gitea/cmd/forge/commit.go
T
jschoubben 32655b95f6 GIT1, GIT2, GIT3, GIT10: the forge module serves the seat git's verbs and says its events, retires its acting tools, follows the catalog half
The four share files (index.ts, module.json, guard.test.ts), so they land as one commit.
GIT1: seat.ts serves pull, open-pulls, tags, trees, file, contains, statuses, status, comment, view, note, merge-pull
as tools on the seat, each answering the contract's shape and an error of kind refused, not-found or failed; a comment,
view or note whose act id is held is answered held (note matched by id). merge-pull merges the head the caller names
(its from-G judgements are GIT6, GIT7, GIT9).
GIT2: seatevents.ts says pull-updated, pull-merged, pull-closed, forge-pull, forge-status, forge-delete and repo-writable on
mesh.seat.git.event.<event>; the module's emits drop them.
GIT3: merge.ts, check.ts, the acting tools, the owed watch, the gate's source read and check-gates-merge are gone.
GIT10: catalog.ts follows the seat mesh-catalog (revision, catalog-revision); no write is granted before the first view.

Builds: design 47 G §310, §311, §312, §313, §314, §163, §317, §278, §321, §196

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 11:31:24 +01:00

594 lines
22 KiB
Go

package main
import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"unicode/utf8"
"gitea/internal/forgehttp"
)
// gitea_commit (novox/hq issue 375): one commit on a branch of its own, so an agent follows the branch / pull
// request / approval rule without anyone pushing by hand. By the rule gitea_api already enforces for a file
// written (novox/hq ADR 0266): never the repository's default branch, never a branch a protection rule names.
// Nothing about merging changes — a merge is the seat git's merge-pull, at the head the operator's Approve names.
// FileChange is one file of a commit: its new content, as text or base64, or its deletion.
type FileChange struct {
Path string
Content *string
ContentBase64 *string
Delete bool
CopyFrom *CopyFrom // copy_from (novox/hq issue 1365): the content of a file or directory of another repository
size int // the content's size in bytes, as committed
}
// CommitAsk is one gitea_commit call.
type CommitAsk struct {
Owner, Repo string
Branch string
Base string // the branch or commit a new branch starts from; the default branch when empty
Message string
Files []FileChange
Patch string // a unified diff, as git diff writes it, instead of Files
// PatchFile is the name of a file in the spool directory holding the patch (novox/hq issue 852), with the
// SHA-256 its caller says it has; commitAsk reads it into Patch.
PatchFile string
PatchSHA256 string
patchFileGiven bool
}
// Committed is what gitea_commit made.
type Committed struct {
Repository string `json:"repository"`
Branch string `json:"branch"`
Created bool `json:"created"`
Base string `json:"base,omitempty"`
Parent string `json:"parent"`
Commit string `json:"commit"`
Files []CommittedFile `json:"files,omitempty"`
Copies []CopiedFrom `json:"copies,omitempty"`
Said []string `json:"said"`
}
// CommittedFile is one file the commit changed, and how.
type CommittedFile struct {
Path string `json:"path"`
Operation string `json:"operation"`
}
// What one commit carries at most (novox/hq issue 539): files, one file's content, one patch, and every
// file's content together, in bytes as committed (content_base64 decoded). Larger work is several commits.
const (
maxFiles = 200
maxFileBytes = 4 << 20
maxPatchBytes = 4 << 20
maxCommitBytes = 16 << 20
)
// readCommitAsk reads the tool's arguments.
func readCommitAsk(args map[string]any) (CommitAsk, error) {
a := CommitAsk{Owner: text(args, "owner"), Repo: text(args, "repo"), Branch: text(args, "branch"),
Base: text(args, "base"), Message: strings.TrimSpace(raw(args, "message"))}
a.Patch = raw(args, "patch")
if v, ok := args["patch_file"]; ok && v != nil {
a.patchFileGiven = true
a.PatchFile = raw(args, "patch_file")
}
a.PatchSHA256 = strings.ToLower(strings.TrimSpace(raw(args, "patch_sha256")))
if a.Branch == "" {
return a, refuse("branch is required: the branch the commit goes on, a branch of its own (created from base when absent). Nothing was sent")
}
if a.Message == "" {
return a, refuse("message is required: the commit's message. Nothing was sent")
}
files, given := args["files"]
if given && files != nil {
list, ok := files.([]any)
if !ok {
return a, refuse("files is a list of {path, content | content_base64 | delete: true}. Nothing was sent")
}
for i, item := range list {
m, ok := item.(map[string]any)
if !ok {
return a, refuse("files[%d] is not an object {path, content | content_base64 | delete: true}. Nothing was sent", i)
}
fc := FileChange{Path: text(m, "path")}
if v, ok := m["content"]; ok && v != nil {
s, ok := v.(string)
if !ok {
return a, refuse("files[%d].content is text. Nothing was sent", i)
}
fc.Content = &s
}
if v, ok := m["content_base64"]; ok && v != nil {
s, ok := v.(string)
if !ok {
return a, refuse("files[%d].content_base64 is text. Nothing was sent", i)
}
fc.ContentBase64 = &s
}
if v, ok := m["delete"]; ok && v != nil {
b, ok := v.(bool)
if !ok {
return a, refuse("files[%d].delete is true or false. Nothing was sent", i)
}
fc.Delete = b
}
if v, ok := m["copy_from"]; ok && v != nil {
c, err := readCopyFrom(i, v)
if err != nil {
return a, err
}
fc.CopyFrom = c
if fc.Content != nil || fc.ContentBase64 != nil || fc.Delete {
return a, refuse("files[%d] (%s) gives copy_from together with content, content_base64 or delete: it takes exactly one of them. Nothing was sent", i, fc.Path)
}
}
a.Files = append(a.Files, fc)
}
}
sources := 0
for _, g := range []bool{len(a.Files) > 0, strings.TrimSpace(a.Patch) != "", a.patchFileGiven} {
if g {
sources++
}
}
switch {
case sources == 0:
return a, refuse("give files (each {path, content | content_base64 | delete: true}), patch (a unified diff, as git diff writes it) or patch_file (the name of a patch in the spool directory). Nothing was sent")
case sources > 1:
return a, refuse("give files, patch or patch_file, not more than one: one commit carries one of them. Nothing was sent")
case a.PatchSHA256 != "" && !a.patchFileGiven:
return a, refuse("patch_sha256 goes with patch_file only. Nothing was sent")
case a.patchFileGiven:
if err := checkPatchFileAsk(a); err != nil {
return a, err
}
case len(a.Files) > maxFiles:
return a, refuse("%d files is more than the %d one commit carries. Nothing was sent", len(a.Files), maxFiles)
case len(a.Patch) > maxPatchBytes:
return a, refuse("the patch is %s, more than the %s one commit carries: split it into several commits. Nothing was sent", mib(len(a.Patch)), mib(maxPatchBytes))
}
seen := map[string]bool{}
total := 0
for i, fc := range a.Files {
if strings.HasPrefix(fc.Path, "/") {
return a, refuse("files[%d].path %q is relative to the repository's root, without a leading slash. Nothing was sent", i, fc.Path)
}
if _, err := segments("file path", fc.Path); err != nil {
return a, err
}
if seen[fc.Path] {
return a, refuse("%s is named twice in files. Nothing was sent", fc.Path)
}
seen[fc.Path] = true
have := 0
for _, g := range []bool{fc.Content != nil, fc.ContentBase64 != nil, fc.Delete, fc.CopyFrom != nil} {
if g {
have++
}
}
if have != 1 {
return a, refuse("files[%d] (%s) needs exactly one of content, content_base64, delete: true or copy_from. Nothing was sent", i, fc.Path)
}
if fc.Content != nil && !utf8.ValidString(*fc.Content) {
return a, refuse("files[%d].content (%s) is not UTF-8 text: give it as content_base64. Nothing was sent", i, fc.Path)
}
size := 0
switch {
case fc.Content != nil:
size = len(*fc.Content)
case fc.ContentBase64 != nil:
if base64.StdEncoding.DecodedLen(len(*fc.ContentBase64)) > maxFileBytes+3 {
size = base64.StdEncoding.DecodedLen(len(*fc.ContentBase64))
break
}
raw, err := base64.StdEncoding.DecodeString(*fc.ContentBase64)
if err != nil {
return a, refuse("files[%d].content_base64 (%s) is not base64: %v. Nothing was sent", i, fc.Path, err)
}
size = len(raw)
}
if size > maxFileBytes {
return a, refuse("files[%d] (%s) is %s, more than the %s one file of a commit may be. Nothing was sent", i, fc.Path, mib(size), mib(maxFileBytes))
}
a.Files[i].size = size
total += size
}
if total > maxCommitBytes {
return a, refuse("the files are %s together, more than the %s one commit carries: split them into several commits. Nothing was sent", mib(total), mib(maxCommitBytes))
}
return a, nil
}
// mib says a size in bytes in MiB, to a tenth.
func mib(n int) string { return fmt.Sprintf("%.1f MiB", float64(n)/(1<<20)) }
// targetRefused says why a commit may not go on a branch: the default branch, or one a protection rule names.
func targetRefused(target, defaultBranch string, rules []Protection) error {
if target == defaultBranch {
return refuse("a commit on %s, the repository's default branch, is refused: a change reaches it by a pull request "+
"that passes its checks (novox/hq ADR 0266). Put it on a branch of its own. Nothing was sent", target)
}
var guarding []string
for _, r := range rules {
if n := r.name(); n != "" && ruleCovers(n, target) {
guarding = append(guarding, n)
}
}
if len(guarding) > 0 {
return refuse("a commit on %s is refused: the protection rule %s guards it (novox/hq ADR 0266). Put it on a "+
"branch of its own. Nothing was sent", target, strings.Join(guarding, ", "))
}
return nil
}
// commit puts one commit on the asked branch, after the judgement, and reads the branch back.
func commit(ctx context.Context, f *Forge, a CommitAsk) (*Committed, error) {
repo, err := repoPath(a.Owner, a.Repo)
if err != nil {
return nil, err
}
if _, err := segments("branch", a.Branch); err != nil {
return nil, err
}
var r Repo
if err := f.GetJSON(ctx, repo, &r); err != nil {
return nil, err
}
if r.DefaultBranch == "" {
return nil, fmt.Errorf("the forge did not say %s/%s's default branch, so whether %s is it cannot be judged. Nothing was sent", a.Owner, a.Repo, a.Branch)
}
// Read the rules, or refuse: a branch the rules could not be read for is never written (fails closed).
var rules []Protection
if err := f.GetJSON(ctx, repo+"/branch_protections", &rules); err != nil {
var status *forgehttp.StatusError
if !errors.As(err, &status) || status.Status != http.StatusForbidden {
return nil, fmt.Errorf("the protection rules of %s/%s could not be read, so the commit was not judged and nothing was sent: %w", a.Owner, a.Repo, err)
}
// The rules are a repository administrator's to read, and the tools' account is none (novox/hq ADR 0365
// §2). What it may read is the protection the forge applies to an existing branch: one protected is
// refused here. A branch not made yet is covered by no rule this account can see, and the forge itself
// judges the commit that makes it against every rule, refusing one a rule guards.
if err := targetRefused(a.Branch, r.DefaultBranch, nil); err != nil {
return nil, err
}
b, err := f.branch(ctx, repo, a.Branch)
if err != nil {
return nil, fmt.Errorf("whether a protection rule guards %s of %s/%s could not be read, so nothing was sent: %w", a.Branch, a.Owner, a.Repo, err)
}
if b != nil && b.Protected {
rule := b.Rule
if rule == "" {
rule = a.Branch
}
rules = []Protection{{RuleName: rule}}
if !ruleCovers(rule, a.Branch) {
return nil, refuse("a commit on %s is refused: the protection rule %s guards it (novox/hq ADR 0266). Put it on a "+
"branch of its own. Nothing was sent", a.Branch, rule)
}
}
}
if err := targetRefused(a.Branch, r.DefaultBranch, rules); err != nil {
return nil, err
}
out := &Committed{Repository: a.Owner + "/" + a.Repo, Branch: a.Branch}
existing, err := f.branch(ctx, repo, a.Branch)
if err != nil {
return nil, err
}
// The files are judged at the commit just read, never at a branch name that could move between two
// reads (novox/hq issue 539): a file the branch changes meanwhile is refused by the forge, whose update
// and delete carry the blob read here.
var ref string
if existing != nil {
out.Parent = existing.Commit.ID
ref = existing.Commit.ID
if a.Base != "" {
out.Said = append(out.Said, fmt.Sprintf("%s exists, at %s: base %s was not used", a.Branch, short(existing.Commit.ID), a.Base))
}
} else {
out.Created = true
out.Base = a.Base
if out.Base == "" {
out.Base = r.DefaultBranch
}
b, err := f.branch(ctx, repo, out.Base)
if err != nil {
return nil, err
}
if b == nil {
return nil, refuse("%s does not exist, and its base %s is not a branch of %s/%s. Nothing was sent", a.Branch, out.Base, a.Owner, a.Repo)
}
out.Parent = b.Commit.ID
ref = b.Commit.ID
}
// Where the forge takes the commit: on the branch, or on a new one from the base's commit.
on := map[string]any{"message": a.Message}
if out.Created {
on["branch"] = out.Base
on["new_branch"] = a.Branch
} else {
on["branch"] = a.Branch
}
// The forge takes no commit to build on, only a branch (Gitea 1.27: neither /contents nor /diffpatch
// carries a last commit), so the parent is read from what it made, never assumed (novox/hq issue 539).
var made struct {
Commit madeCommit `json:"commit"`
}
// A patch goes to the forge's diffpatch first. When the forge answers a server error (it does so, with an
// empty message, when an unchanged context line holds a non-ASCII character: novox/hq issue 1604) the tool
// applies the patch itself and commits the resulting files the way a files commit does.
files := a.Files
forgeApplied := false
if a.Patch != "" {
on["content"] = a.Patch
err := f.SendJSON(ctx, http.MethodPost, repo+"/diffpatch", on, &made)
var status *forgehttp.StatusError
switch {
case err == nil:
forgeApplied = true
case errors.As(err, &status) && status.Status >= 500:
delete(on, "content")
read := func(p string) ([]byte, string, bool, error) {
b, ok, err := f.contentAt(ctx, repo, p, ref)
if err != nil || !ok {
return nil, "", false, err
}
mode, err := f.modeAt(ctx, repo, ref, p)
return b, mode, true, err
}
var perr error
if files, perr = patchAsFiles(a.Patch, read); perr != nil {
return nil, fmt.Errorf("the forge did not take the patch: %w; the tool could not apply it itself: %v", err, perr)
}
total := 0
for _, fc := range files {
if fc.size > maxFileBytes {
return nil, refuse("%s would be %s, more than the %s one file of a commit may be. Nothing was sent", fc.Path, mib(fc.size), mib(maxFileBytes))
}
total += fc.size
}
if total > maxCommitBytes {
return nil, refuse("the patch's files are %s together, more than the %s one commit carries. Nothing was sent", mib(total), mib(maxCommitBytes))
}
out.Said = append(out.Said, fmt.Sprintf("the forge answered %d to the patch, so the tool applied it to the files at %s and committed them", status.Status, short(ref)))
default:
return nil, fmt.Errorf("the forge did not take the patch: %w", err)
}
}
if !forgeApplied {
for _, fc := range files {
if fc.CopyFrom != nil {
// Read, and judged against the limits, before anything is sent.
var err error
if files, out.Copies, err = expandCopies(ctx, f, a); err != nil {
return nil, err
}
break
}
}
var ops []map[string]any
for _, fc := range files {
sha, err := f.blobAt(ctx, repo, fc.Path, ref)
if err != nil {
return nil, err
}
op := map[string]any{"path": fc.Path}
switch {
case fc.Delete && sha == "":
return nil, refuse("%s is to be deleted, but %s has no such file at %s. Nothing was sent", fc.Path, a.Owner+"/"+a.Repo, short(ref))
case fc.Delete:
op["operation"], op["sha"] = "delete", sha
case sha == "":
op["operation"] = "create"
default:
op["operation"], op["sha"] = "update", sha
}
if !fc.Delete {
if fc.Content != nil {
op["content"] = base64.StdEncoding.EncodeToString([]byte(*fc.Content))
} else {
op["content"] = *fc.ContentBase64
}
}
ops = append(ops, op)
out.Files = append(out.Files, CommittedFile{Path: fc.Path, Operation: op["operation"].(string)})
}
on["files"] = ops
if err := f.SendJSON(ctx, http.MethodPost, repo+"/contents", on, &made); err != nil {
return nil, fmt.Errorf("the forge did not take the commit: %w", err)
}
}
if made.Commit.SHA == "" {
return nil, fmt.Errorf("the forge answered the commit on %s without saying the commit it made: read the branch (gitea_list_branches) before trying again", a.Branch)
}
out.Commit = made.Commit.SHA
read := out.Parent
parent, err := f.parentOf(ctx, repo, made.Commit)
if err != nil {
return nil, fmt.Errorf("the forge made %s on %s, but which commit it went on could not be read: %w", out.Commit, a.Branch, err)
}
out.Parent = parent
if parent != read {
moved := "branch " + a.Branch
if out.Created {
moved = "base " + out.Base
}
how := "each file it changes was checked against " + short(read) + " by its blob, so none of them changed in between"
if forgeApplied {
how = "the forge applied the patch there, and it refuses a patch whose context changed"
}
out.Said = append(out.Said, fmt.Sprintf("the %s moved from %s to %s between the read and the commit, so the "+
"commit went on %s: %s. What else changed in between: gitea_compare %s...%s",
moved, short(read), short(parent), short(parent), how, short(read), short(parent)))
}
// Evidence over assertion: the branch is read back, and it must be at the commit the forge said.
after, err := f.branch(ctx, repo, a.Branch)
switch {
case err != nil:
return nil, fmt.Errorf("the forge made %s on %s, but the branch could not be read back: %w", out.Commit, a.Branch, err)
case after == nil:
return nil, fmt.Errorf("the forge said it made %s on %s, but the branch does not exist", out.Commit, a.Branch)
case after.Commit.ID != out.Commit:
return nil, fmt.Errorf("the forge said it made %s on %s, but the branch is at %s: something else moved it", out.Commit, a.Branch, after.Commit.ID)
}
what := "on"
if out.Created {
what = "on the new branch"
}
out.Said = append(out.Said, fmt.Sprintf("%s committed %s %s of %s, after %s; the branch is there now. A merge "+
"still needs a review at this head and both required checks green", short(out.Commit), what, a.Branch, out.Repository, short(out.Parent)))
return out, nil
}
// madeCommit is the commit the forge answers a write with.
type madeCommit struct {
SHA string `json:"sha"`
Parents []struct {
SHA string `json:"sha"`
} `json:"parents"`
}
// parentOf is the commit the forge made its commit on: from its answer, or read from the commit when the
// answer does not say it.
func (f *Forge) parentOf(ctx context.Context, repo string, made madeCommit) (string, error) {
if len(made.Parents) == 0 {
var read madeCommit
if err := f.GetJSON(ctx, repo+"/git/commits/"+url.PathEscape(made.SHA)+"?stat=false&verification=false&files=false", &read); err != nil {
return "", err
}
made = read
}
if len(made.Parents) != 1 || made.Parents[0].SHA == "" {
return "", fmt.Errorf("the forge says %s has %d parents, not one", made.SHA, len(made.Parents))
}
return made.Parents[0].SHA, nil
}
// blobAt is the blob sha of a file at a ref, "" when there is no file there.
func (f *Forge) blobAt(ctx context.Context, repo, path, ref string) (string, error) {
seg, err := segments("file path", path)
if err != nil {
return "", err
}
var entry struct {
Type string `json:"type"`
SHA string `json:"sha"`
}
var list []any
body, err := f.Do(ctx, http.MethodGet, repo+"/contents/"+seg+"?ref="+queryEscape(ref), nil, maxBody)
if err != nil {
if errors.Is(err, errNotFound) {
return "", nil
}
return "", err
}
if jsonInto(body, &list) == nil {
return "", refuse("%s is a directory, not a file. Nothing was sent", path)
}
if err := jsonInto(body, &entry); err != nil {
return "", fmt.Errorf("the forge's answer about %s is not readable: %w", path, err)
}
if entry.Type != "file" && entry.Type != "symlink" {
return "", refuse("%s is a %s, not a file. Nothing was sent", path, entry.Type)
}
return entry.SHA, nil
}
func short(sha string) string {
if len(sha) > 12 {
return sha[:12]
}
return sha
}
// contentAt is the content of a file at a ref; ok is false when there is no file there.
func (f *Forge) contentAt(ctx context.Context, repo, path, ref string) ([]byte, bool, error) {
seg, err := segments("file path", path)
if err != nil {
return nil, false, err
}
body, err := f.Do(ctx, http.MethodGet, repo+"/contents/"+seg+"?ref="+queryEscape(ref), nil, maxBody)
if err != nil {
if errors.Is(err, errNotFound) {
return nil, false, nil
}
return nil, false, err
}
var entry struct {
Type string `json:"type"`
Size int `json:"size"`
Content string `json:"content"`
Encoding string `json:"encoding"`
}
if err := jsonInto(body, &entry); err != nil {
return nil, false, fmt.Errorf("the forge's answer about %s is not readable: %w", path, err)
}
if entry.Type != "file" {
return nil, false, refuse("%s is a %s, not a file. Nothing was sent", path, entry.Type)
}
if entry.Encoding == "base64" && (entry.Content != "" || entry.Size == 0) {
raw, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(entry.Content, "\n", ""))
if err != nil {
return nil, false, fmt.Errorf("the content of %s is not base64: %w", path, err)
}
return raw, true, nil
}
raw, err := f.Do(ctx, http.MethodGet, repo+"/raw/"+seg+"?ref="+queryEscape(ref), nil, maxFileBytes+1)
if err != nil {
return nil, false, err
}
return raw, true, nil
}
// modeAt is the git mode of a file at a commit, read through the git trees API: the contents API carries none.
func (f *Forge) modeAt(ctx context.Context, repo, ref, path string) (string, error) {
parts := strings.Split(path, "/")
tree := ref
for depth, name := range parts {
found := false
for page := 1; ; page++ {
var got struct {
Tree []struct {
Path string `json:"path"`
Mode string `json:"mode"`
Type string `json:"type"`
SHA string `json:"sha"`
} `json:"tree"`
Total int `json:"total_count"`
}
if err := f.GetJSON(ctx, fmt.Sprintf("%s/git/trees/%s?per_page=1000&page=%d", repo, url.PathEscape(tree), page), &got); err != nil {
return "", fmt.Errorf("the mode of %s could not be read: %w", path, err)
}
for _, e := range got.Tree {
if e.Path != name {
continue
}
if depth == len(parts)-1 {
return e.Mode, nil
}
tree, found = e.SHA, true
break
}
if found || len(got.Tree) == 0 || page*1000 >= got.Total {
break
}
}
if !found && depth < len(parts)-1 {
return "", fmt.Errorf("the mode of %s could not be read: %s is not a folder at %s", path, name, short(ref))
}
}
return "", fmt.Errorf("the mode of %s could not be read: not in the tree of %s", path, short(ref))
}