Add a guarded git_rebase tool (rebase, conflict continue, interactive reorder/reword) #40
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#40
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?
Implementation: #41
Problem
Agents need a rebase tool with limited capabilities, mainly to rebase a feature branch onto an updated main, resolving conflicts when they arise. Raw
git rebasein bash is denied by the git bash rule, so today an agent cannot do this at all.Requirements (original brief, refined):
git_show/git_diffas needed.--continueworkflow so the agent can fix conflicts.local://storage that the agent edits; a second call applies it. The plan exists only while an interactive rebase has been started and not finalized.git_push,git_branch(create/switch/delete),git_fetchwithfast_forward,forgejo_create_pr,forgejo_update_pr.git_commitstays allowed (useful e.g. to split work while stopped).pick(with reordering) andreword(full new message supplied in the plan). No drop, squash, fixup, edit, exec, break, label/reset/merge, no skip, no adding commits.git_pushmay force-push that branch once, with a lease on the remote sha recorded at rebase start, to the same-named branch only.Plan
Context
Add a
git_rebasecustom tool to the deck's first-class git tools (apps/server/src/bridge/git-tools/), a turn-end guard and a tool gate that keep an open rebase from being abandoned or tampered with, and a lease-guardedforce_after_rebaseoption ongit_push. End state: an agent can rebase its branch onto a new base, resolve conflicts, optionally reorder/reword its own commits, and push the result, without any raw git.Approach
1. Extract shared identity/signing preparation (refactor, no behavior change)
apps/server/src/bridge/git-tools/identity.tsexporting:export interface CommitEnvConfig { signingKey?: string; userName?: string; userEmail?: string; gpgPrivateKey?: string; gnupgHome?: string }export async function prepareCommitEnv(root: string, cfg: CommitEnvConfig, signal?: AbortSignal): Promise<{ args: string[]; env: Record<string, string> } | { error: string }>buildCommitToolincommit.ts: the both-or-neither name/email check,identityArgs, thegit config user.namecheck, the GNUPGHOME resolution +mkdirSync(..., { mode: 0o700 }), the GPG import (moverunGpgfromcommit.tstoo), andcommitSignArgs.args=[...identityArgs, ...commitSignArgs];env={ GNUPGHOME: effectiveGnupgHome }. Error strings stay byte-identical.buildCommitToolsignature becomesbuildCommitTool(cwd: string, cfg: CommitEnvConfig); it callsprepareCommitEnvwhere the inline code was (still before any index mutation) and passesargs/envto the commitrunGit. Update the only caller,git-tools/index.ts, to build oneCommitEnvConfigfrom the existingOMP_DECK_*env reads and pass it to both commit and rebase tools.coAuthorTrailerstays commit-only; rebase never adds trailers.2. Repo-state helpers (
apps/server/src/bridge/git-tools/rebase-state.ts, new)No equivalent exists in
repo.ts. Exports:gitPath(root, name, signal): Promise<string>→ absolute path fromgit rev-parse --git-path <name>(resolved againstroot), so worktrees work.rebaseInProgress(root, signal): Promise<boolean>→ true ifgitPath("rebase-merge")orgitPath("rebase-apply")exists.gitPath("omp-deck/rebase-plan.json"):{ planUrl: "local://rebase-plan.md", planPath: string /* absolute */, branch: string, onto: string /* full sha */, base: string /* merge-base sha */, commits: string[] /* full shas, original order */, sessionId: string | null, createdAt: string }. FunctionsreadPlanMarker,writePlanMarker,clearPlanMarker(clear also deletesplanPathif present, ignoring ENOENT).gitPath("omp-deck/force-push.json"):{ branch: string, remote: string, remoteSha: string | null, completedAt: string }. FunctionsreadForceMarker,writeForceMarker,clearForceMarker. A rebase-start recordgitPath("omp-deck/rebase-start.json")with the same shape minuscompletedAtplusorigHead: stringis written at rebase start and promoted to the force marker on successful completion (cleared on abort).rebaseOpen(root, signal): Promise<{ open: false } | { open: true; kind: "stopped" | "plan-pending" }>= rebaseInProgress →stopped; else plan marker present →plan-pending.defaultBranches(root, remote, signal): Promise<Set<string>>→ short name fromgit symbolic-ref --short refs/remotes/<remote>/HEAD(strip<remote>/); if that fails,new Set(["main", "master"]).All markers are JSON written with
fs.promises.writeFileaftermkdir(dirname, { recursive: true }); a corrupt/unparseable marker is treated as absent for reads butrebaseOpentreats an unparseable plan marker asplan-pending(fail closed) andgit_rebase abortclears it.3.
git_rebasetool (apps/server/src/bridge/git-tools/rebase.ts, new; registered inindex.tsafterbuildPushTool)buildRebaseTool(cwd: string, cfg: CommitEnvConfig): CustomTool. Followpush.tsstructure (guarded("git_rebase", …),resolveRepo,fail/ok). Schema (zod, like other tools):Common preconditions for
startandplan: repo resolved;currentBranchnon-null (elserefusing to rebase: HEAD is detached); branch not indefaultBranches(root, upstreamRemote ?? "origin")(elserefusing to rebase the default branch <b>);rebaseOpenis{open:false}(elsea rebase is already open (<kind>); use git_rebase continue/apply/abort); working tree clean viagit status --porcelain=v1 -unoempty (elserefusing to rebase: uncommitted changes to tracked files+ names).ontovalidated withassertRefLike(onto, "onto")and resolved withgit rev-parse --verify <onto>^{commit}(elseunknown revision <onto>).All mutating rebase invocations (
start,apply,continue) runrunGit(root, [...cfg.args, "rebase", ...], { timeoutMs: NETWORK_TIMEOUT_MS, signal, env: { ...cfg.env, GIT_EDITOR: "true" } })withcfg = await prepareCommitEnv(root, config, signal)(returnfail(error)on{error}).startandapplyalways add--no-autosquash --no-autostash --no-update-refs(so user config cannot widen the rewrite); if the installed git rejects--no-update-refs(git < 2.38), omit that one flag.Before starting (start and apply), record
rebase-start.json:branch,origHead(headSha),remote=branchUpstream(...).remoteororigin,remoteSha=git rev-parse --verify --quiet refs/remotes/<remote>/<branch>or null.start(ontorequired, elseonto is required for start):commits=git log --reverse --format=%h%x1f%s <onto>..HEADas{ sha, subject }[];upstreamGained={ count: rev-list --count HEAD..<onto>, overlappingFiles: intersection of git diff --name-only <mb>..<onto> and <mb>..HEAD }wheremb = git merge-base HEAD <onto>.commitsempty: failnothing to rebase: <branch> has no commits that are not in <onto>.rebase --onto <ontoSha> <mb>(equivalent to rebasing the branch's own commits). Then report via the shared outcome builder below.plan(interactive step 1):ontoSha= resolvedontoif given, elsembof HEAD and upstream (@{upstream}), else failonto is required: <branch> has no upstream.base = merge-base HEAD ontoSha. Commits =base..HEAD(empty → samenothing to rebasefail).resolveLocalUrlToPath("local://rebase-plan.md", ctx.localProtocolOptions)imported from@oh-my-pi/pi-coding-agent/internal-urls/local-protocol(ctxis the 4thexecuteargument). Ifctx?.localProtocolOptionsis missing, failinteractive rebase needs a session with local:// storage.{ action: "plan", branch, onto, planUrl: "local://rebase-plan.md", commits, upstreamGained, next: "Edit local://rebase-plan.md, then call git_rebase with action:\"apply\" (or action:\"abort\")." }.apply(interactive step 2): requires a plan marker (elseno pending plan: call git_rebase with action:"plan" first). Re-check branch == marker.branch andheadSha== last ofmarker.commits(else clear marker and failbranch moved since the plan was made; call plan again). Parse the plan file:#lines (outside a reword message block). Entry line regex^(pick|reword)\s+([0-9a-f]{4,40})\b(.*)$; any other non-indented line → failplan line <n>: only pick and reword are allowed: <line>.rewordline form its message (strip exactly 4 spaces);rewordwith empty message → failplan line <n>: reword needs a message. Indented lines afterpick→ fail.marker.commitsentry (by prefix), every commit exactly once (else fail listing missing/duplicate/unknown shas).Build the git todo: for each entry
pick <fullsha>; forreword,pick <fullsha>followed byexec git commit --amend --allow-empty --no-verify -F '<msgfile>'where<msgfile>=gitPath("omp-deck/reword-<fullsha>.txt")written beforehand (single-quote the path, rejecting paths containing'). Theexeclines are generated only by the tool; the agent can never inject one. Signing/identity flow to the amend throughGIT_CONFIG_PARAMETERS, which git sets for children ofgit -c.Write the todo to
gitPath("omp-deck/todo")and runrebase -i --onto <marker.onto> <marker.base>with env additionallyGIT_SEQUENCE_EDITOR: "cp '<todoPath>'". Recordrebase-start.json, clear the plan marker (and plan file) once git has started (i.e. afterrunGitreturns, regardless of conflict), then report via the outcome builder.continue: requiresrebaseInProgress(elseno rebase in progress).unmerged = git diff --name-only --diff-filter=U -z. For each unmerged path, read the working-tree file; if any line matches^(<{7}|={7}|>{7})( |$)failconflict markers remain in: <paths>. Fail ifgit diff --name-only -z(unstaged tracked changes) lists any path not inunmerged:unstaged changes outside the conflicted files: <paths>; commit them with git_commit or revert them.git add -- <unmerged>, thenrebase --continue. Report via outcome builder.abort: ifrebaseInProgress→rebase --abort; always clear plan marker,rebase-start.json, andomp-deck/reword-*.txt/todo. If nothing was open → failnothing to abort. Result{ action: "abort", branch, head }.status: read-only; returns{ action: "status", open: kind | false, branch, stoppedAt?, conflicted?, remaining?, planUrl? }.Outcome builder (after start/apply/continue): if
rebaseInProgress→{ action, state: "stopped", stoppedAt: { sha, subject } (from <gitdir>/rebase-merge/stopped-sha or REBASE_HEAD), conflicted: string[] (diff --diff-filter=U), done: n, remaining: n (count non-comment lines in rebase-merge/done and git-rebase-todo), next: "Resolve the conflicts in the listed files (edit them, no git add needed), then call git_rebase with action:\"continue\"; or action:\"abort\"." }. If not in progress and the git exit code was non-zero → fail with combined stderr/stdout (samecombinedOutputpattern aspush.ts). If completed →{ action, state: "done", branch, head, mapping: [{ old: sha7, new: sha7, subject }] }built by pairinggit log --reverse --format=%h%x1f%soforigHead's commit list with<onto>..HEADby original-order-to-plan-order (for start: positional; for apply: by plan order),pushed: remoteSha !== null, andnext=Pushed before: use git_push with force_after_rebase:true.whenremoteShanon-null. Promoterebase-start.json→force-push.jsonwithcompletedAt, deleterebase-start.jsonand reword/todo temp files.Tool description must state: only the current non-default branch; actions; that
planmust be called beforeapply; conflicts are resolved by editing files thencontinue; the turn cannot end while a rebase is open; allowed plan verbs; use instead ofgit rebasein bash.Add
GitRebaseResult(discriminated onaction/state, fields exactly as above) topackages/protocol/src/git-tools.tsnext toGitPushResult, doc-commented per field like its neighbours.4. Tool gate while a rebase is open
In
apps/server/src/bridge/git-tools/rebase-gate.ts(new) exportwrapRebaseGate(tool: CustomTool, cwd: string): CustomTool. For these names it callsrebaseOpen(root)before delegating and returnsfail("a rebase is open (<kind>) in this repository; finish it with git_rebase continue/apply or git_rebase abort first")when open:git_push,git_branchwithactionincreate|switch|delete(listpasses),forgejo_create_pr,forgejo_update_pr;git_fetchonly whenparams.fast_forward === true.Other tools are returned unwrapped. If
resolveReporeturns null, delegate unchanged. Apply in bothcustomToolsarrays inapps/server/src/bridge/in-process.ts(~271-277 and ~356-362):.map((t) => wrapToolWithFilterRef(wrapRebaseGate(t, cwd), filterRef))(useopts.cwdin the first).5. Turn-end guard (session_stop)
In
apps/server/src/bridge/git-tools/rebase-gate.tsalso exportrebaseStopGuard(cwd: string): ExtensionFactory(type from@oh-my-pi/pi-coding-agent;ExtensionFactoryis whatcreateAgentSession({ extensions })accepts,sdk.ts:664). It registerspi.on("session_stop", async (ev) => …): resolve repo; ifrebaseOpenis open, return{ continue: true, additionalContext: "<text>" }, elseundefined. Text: stopped →A git rebase is stopped on conflicts in <repo>: <conflicted>. You must not end your turn: resolve and call git_rebase action:"continue", or call git_rebase action:"abort". If you need the user's input, use the ask tool.; plan-pending →An interactive rebase plan is pending at local://rebase-plan.md. You must not end your turn: call git_rebase action:"apply" or action:"abort".It fires on every stop (ignoresstop_hook_active). Errors inside the handler are caught and returnundefined(never block on a broken repo). Passextensions: [rebaseStopGuard(cwd)]in bothcreateAgentSessioncalls inin-process.ts. Note: trigger sessions withrestrictedToolOptionsskip inline extensions (sdk.ts:2593), which is fine becausegit_rebaseis not on any trigger tool list.6.
git_pushforce after rebaseIn
push.tsadd schema fieldforce_after_rebase: z.boolean().optional().describe("Only after git_rebase completed on this branch: force-push with a lease on the remote sha seen when the rebase started."). When true:readForceMarker; failforce_after_rebase: no completed git_rebase recorded for <branch>if absent ormarker.branch !== branch.branchis indefaultBranches(root, remote).remote !== marker.remote:force push must go to <marker.remote>.--force-with-lease=refs/heads/<branch>:<marker.remoteSha ?? "">(empty expected value = must not exist). On rejection containingstale infofailremote branch changed since the rebase started; fetch and inspect before pushing.clearForceMarker; result gainsforced: true. Addforced?: booleantoGitPushResult.Update description: drop "no force" wording to "no force except
force_after_rebase(lease-protected, once per completed git_rebase)".7. Wiring and prompt surfaces
git-bash-rule.tsGIT_BASH_RULE_MESSAGE: addgit_rebaseto the listed tools; update the message assertions inapps/server/src/db/permissions-system-rule.test.ts(~13-15, 65-66, 255) to the new text; add"git rebase origin/main"to the DENY list ingit-bash-rule.test.ts.idea-mode-bridge.tsIDEA_BLOCKED_TOOLS: add"git_rebase": true.orientation-store.ts## Gitsection (~91-95): addgit_rebaseto the tool list and replace "They never force, amend, skip hooks, or merge/rebase." with "They never merge, amend, or skip hooks;git_rebaserewrites only the current branch's own commits, andgit_pushforce-pushes only once after a completedgit_rebase."docs/git-tools.md: "eight" → "nine", add agit_rebasetable row, documentforce_after_rebase.GitTool.tsxfalls back toGenericToolfor unknowngit_*names.Critical files & anchors
apps/server/src/bridge/git-tools/commit.tsbuildCommitTool~139-223 — code moved intoidentity.ts.apps/server/src/bridge/git-tools/push.ts— template for tool shape and the force option.apps/server/src/bridge/in-process.ts~256-287, ~346-364 — bothcreateAgentSessioncalls: gate wrapper andextensions.node_modules/@oh-my-pi/pi-coding-agent/src/extensibility/shared-events.ts~110-120, ~486-495 —SessionStopEvent/SessionStopEventResult.node_modules/@oh-my-pi/pi-coding-agent/src/internal-urls/local-protocol.ts~296 —resolveLocalUrlToPath.Verification
bun test,bun:testlikestatus-parse.test.ts): newrebase-plan.test.tsfor the plan parser — reorder accepted; reword with indented message yields that message;drop/squash/execlines rejected with line number; missing, duplicate, ambiguous-prefix and unknown sha rejected; reword without message rejected.rebase.test.tsusingfs.mkdtemptemp repos (git init, commits viaBun.spawn) callingbuildRebaseTool(dir, {}).execute(...)with a stub ctx{ localProtocolOptions: { getArtifactsDir: () => tmpArtifacts, getSessionId: () => "t" } }:b.txt; feature touchesa.txt;start onto:"main"→state:"done", mapping length 1,git log main..featurehas 1 commit whose parent is main's tip.a.txtsame line →startreturnsstate:"stopped",conflicted:["a.txt"];continuewith markers still present failsconflict markers remain; after writing resolved contentcontinue→done.planthenapplywith lines swapped and onereword→ commit order swapped, reworded commit message equals the indented text;applywithoutplanfailsno pending plan.startonmainfailsrefusing to rebase the default branch.git_pushandgit_branch action:"switch"return the gate error;git_branch action:"list"andgit_commitare not gated.git_push force_after_rebase:truewithout marker fails; with a bare temp remote: push feature, rebase,force_after_rebasesucceeds; second force push fails (marker cleared).origin/mainwith a staged conflict, then tell it to stop mid-conflict → it receives the continuation context instead of ending; afterabortthe turn ends normally.Assumptions & contingencies
git rebase --no-update-refsis unsupported by the installed git, omit the flag.session_stopcontinuation turns cannot repeat indefinitely in the SDK (only one per settle), keep the handler as is; the gate still blocks the dangerous tools.@oh-my-pi/pi-coding-agent/internal-urls/local-protocolfails under the packageexportsmap ("./*"→./src/*.ts), compute the path aspath.join(resolveLocalRoot(...))from the same module or, failing that,path.join(ctx.localProtocolOptions.getArtifactsDir(), "local", "rebase-plan.md").