Fall back to the tool's own patch applier when the forge answers 500, with tests (issue 1604)

This commit is contained in:
2026-10-21 09:21:59 +00:00
parent e6053262e4
commit e5cfcb316a
3 changed files with 211 additions and 6 deletions
+132
View File
@@ -0,0 +1,132 @@
package main
import (
"context"
"strings"
"testing"
)
// novox/hq issue 1604: the forge's diffpatch answers 500 when an unchanged context line holds a non-ASCII
// character; the tool then applies the patch itself.
func sectionFake(t *testing.T) *fakeForge {
f := newFake(t)
f.trees["c0"]["doc.md"] = "# Title\n\nSee § 4 for the rule.\nold line\nend § here\n"
f.trees["c0"]["run.sh"] = "#!/bin/sh\necho a\n"
f.patchStatus = 500
return f
}
const sectionPatch = "diff --git a/doc.md b/doc.md\n--- a/doc.md\n+++ b/doc.md\n@@ -2,4 +2,4 @@\n \n See § 4 for the rule.\n-old line\n+new line\n end § here\n"
func TestAPatchWhoseContextHoldsANonASCIICharacterIsAppliedByTheToolWhenTheForgeAnswers500(t *testing.T) {
f := sectionFake(t)
got, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/1604", "patch": sectionPatch}))
if err != nil {
t.Fatal(err)
}
want := "# Title\n\nSee § 4 for the rule.\nnew line\nend § here\n"
if tree := f.trees[got.Commit]; tree["doc.md"] != want || tree["README.md"] != "hello\n" {
t.Fatalf("tree %v", tree)
}
if len(f.patches) != 1 || len(f.writes) != 1 || f.writes[0]["new_branch"] != "fix/1604" || f.writes[0]["branch"] != "main" {
t.Fatalf("patches %v writes %v", f.patches, f.writes)
}
if len(got.Files) != 1 || got.Files[0].Operation != "update" || got.Parent != "c0" {
t.Fatalf("%+v", got)
}
if !strings.Contains(strings.Join(got.Said, " "), "applied it") {
t.Errorf("not said: %v", got.Said)
}
}
func TestAPatchTheForgeTakesIsStillTheForgesToApply(t *testing.T) {
f := sectionFake(t)
f.patchStatus = 0
if _, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/1604", "patch": sectionPatch})); err != nil {
t.Fatal(err)
}
if len(f.writes) != 0 {
t.Fatal("the tool applied a patch the forge took")
}
}
func TestAPatchThatCreatesDeletesAndRenamesIsApplied(t *testing.T) {
f := sectionFake(t)
patch := "diff --git a/new.txt b/new.txt\nnew file mode 100644\n--- /dev/null\n+++ b/new.txt\n@@ -0,0 +1,2 @@\n+one §\n+two\n" +
"diff --git a/docs/a.md b/docs/a.md\ndeleted file mode 100644\n--- a/docs/a.md\n+++ /dev/null\n@@ -1 +0,0 @@\n-a\n" +
"diff --git a/README.md b/README.txt\nsimilarity index 80%\nrename from README.md\nrename to README.txt\n--- a/README.md\n+++ b/README.txt\n@@ -1 +1 @@\n-hello\n+hello §\n"
got, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/r", "patch": patch}))
if err != nil {
t.Fatal(err)
}
tree := f.trees[got.Commit]
if tree["new.txt"] != "one §\ntwo\n" || tree["README.txt"] != "hello §\n" {
t.Fatalf("tree %v", tree)
}
if _, has := tree["README.md"]; has {
t.Error("the renamed file is still there")
}
if _, has := tree["docs/a.md"]; has {
t.Error("the deleted file is still there")
}
}
func TestAHunkMovedByEarlierLinesIsFoundAndNoNewlineIsKept(t *testing.T) {
f := sectionFake(t)
f.trees["c0"]["x.txt"] = "pre\npre\nabc §\nd\ne"
patch := "--- a/x.txt\n+++ b/x.txt\n@@ -1,3 +1,3 @@\n abc §\n d\n-e\n\\ No newline at end of file\n+f\n\\ No newline at end of file\n"
got, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/n", "patch": patch}))
if err != nil {
t.Fatal(err)
}
if g := f.trees[got.Commit]["x.txt"]; g != "pre\npre\nabc §\nd\nf" {
t.Fatalf("%q", g)
}
}
func TestAPatchThatDoesNotApplyIsRefusedNamingTheFileAndLine(t *testing.T) {
f := sectionFake(t)
patch := strings.Replace(sectionPatch, "-old line", "-other line", 1)
_, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/1604", "patch": patch}))
if err == nil || !strings.Contains(err.Error(), "doc.md") || !strings.Contains(err.Error(), "at line 2 does not apply") {
t.Fatalf("%v", err)
}
if len(f.writes) != 0 {
t.Fatal("something was written")
}
}
func TestAPatchWithAnExecutableFileIsNotAppliedByTheToolAndSaysWhy(t *testing.T) {
f := sectionFake(t)
for _, patch := range []string{
"diff --git a/s.sh b/s.sh\nnew file mode 100755\n--- /dev/null\n+++ b/s.sh\n@@ -0,0 +1 @@\n+echo §\n",
"diff --git a/run.sh b/run.sh\nold mode 100644\nnew mode 100755\n",
"diff --git a/run.sh b/run.sh\nindex 1111111..2222222 100755\n--- a/run.sh\n+++ b/run.sh\n@@ -1,2 +1,2 @@\n #!/bin/sh\n-echo a\n+echo b\n",
} {
_, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/x", "patch": patch}))
if err == nil || !strings.Contains(err.Error(), "the forge did not take the patch") || !strings.Contains(err.Error(), "carries no file mode") {
t.Errorf("%v", err)
}
}
if len(f.writes) != 0 {
t.Fatal("something was written")
}
}
func TestAClientErrorOfTheForgeIsNotRetriedByTheTool(t *testing.T) {
f := sectionFake(t)
f.patchStatus = 422
_, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "fix/1604", "patch": sectionPatch}))
if err == nil || len(f.writes) != 0 {
t.Fatalf("%v", err)
}
}
func TestADefaultBranchIsStillRefusedForAPatch(t *testing.T) {
f := sectionFake(t)
_, err := commit(context.Background(), f.serve(), ask(t, map[string]any{"branch": "main", "patch": sectionPatch}))
if err == nil || !strings.Contains(err.Error(), "ADR 0266") || len(f.patches)+len(f.writes) != 0 {
t.Fatalf("%v", err)
}
}
+72 -5
View File
@@ -326,14 +326,42 @@ func commit(ctx context.Context, f *Forge, a CommitAsk) (*Committed, error) {
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
if err := f.SendJSON(ctx, http.MethodPost, repo+"/diffpatch", on, &made); err != nil {
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, bool, error) { return f.contentAt(ctx, repo, p, ref) }
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)
}
} else {
files := a.Files
for _, fc := range a.Files {
}
if !forgeApplied {
for _, fc := range files {
if fc.CopyFrom != nil {
// Read, and judged against the limits, before anything is sent.
var err error
@@ -391,7 +419,7 @@ func commit(ctx context.Context, f *Forge, a CommitAsk) (*Committed, error) {
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 a.Patch != "" {
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 "+
@@ -478,3 +506,42 @@ func short(sha string) string {
}
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
}
+7 -1
View File
@@ -46,6 +46,7 @@ type fakeForge struct {
parentsBroken bool // and GET git/commits fails
parents map[string]string // commit -> parent, as GET git/commits says it
sources map[string]*sourceRepo
patchStatus int // the status diffpatch answers instead of taking the patch (issue 1604)
}
type move struct {
@@ -128,7 +129,8 @@ func (f *fakeForge) handle(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
reply(map[string]any{"type": "file", "path": p, "sha": blob(content)})
reply(map[string]any{"type": "file", "path": p, "sha": blob(content), "size": len(content), "encoding": "base64",
"content": base64.StdEncoding.EncodeToString([]byte(content))})
case r.Method == "POST" && path == repo+"/contents":
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
@@ -176,6 +178,10 @@ func (f *fakeForge) handle(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
f.patches = append(f.patches, body)
if f.patchStatus != 0 {
http.Error(w, `{"message":""}`, f.patchStatus)
return
}
f.next++
made := fmt.Sprintf("p%d", f.next)
from := f.branches[body["branch"].(string)]