From 4d658004ae84c2ca2b393cde9197353b24fd5481 Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 1 Jul 2026 23:11:49 +0200 Subject: [PATCH] fix(create_project): handle gitea generate-async branch race (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live e2e surfaced a race unit tests couldn't (mocks are instant): gitea's /generate returns and serves reads before the branch ref is writable, so the first content writes 404 "branch does not exist" for a beat — aborting the whole substitution pass. Add waitForBranch (poll BranchExists after generate) + upsertRetry (retry writes on the transient not-found). Test fake now serves the branch readiness probe. Refs #42. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tools/create_project_from_template.go | 47 ++++++++++++++++++- .../create_project_from_template_test.go | 4 ++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/internal/tools/create_project_from_template.go b/internal/tools/create_project_from_template.go index e934ef8..ad94569 100644 --- a/internal/tools/create_project_from_template.go +++ b/internal/tools/create_project_from_template.go @@ -8,6 +8,7 @@ import ( "fmt" "regexp" "strings" + "time" "gitea.d-ma.be/mathias/gitea-mcp/internal/allowlist" "gitea.d-ma.be/mathias/gitea-mcp/internal/gitea" @@ -150,6 +151,12 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag } result.DefaultBranch = branch + // Wait for the generated branch to become writable. gitea's /generate is + // async: it returns (and serves reads) before the branch ref is committed, so + // the first writes 404 "branch does not exist" for a beat. Poll until the + // branch is present. + t.waitForBranch(ctx, args.Owner, args.Name, branch) + // Substitute across the WHOLE tree: content in every blob, plus a path rename // for any file whose path carries a placeholder (e.g. cmd/__PROJECT_NAME__/main.go). // A fixed known-files list can't rename directories or cover every templated @@ -185,6 +192,42 @@ func (t *CreateProjectFromTemplate) Call(ctx context.Context, raw json.RawMessag return textOK(result) } +// waitForBranch polls until the freshly-generated branch is present, or a short +// budget elapses. Best-effort — upsertRetry covers the remaining read-vs-write +// consistency window where the branch reads but does not yet write. +func (t *CreateProjectFromTemplate) waitForBranch(ctx context.Context, owner, name, branch string) { + for i := 0; i < 20; i++ { + if ok, err := t.c.BranchExists(ctx, owner, name, branch); ok && err == nil { + return + } + select { + case <-ctx.Done(): + return + case <-time.After(500 * time.Millisecond): + } + } +} + +// upsertRetry retries UpsertFile through the brief post-generate window where the +// branch is readable but writes still 404 "branch does not exist". +func (t *CreateProjectFromTemplate) upsertRetry(ctx context.Context, owner, name, path string, args gitea.UpsertFileArgs) error { + var err error + for i := 0; i < 5; i++ { + if _, err = t.c.UpsertFile(ctx, owner, name, path, args); err == nil { + return nil + } + if !errors.Is(err, gitea.ErrNotFound) { + return err + } + select { + case <-ctx.Done(): + return err + case <-time.After(500 * time.Millisecond): + } + } + return err +} + // substituteEntry substitutes placeholders in one blob. If the path carries a // placeholder it renames the file (write new + delete old); otherwise it rewrites // content in place when changed. Returns a human-readable description of what was @@ -212,7 +255,7 @@ func (t *CreateProjectFromTemplate) substituteEntry(ctx context.Context, owner, enc := base64.StdEncoding.EncodeToString([]byte(newContent)) if renamed { - if _, err := t.c.UpsertFile(ctx, owner, name, newPath, gitea.UpsertFileArgs{ + if err := t.upsertRetry(ctx, owner, name, newPath, gitea.UpsertFileArgs{ Branch: branch, Content: enc, Message: fmt.Sprintf("template: substitute + rename %s -> %s", path, newPath), @@ -229,7 +272,7 @@ func (t *CreateProjectFromTemplate) substituteEntry(ctx context.Context, owner, return path + " -> " + newPath, "" } - if _, err := t.c.UpsertFile(ctx, owner, name, path, gitea.UpsertFileArgs{ + if err := t.upsertRetry(ctx, owner, name, path, gitea.UpsertFileArgs{ Branch: branch, Content: enc, Message: "template: substitute placeholders", diff --git a/internal/tools/create_project_from_template_test.go b/internal/tools/create_project_from_template_test.go index a91f744..4cc81f8 100644 --- a/internal/tools/create_project_from_template_test.go +++ b/internal/tools/create_project_from_template_test.go @@ -69,6 +69,10 @@ func (f *fakeTemplateServer) handler(t *testing.T, tmpl, dest string) http.Handl _, _ = fmt.Fprintf(w, `{"name":%q,"full_name":"mathias/%s","default_branch":%q,"clone_url":"http://gitea.example.com/mathias/%s.git","html_url":"http://gitea.example.com/mathias/%s","template":false}`, dest, dest, f.genBranch, dest, dest) + case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/branches/"): + // waitForBranch readiness probe — branch is present. + _, _ = fmt.Fprint(w, `{"name":"main","commit":{"id":"c"}}`) + case r.Method == http.MethodGet && strings.HasPrefix(p, "/api/v1/repos/mathias/"+dest+"/git/trees/"): var entries []string for path := range f.files {