Forgejo: ensure the agent resolves PR conflicts against the base branch #63
Labels
No labels
Agent
Ignore
Agent
In Progress
Agent
Planned
Agent
Planning
Agent
Ready
Agent
Triage
Compat
Breaking
Kind
Bug
Kind
Documentation
Kind
Enhancement
Kind
Feature
Kind
Security
Kind
Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
lucas/omp-deck#63
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Agents working on Forgejo pull requests (implementer sessions, review-submitted follow-ups) never check whether their PR conflicts with the base branch, so PRs sit unmergeable. Add steering, and change tools, so the agent always notices and resolves conflicts against the base branch.
Decisions:
origin/<base>withgit_rebase, thengit_push force_after_rebase(no merge commits; no new merge capability).git merge-treeagainst a freshly fetched base is the dedicated, deterministic check; Forgejo'smergeableflag is used where a Forgejo tool already has it.Plan
Context
Tools that touch a PR branch report conflicts with the PR's base:
git_push(after a successful push to a branch that has an open PR),forgejo_create_pr(after creating the PR; never refuses),forgejo_read_pr(lists conflicted files when Forgejo saysmergeable: no). The implementer and review-submitted seeds tell the agent to check and resolve conflicts by rebasing.git_push's non-fast-forward message stops telling the agent the branches "need a human".Approach
1. Shared conflict check helper
New file
apps/server/src/bridge/git-tools/base-conflict.ts(no equivalent exists;rebase.ts#conflictedFilesonly reads an in-progress rebase's index):Implementation:
prepareDeckSsh()from./ssh.ts; on!okreturn{ ok: false, error }.runGit(root, ["fetch", "--no-tags", "--no-write-fetch-head", remote, ...refspecs], { timeoutMs: NETWORK_TIMEOUT_MS, signal, env: prepared.ssh?.env }); non-zero →{ ok: false, error: withHostKeyHint(gitErrorText(res, "git fetch failed"), prepared.ssh) }. Copy the pattern fromfetch.tslines 90–96.runGit(root, ["merge-tree", "--write-tree", "--name-only", "--no-messages", "-z", base, head], { signal }). Exit 0 →{ ok: true, files: [] }. Exit 1 → split stdout on\0, drop the first entry (the tree oid) and empties, dedupe preserving order →{ ok: true, files }. Any other exit →{ ok: false, error: gitErrorText(res, "git merge-tree failed") }. (Requires git ≥ 2.38; dev box has 2.47.)Also export from the same file the shared next-step text:
2.
git_pushreports base conflicts for the branch's open PRapps/server/src/bridge/git-tools/push.ts: change signature tobuildPushTool(cwd: string, forgejo: ForgejoToolContext | null); update the caller ingit-tools/index.ts(buildPushTool(cwd, forgejo)) and every test call inrebase.test.ts(buildPushTool(repo)→buildPushTool(repo, null); grepbuildPushTool(to find all).clearMarker, beforeok(result)), whenforgejo !== null && remote === "origin":fetchAllPages<ApiPull>(forgejo.client, repoApiPath(forgejo.owner, forgejo.repo, "pulls"), { query: { state: "open" }, signal })(imports from../../forgejo/tools/shared.tsand the module that exportsrepoApiPath— the same oneprs.tsimports it from).head.ref === branchandhead.repo?.full_name === \${owner}/${repo}`` (fork PRs skipped). None → no check, no new fields.checkBaseConflict(root, { remote, refspecs: [\+refs/heads/${base}:refs/remotes/${remote}/${base}`], base: `refs/remotes/${remote}/${base}`, head: "HEAD", signal })withbase = pr.base.ref`.GitPushResultinpackages/protocol/src/git-tools.ts): SetbaseConflictonly whenfiles.length > 0(next = rebaseInstruction(remote, base)). PR listing failure (!ok, use the API error message) or helper{ ok: false }→ setbaseConflictCheckError; the push still returnsok(...)(neverfail)."After pushing a branch that has an open PR, reports files that conflict with the PR's base (baseConflict)."push rejected: ${remote}/${branch} has commits you do not have locally. Run git_fetch with fast_forward:true; if the branches have diverged, git_rebase action:"start" onto:"${remote}/${branch}", then git_push.\n${out}and change the description sentence
"If the remote rejects it as non-fast-forward, call git_fetch with fast_forward:true first."to"If the remote rejects it as non-fast-forward, call git_fetch with fast_forward:true, or git_rebase onto the remote branch if they diverged."apps/web/src/components/tools/git/GitPushView.tsx: whenr.baseConflictis present, render a warning lineconflicts with <base> (PR #<pr>): <files joined ", ">; whenr.baseConflictCheckErroris present, render it muted. Follow the existingTag/row styling in that file. Add abaseConflictexample to thepushfixture inapps/web/src/preview-git.tsonly if that file has multiple variants per tool; otherwise leave it.3.
forgejo_create_prwarns after creating a conflicting PRIn
apps/server/src/forgejo/tools/prs.tscreatePr.run, afterres.okand only when!head.includes(":"):resolveRepo(ctx.cwd, signal)(frombridge/git-tools/run.ts); null → skip check.checkBaseConflict(root, { remote: "origin", refspecs: [\+refs/heads/${base}:refs/remotes/origin/${base}`, `+refs/heads/${head}:refs/remotes/origin/${head}`], base: `refs/remotes/origin/${base}`, head: `refs/remotes/origin/${head}`, signal })`.\n\nWARNING: ${head} conflicts with ${base} in: ${files.join(", ")}. ${rebaseInstruction("origin", base)}and setbaseConflictFiles: fileson the details. Check failure → append\n\n(could not check for conflicts with ${base}: ${error}). Never turn the result into an error: the PR exists.ForgejoCreatePrResultinpackages/protocol/src/forgejo-tools.ts: addbaseConflictFiles?: string[].CreatePrView.tsx: show a warning row listing them when present."After creating it, warns if the head conflicts with the base."4.
forgejo_read_prlists conflicted files when unmergeableIn
readPr.run(prs.ts ~193): whenpr.data.mergeable === false && pr.data.head?.sha && pr.data.base?.refand the head is in this repo (head.repo?.full_name === \${ctx.owner}/${ctx.repo}``):resolveRepo(ctx.cwd); null → skip.checkBaseConflict(root, { remote: "origin", refspecs: [\+refs/heads/${base}:refs/remotes/origin/${base}`, `refs/pull/${n}/head`], base: `refs/remotes/origin/${base}`, head: pr.data.head.sha, signal }). (--no-write-fetch-head` is fine: the object is fetched into the odb and referenced by sha.)formatPrvia a new optional second parameterformatPr(pr: ApiPull, conflict?: { files: string[] } | { error: string }), which changes themergeable:line tomergeable: no — conflicts with <base> in: a.ts, b.tsormergeable: no (conflict check failed: <error>). OtherformatPrcallers unchanged. If the local check returns zero files (Forgejo stale), printmergeable: no (Forgejo); a local merge of the current heads is clean.mergeable/merged/draftwithmergeable/merged/draft (when not mergeable, the conflicting files).5. Seed steering
apps/server/src/forgejo/conversation-detect.ts:buildImplementerSeed: append a paragraph:Before you finish: fetch the base branch and make sure your PR does not conflict with it (git_push and forgejo_create_pr report conflicts). If it does, rebase onto origin/<base> with git_rebase, resolve the conflicts, and git_push force_after_rebase:true.with<base>=t.baseBranch ?? "the default branch".buildReviewSubmittedSeedTask line: after...Address the feedback: change the code, commit, push.insertThen check the PR is not in conflict with ${pr?.base?.ref ?? "its base"} (forgejo_read_pr lists conflicting files; git_push reports them); if it is, rebase onto origin/<base> with git_rebase and git_push force_after_rebase:true.Replace the final sentence withIf the verdict is approved and there is no feedback, post no comment; push only if you had to resolve conflicts with the base.Critical files & anchors
apps/server/src/bridge/git-tools/push.ts—buildPushToolexecute tail (lines ~78–97) and non-ff message.apps/server/src/forgejo/tools/prs.ts—readPr(~186),createPr(~323).apps/server/src/forgejo/tools/format.ts—formatPrmergeable line (~84).apps/server/src/forgejo/conversation-detect.ts—buildImplementerSeed(~214),buildReviewSubmittedSeedtask line (~263).packages/protocol/src/git-tools.ts/forgejo-tools.ts— result types.Verification
Run from
/workspace/omp-deckwithbun test <path>.apps/server/src/bridge/git-tools/base-conflict.test.ts(temp repos as inrebase.test.ts: a bare remote + clone): main and feature both edita.txtdifferently, feature pushed →checkBaseConflictreturns{ ok: true, files: ["a.txt"] }; non-overlapping edits →files: []; nonexistent refspec →ok: false. Assert the clone's HEAD, index and working tree are unchanged after the call.apps/server/src/bridge/git-tools/rebase.test.ts: existing push tests pass withbuildPushTool(repo, null); add one case asserting the non-ff rejection text containsgit_rebase action:"start" onto:"origin/<branch>".apps/server/src/forgejo/conversation-detect.test.ts: implementer seed containsrebase onto origin/mainforbaseBranch: "main"; review-submitted seed containspush only if you had to resolve conflicts.apps/server/src/forgejo/tools/format.test.ts:formatPr({ ...pr, mergeable: false }, { files: ["a.ts"] })containsmergeable: no — conflicts with main in: a.ts; existingmergeable: noassertion still matches.mainalso changed,git_push→ result JSON hasbaseConflict.fileswith that path;forgejo_create_pr→ text containsWARNING: ... conflicts with main;forgejo_read_pr→mergeable: no — conflicts with main in: .... Thengit_rebaseontoorigin/main, resolve,git_push force_after_rebase:true→ nobaseConflict.bun run typecheck(or the repo's equivalent script in rootpackage.json) passes for server, protocol and web.Assumptions & contingencies
refs/pull/<n>/headover git fetch (Gitea-compatible). If the fetch fails in the manual check, fall back in step 4 to fetching+refs/heads/<head.ref>:refs/remotes/origin/<head.ref>and using that ref ashead.head.repois absent in this Forgejo's payload, match onhead.ref === branchalone.