Add a guarded git_rebase tool (rebase, conflict continue, interactive reorder/reword) #40

Closed
opened 2026-10-19 14:49:14 +02:00 by omp-deck · 0 comments
Collaborator

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 rebase in bash is denied by the git bash rule, so today an agent cannot do this at all.

Requirements (original brief, refined):

  • The agent learns what changes before/after the rebase, token-efficiently: names only (sha7 + subject, file names), fetching details itself with git_show/git_diff as needed.
  • --continue workflow so the agent can fix conflicts.
  • Interactive mode as a two-step flow: the agent MUST first request the plan; the plan is a real file in the session's 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.
  • Guards force the agent to finish: both a pending interactive plan and a stopped (conflict) rebase. Every time the agent tries to end its turn with one open, it is sent back to continue or abort.
  • While a rebase is open, history/branch-changing tools refuse: git_push, git_branch (create/switch/delete), git_fetch with fast_forward, forgejo_create_pr, forgejo_update_pr. git_commit stays allowed (useful e.g. to split work while stopped).
  • NO arbitrary history rewrite: only the current branch, never the default branch, never detached HEAD; interactive actions limited to pick (with reordering) and reword (full new message supplied in the plan). No drop, squash, fixup, edit, exec, break, label/reset/merge, no skip, no adding commits.
  • After a completed rebase, git_push may 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_rebase custom 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-guarded force_after_rebase option on git_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)

  • Create apps/server/src/bridge/git-tools/identity.ts exporting:
    • 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 }>
  • Move into it, unchanged in wording and order, from buildCommitTool in commit.ts: the both-or-neither name/email check, identityArgs, the git config user.name check, the GNUPGHOME resolution + mkdirSync(..., { mode: 0o700 }), the GPG import (move runGpg from commit.ts too), and commitSignArgs. args = [...identityArgs, ...commitSignArgs]; env = { GNUPGHOME: effectiveGnupgHome }. Error strings stay byte-identical.
  • buildCommitTool signature becomes buildCommitTool(cwd: string, cfg: CommitEnvConfig); it calls prepareCommitEnv where the inline code was (still before any index mutation) and passes args/env to the commit runGit. Update the only caller, git-tools/index.ts, to build one CommitEnvConfig from the existing OMP_DECK_* env reads and pass it to both commit and rebase tools.
  • The coAuthorTrailer stays 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 from git rev-parse --git-path <name> (resolved against root), so worktrees work.
  • rebaseInProgress(root, signal): Promise<boolean> → true if gitPath("rebase-merge") or gitPath("rebase-apply") exists.
  • Pending-plan marker at 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 }. Functions readPlanMarker, writePlanMarker, clearPlanMarker (clear also deletes planPath if present, ignoring ENOENT).
  • Push-lease marker at gitPath("omp-deck/force-push.json"): { branch: string, remote: string, remoteSha: string | null, completedAt: string }. Functions readForceMarker, writeForceMarker, clearForceMarker. A rebase-start record gitPath("omp-deck/rebase-start.json") with the same shape minus completedAt plus origHead: string is 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 from git symbolic-ref --short refs/remotes/<remote>/HEAD (strip <remote>/); if that fails, new Set(["main", "master"]).

All markers are JSON written with fs.promises.writeFile after mkdir(dirname, { recursive: true }); a corrupt/unparseable marker is treated as absent for reads but rebaseOpen treats an unparseable plan marker as plan-pending (fail closed) and git_rebase abort clears it.

3. git_rebase tool (apps/server/src/bridge/git-tools/rebase.ts, new; registered in index.ts after buildPushTool)

buildRebaseTool(cwd: string, cfg: CommitEnvConfig): CustomTool. Follow push.ts structure (guarded("git_rebase", …), resolveRepo, fail/ok). Schema (zod, like other tools):

z.object({
  action: z.enum(["start", "plan", "apply", "continue", "abort", "status"]),
  onto: z.string().optional(),   // start: required; plan: optional
})

Common preconditions for start and plan: repo resolved; currentBranch non-null (else refusing to rebase: HEAD is detached); branch not in defaultBranches(root, upstreamRemote ?? "origin") (else refusing to rebase the default branch <b>); rebaseOpen is {open:false} (else a rebase is already open (<kind>); use git_rebase continue/apply/abort); working tree clean via git status --porcelain=v1 -uno empty (else refusing to rebase: uncommitted changes to tracked files + names). onto validated with assertRefLike(onto, "onto") and resolved with git rev-parse --verify <onto>^{commit} (else unknown revision <onto>).

All mutating rebase invocations (start, apply, continue) run runGit(root, [...cfg.args, "rebase", ...], { timeoutMs: NETWORK_TIMEOUT_MS, signal, env: { ...cfg.env, GIT_EDITOR: "true" } }) with cfg = await prepareCommitEnv(root, config, signal) (return fail(error) on {error}). start and apply always 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(...).remote or origin, remoteSha = git rev-parse --verify --quiet refs/remotes/<remote>/<branch> or null.

start (onto required, else onto is required for start):

  • Before-report (names only): commits = git log --reverse --format=%h%x1f%s <onto>..HEAD as { sha, subject }[]; upstreamGained = { count: rev-list --count HEAD..<onto>, overlappingFiles: intersection of git diff --name-only <mb>..<onto> and <mb>..HEAD } where mb = git merge-base HEAD <onto>.
  • If commits empty: fail nothing to rebase: <branch> has no commits that are not in <onto>.
  • Run 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 = resolved onto if given, else mb of HEAD and upstream (@{upstream}), else fail onto is required: <branch> has no upstream. base = merge-base HEAD ontoSha. Commits = base..HEAD (empty → same nothing to rebase fail).
  • Resolve the plan path with resolveLocalUrlToPath("local://rebase-plan.md", ctx.localProtocolOptions) imported from @oh-my-pi/pi-coding-agent/internal-urls/local-protocol (ctx is the 4th execute argument). If ctx?.localProtocolOptions is missing, fail interactive rebase needs a session with local:// storage.
  • Write the plan file:
    # Interactive rebase of <branch> onto <onto7> (<n> commits, oldest first).
    # Allowed: reorder lines; change `pick` to `reword` and add the full new
    # message on the following lines, each indented 4 spaces.
    # Every commit must appear exactly once. Lines starting with # are ignored.
    pick <sha7> <subject>
    ...
    
  • Write plan marker. Result: { 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 (else no pending plan: call git_rebase with action:"plan" first). Re-check branch == marker.branch and headSha == last of marker.commits (else clear marker and fail branch moved since the plan was made; call plan again). Parse the plan file:

  • Ignore blank lines and # lines (outside a reword message block). Entry line regex ^(pick|reword)\s+([0-9a-f]{4,40})\b(.*)$; any other non-indented line → fail plan line <n>: only pick and reword are allowed: <line>.
  • 4-space-indented lines directly after a reword line form its message (strip exactly 4 spaces); reword with empty message → fail plan line <n>: reword needs a message. Indented lines after pick → fail.
  • Each sha prefix must match exactly one marker.commits entry (by prefix), every commit exactly once (else fail listing missing/duplicate/unknown shas).
  • On any parse failure the marker stays (agent fixes the file and re-applies).
    Build the git todo: for each entry pick <fullsha>; for reword, pick <fullsha> followed by exec 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 '). The exec lines are generated only by the tool; the agent can never inject one. Signing/identity flow to the amend through GIT_CONFIG_PARAMETERS, which git sets for children of git -c.
    Write the todo to gitPath("omp-deck/todo") and run rebase -i --onto <marker.onto> <marker.base> with env additionally GIT_SEQUENCE_EDITOR: "cp '<todoPath>'". Record rebase-start.json, clear the plan marker (and plan file) once git has started (i.e. after runGit returns, regardless of conflict), then report via the outcome builder.

continue: requires rebaseInProgress (else no 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})( |$) fail conflict markers remain in: <paths>. Fail if git diff --name-only -z (unstaged tracked changes) lists any path not in unmerged: unstaged changes outside the conflicted files: <paths>; commit them with git_commit or revert them. git add -- <unmerged>, then rebase --continue. Report via outcome builder.

abort: if rebaseInProgress → rebase --abort; always clear plan marker, rebase-start.json, and omp-deck/reword-*.txt/todo. If nothing was open → fail nothing 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 (same combinedOutput pattern as push.ts). If completed → { action, state: "done", branch, head, mapping: [{ old: sha7, new: sha7, subject }] } built by pairing git log --reverse --format=%h%x1f%s of origHead's commit list with <onto>..HEAD by original-order-to-plan-order (for start: positional; for apply: by plan order), pushed: remoteSha !== null, and next = Pushed before: use git_push with force_after_rebase:true. when remoteSha non-null. Promote rebase-start.json → force-push.json with completedAt, delete rebase-start.json and reword/todo temp files.

Tool description must state: only the current non-default branch; actions; that plan must be called before apply; conflicts are resolved by editing files then continue; the turn cannot end while a rebase is open; allowed plan verbs; use instead of git rebase in bash.

Add GitRebaseResult (discriminated on action/state, fields exactly as above) to packages/protocol/src/git-tools.ts next to GitPushResult, 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) export wrapRebaseGate(tool: CustomTool, cwd: string): CustomTool. For these names it calls rebaseOpen(root) before delegating and returns fail("a rebase is open (<kind>) in this repository; finish it with git_rebase continue/apply or git_rebase abort first") when open:

  • always: git_push, git_branch with action in create|switch|delete (list passes), forgejo_create_pr, forgejo_update_pr;
  • git_fetch only when params.fast_forward === true.
    Other tools are returned unwrapped. If resolveRepo returns null, delegate unchanged. Apply in both customTools arrays in apps/server/src/bridge/in-process.ts (~271-277 and ~356-362): .map((t) => wrapToolWithFilterRef(wrapRebaseGate(t, cwd), filterRef)) (use opts.cwd in the first).

5. Turn-end guard (session_stop)

In apps/server/src/bridge/git-tools/rebase-gate.ts also export rebaseStopGuard(cwd: string): ExtensionFactory (type from @oh-my-pi/pi-coding-agent; ExtensionFactory is what createAgentSession({ extensions }) accepts, sdk.ts:664). It registers pi.on("session_stop", async (ev) => …): resolve repo; if rebaseOpen is open, return { continue: true, additionalContext: "<text>" }, else undefined. 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 (ignores stop_hook_active). Errors inside the handler are caught and return undefined (never block on a broken repo). Pass extensions: [rebaseStopGuard(cwd)] in both createAgentSession calls in in-process.ts. Note: trigger sessions with restrictedToolOptions skip inline extensions (sdk.ts:2593), which is fine because git_rebase is not on any trigger tool list.

6. git_push force after rebase

In push.ts add schema field force_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; fail force_after_rebase: no completed git_rebase recorded for <branch> if absent or marker.branch !== branch.
  • Refuse if branch is in defaultBranches(root, remote).
  • Refuse if resolved remote !== marker.remote: force push must go to <marker.remote>.
  • Push args: same as now plus --force-with-lease=refs/heads/<branch>:<marker.remoteSha ?? ""> (empty expected value = must not exist). On rejection containing stale info fail remote branch changed since the rebase started; fetch and inspect before pushing.
  • On success clearForceMarker; result gains forced: true. Add forced?: boolean to GitPushResult.
    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.ts GIT_BASH_RULE_MESSAGE: add git_rebase to the listed tools; update the message assertions in apps/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 in git-bash-rule.test.ts.
  • idea-mode-bridge.ts IDEA_BLOCKED_TOOLS: add "git_rebase": true.
  • orientation-store.ts ## Git section (~91-95): add git_rebase to the tool list and replace "They never force, amend, skip hooks, or merge/rebase." with "They never merge, amend, or skip hooks; git_rebase rewrites only the current branch's own commits, and git_push force-pushes only once after a completed git_rebase."
  • docs/git-tools.md: "eight" → "nine", add a git_rebase table row, document force_after_rebase.
  • Web: no new view; GitTool.tsx falls back to GenericTool for unknown git_* names.

Critical files & anchors

  • apps/server/src/bridge/git-tools/commit.ts buildCommitTool ~139-223 — code moved into identity.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 — both createAgentSession calls: gate wrapper and extensions.
  • 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

  • Unit (bun test, bun:test like status-parse.test.ts): new rebase-plan.test.ts for the plan parser — reorder accepted; reword with indented message yields that message; drop/squash/exec lines rejected with line number; missing, duplicate, ambiguous-prefix and unknown sha rejected; reword without message rejected.
  • Integration test rebase.test.ts using fs.mkdtemp temp repos (git init, commits via Bun.spawn) calling buildRebaseTool(dir, {}).execute(...) with a stub ctx { localProtocolOptions: { getArtifactsDir: () => tmpArtifacts, getSessionId: () => "t" } }:
    1. main gets a commit touching b.txt; feature touches a.txt; start onto:"main" → state:"done", mapping length 1, git log main..feature has 1 commit whose parent is main's tip.
    2. Both sides edit a.txt same line → start returns state:"stopped", conflicted:["a.txt"]; continue with markers still present fails conflict markers remain; after writing resolved content continue → done.
    3. plan then apply with lines swapped and one reword → commit order swapped, reworded commit message equals the indented text; apply without plan fails no pending plan.
    4. start on main fails refusing to rebase the default branch.
    5. Gate: with a stopped rebase, wrapped git_push and git_branch action:"switch" return the gate error; git_branch action:"list" and git_commit are not gated.
    6. git_push force_after_rebase:true without marker fails; with a bare temp remote: push feature, rebase, force_after_rebase succeeds; second force push fails (marker cleared).
  • Manual (deck dev server): in a session on a feature branch, ask the agent to rebase onto origin/main with a staged conflict, then tell it to stop mid-conflict → it receives the continuation context instead of ending; after abort the turn ends normally.

Assumptions & contingencies

  • If git rebase --no-update-refs is unsupported by the installed git, omit the flag.
  • If session_stop continuation turns cannot repeat indefinitely in the SDK (only one per settle), keep the handler as is; the gate still blocks the dangerous tools.
  • If importing @oh-my-pi/pi-coding-agent/internal-urls/local-protocol fails under the package exports map ("./*" → ./src/*.ts), compute the path as path.join(resolveLocalRoot(...)) from the same module or, failing that, path.join(ctx.localProtocolOptions.getArtifactsDir(), "local", "rebase-plan.md").
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 rebase` in bash is denied by the git bash rule, so today an agent cannot do this at all. Requirements (original brief, refined): - The agent learns what changes before/after the rebase, token-efficiently: names only (sha7 + subject, file names), fetching details itself with `git_show`/`git_diff` as needed. - `--continue` workflow so the agent can fix conflicts. - Interactive mode as a two-step flow: the agent MUST first request the plan; the plan is a real file in the session's `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. - Guards force the agent to finish: both a pending interactive plan and a stopped (conflict) rebase. Every time the agent tries to end its turn with one open, it is sent back to continue or abort. - While a rebase is open, history/branch-changing tools refuse: `git_push`, `git_branch` (create/switch/delete), `git_fetch` with `fast_forward`, `forgejo_create_pr`, `forgejo_update_pr`. `git_commit` stays allowed (useful e.g. to split work while stopped). - NO arbitrary history rewrite: only the current branch, never the default branch, never detached HEAD; interactive actions limited to `pick` (with reordering) and `reword` (full new message supplied in the plan). No drop, squash, fixup, edit, exec, break, label/reset/merge, no skip, no adding commits. - After a completed rebase, `git_push` may 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_rebase` custom 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-guarded `force_after_rebase` option on `git_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) - Create `apps/server/src/bridge/git-tools/identity.ts` exporting: - `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 }>` - Move into it, unchanged in wording and order, from `buildCommitTool` in `commit.ts`: the both-or-neither name/email check, `identityArgs`, the `git config user.name` check, the GNUPGHOME resolution + `mkdirSync(..., { mode: 0o700 })`, the GPG import (move `runGpg` from `commit.ts` too), and `commitSignArgs`. `args` = `[...identityArgs, ...commitSignArgs]`; `env` = `{ GNUPGHOME: effectiveGnupgHome }`. Error strings stay byte-identical. - `buildCommitTool` signature becomes `buildCommitTool(cwd: string, cfg: CommitEnvConfig)`; it calls `prepareCommitEnv` where the inline code was (still before any index mutation) and passes `args`/`env` to the commit `runGit`. Update the only caller, `git-tools/index.ts`, to build one `CommitEnvConfig` from the existing `OMP_DECK_*` env reads and pass it to both commit and rebase tools. - The `coAuthorTrailer` stays 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 from `git rev-parse --git-path <name>` (resolved against `root`), so worktrees work. - `rebaseInProgress(root, signal): Promise<boolean>` → true if `gitPath("rebase-merge")` or `gitPath("rebase-apply")` exists. - Pending-plan marker at `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 }`. Functions `readPlanMarker`, `writePlanMarker`, `clearPlanMarker` (clear also deletes `planPath` if present, ignoring ENOENT). - Push-lease marker at `gitPath("omp-deck/force-push.json")`: `{ branch: string, remote: string, remoteSha: string | null, completedAt: string }`. Functions `readForceMarker`, `writeForceMarker`, `clearForceMarker`. A rebase-start record `gitPath("omp-deck/rebase-start.json")` with the same shape minus `completedAt` plus `origHead: string` is 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 from `git symbolic-ref --short refs/remotes/<remote>/HEAD` (strip `<remote>/`); if that fails, `new Set(["main", "master"])`. All markers are JSON written with `fs.promises.writeFile` after `mkdir(dirname, { recursive: true })`; a corrupt/unparseable marker is treated as absent for reads but `rebaseOpen` treats an unparseable plan marker as `plan-pending` (fail closed) and `git_rebase abort` clears it. #### 3. `git_rebase` tool (`apps/server/src/bridge/git-tools/rebase.ts`, new; registered in `index.ts` after `buildPushTool`) `buildRebaseTool(cwd: string, cfg: CommitEnvConfig): CustomTool`. Follow `push.ts` structure (`guarded("git_rebase", …)`, `resolveRepo`, `fail`/`ok`). Schema (zod, like other tools): ```ts z.object({ action: z.enum(["start", "plan", "apply", "continue", "abort", "status"]), onto: z.string().optional(), // start: required; plan: optional }) ``` Common preconditions for `start` and `plan`: repo resolved; `currentBranch` non-null (else `refusing to rebase: HEAD is detached`); branch not in `defaultBranches(root, upstreamRemote ?? "origin")` (else `refusing to rebase the default branch <b>`); `rebaseOpen` is `{open:false}` (else `a rebase is already open (<kind>); use git_rebase continue/apply/abort`); working tree clean via `git status --porcelain=v1 -uno` empty (else `refusing to rebase: uncommitted changes to tracked files` + names). `onto` validated with `assertRefLike(onto, "onto")` and resolved with `git rev-parse --verify <onto>^{commit}` (else `unknown revision <onto>`). All mutating rebase invocations (`start`, `apply`, `continue`) run `runGit(root, [...cfg.args, "rebase", ...], { timeoutMs: NETWORK_TIMEOUT_MS, signal, env: { ...cfg.env, GIT_EDITOR: "true" } })` with `cfg = await prepareCommitEnv(root, config, signal)` (return `fail(error)` on `{error}`). `start` and `apply` always 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(...).remote` or `origin`, `remoteSha` = `git rev-parse --verify --quiet refs/remotes/<remote>/<branch>` or null. **`start`** (`onto` required, else `onto is required for start`): - Before-report (names only): `commits` = `git log --reverse --format=%h%x1f%s <onto>..HEAD` as `{ sha, subject }[]`; `upstreamGained` = `{ count: rev-list --count HEAD..<onto>, overlappingFiles: intersection of git diff --name-only <mb>..<onto> and <mb>..HEAD }` where `mb = git merge-base HEAD <onto>`. - If `commits` empty: fail `nothing to rebase: <branch> has no commits that are not in <onto>`. - Run `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` = resolved `onto` if given, else `mb` of HEAD and upstream (`@{upstream}`), else fail `onto is required: <branch> has no upstream`. `base = merge-base HEAD ontoSha`. Commits = `base..HEAD` (empty → same `nothing to rebase` fail). - Resolve the plan path with `resolveLocalUrlToPath("local://rebase-plan.md", ctx.localProtocolOptions)` imported from `@oh-my-pi/pi-coding-agent/internal-urls/local-protocol` (`ctx` is the 4th `execute` argument). If `ctx?.localProtocolOptions` is missing, fail `interactive rebase needs a session with local:// storage`. - Write the plan file: ``` # Interactive rebase of <branch> onto <onto7> (<n> commits, oldest first). # Allowed: reorder lines; change `pick` to `reword` and add the full new # message on the following lines, each indented 4 spaces. # Every commit must appear exactly once. Lines starting with # are ignored. pick <sha7> <subject> ... ``` - Write plan marker. Result: `{ 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 (else `no pending plan: call git_rebase with action:"plan" first`). Re-check branch == marker.branch and `headSha` == last of `marker.commits` (else clear marker and fail `branch moved since the plan was made; call plan again`). Parse the plan file: - Ignore blank lines and `#` lines (outside a reword message block). Entry line regex `^(pick|reword)\s+([0-9a-f]{4,40})\b(.*)$`; any other non-indented line → fail `plan line <n>: only pick and reword are allowed: <line>`. - 4-space-indented lines directly after a `reword` line form its message (strip exactly 4 spaces); `reword` with empty message → fail `plan line <n>: reword needs a message`. Indented lines after `pick` → fail. - Each sha prefix must match exactly one `marker.commits` entry (by prefix), every commit exactly once (else fail listing missing/duplicate/unknown shas). - On any parse failure the marker stays (agent fixes the file and re-applies). Build the git todo: for each entry `pick <fullsha>`; for `reword`, `pick <fullsha>` followed by `exec 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 `'`). The `exec` lines are generated only by the tool; the agent can never inject one. Signing/identity flow to the amend through `GIT_CONFIG_PARAMETERS`, which git sets for children of `git -c`. Write the todo to `gitPath("omp-deck/todo")` and run `rebase -i --onto <marker.onto> <marker.base>` with env additionally `GIT_SEQUENCE_EDITOR: "cp '<todoPath>'"`. Record `rebase-start.json`, clear the plan marker (and plan file) once git has started (i.e. after `runGit` returns, regardless of conflict), then report via the outcome builder. **`continue`**: requires `rebaseInProgress` (else `no 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})( |$)` fail `conflict markers remain in: <paths>`. Fail if `git diff --name-only -z` (unstaged tracked changes) lists any path not in `unmerged`: `unstaged changes outside the conflicted files: <paths>; commit them with git_commit or revert them`. `git add -- <unmerged>`, then `rebase --continue`. Report via outcome builder. **`abort`**: if `rebaseInProgress` → `rebase --abort`; always clear plan marker, `rebase-start.json`, and `omp-deck/reword-*.txt`/`todo`. If nothing was open → fail `nothing 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 (same `combinedOutput` pattern as `push.ts`). If completed → `{ action, state: "done", branch, head, mapping: [{ old: sha7, new: sha7, subject }] }` built by pairing `git log --reverse --format=%h%x1f%s` of `origHead`'s commit list with `<onto>..HEAD` by original-order-to-plan-order (for start: positional; for apply: by plan order), `pushed: remoteSha !== null`, and `next` = `Pushed before: use git_push with force_after_rebase:true.` when `remoteSha` non-null. Promote `rebase-start.json` → `force-push.json` with `completedAt`, delete `rebase-start.json` and reword/todo temp files. Tool description must state: only the current non-default branch; actions; that `plan` must be called before `apply`; conflicts are resolved by editing files then `continue`; the turn cannot end while a rebase is open; allowed plan verbs; use instead of `git rebase` in bash. Add `GitRebaseResult` (discriminated on `action`/`state`, fields exactly as above) to `packages/protocol/src/git-tools.ts` next to `GitPushResult`, 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) export `wrapRebaseGate(tool: CustomTool, cwd: string): CustomTool`. For these names it calls `rebaseOpen(root)` before delegating and returns `fail("a rebase is open (<kind>) in this repository; finish it with git_rebase continue/apply or git_rebase abort first")` when open: - always: `git_push`, `git_branch` with `action` in `create|switch|delete` (`list` passes), `forgejo_create_pr`, `forgejo_update_pr`; - `git_fetch` only when `params.fast_forward === true`. Other tools are returned unwrapped. If `resolveRepo` returns null, delegate unchanged. Apply in both `customTools` arrays in `apps/server/src/bridge/in-process.ts` (~271-277 and ~356-362): `.map((t) => wrapToolWithFilterRef(wrapRebaseGate(t, cwd), filterRef))` (use `opts.cwd` in the first). #### 5. Turn-end guard (session_stop) In `apps/server/src/bridge/git-tools/rebase-gate.ts` also export `rebaseStopGuard(cwd: string): ExtensionFactory` (type from `@oh-my-pi/pi-coding-agent`; `ExtensionFactory` is what `createAgentSession({ extensions })` accepts, `sdk.ts:664`). It registers `pi.on("session_stop", async (ev) => …)`: resolve repo; if `rebaseOpen` is open, return `{ continue: true, additionalContext: "<text>" }`, else `undefined`. 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 (ignores `stop_hook_active`). Errors inside the handler are caught and return `undefined` (never block on a broken repo). Pass `extensions: [rebaseStopGuard(cwd)]` in both `createAgentSession` calls in `in-process.ts`. Note: trigger sessions with `restrictedToolOptions` skip inline extensions (`sdk.ts:2593`), which is fine because `git_rebase` is not on any trigger tool list. #### 6. `git_push` force after rebase In `push.ts` add schema field `force_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`; fail `force_after_rebase: no completed git_rebase recorded for <branch>` if absent or `marker.branch !== branch`. - Refuse if `branch` is in `defaultBranches(root, remote)`. - Refuse if resolved `remote !== marker.remote`: `force push must go to <marker.remote>`. - Push args: same as now plus `--force-with-lease=refs/heads/<branch>:<marker.remoteSha ?? "">` (empty expected value = must not exist). On rejection containing `stale info` fail `remote branch changed since the rebase started; fetch and inspect before pushing`. - On success `clearForceMarker`; result gains `forced: true`. Add `forced?: boolean` to `GitPushResult`. 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.ts` `GIT_BASH_RULE_MESSAGE`: add `git_rebase` to the listed tools; update the message assertions in `apps/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 in `git-bash-rule.test.ts`. - `idea-mode-bridge.ts` `IDEA_BLOCKED_TOOLS`: add `"git_rebase": true`. - `orientation-store.ts` `## Git` section (~91-95): add `git_rebase` to the tool list and replace "They never force, amend, skip hooks, or merge/rebase." with "They never merge, amend, or skip hooks; `git_rebase` rewrites only the current branch's own commits, and `git_push` force-pushes only once after a completed `git_rebase`." - `docs/git-tools.md`: "eight" → "nine", add a `git_rebase` table row, document `force_after_rebase`. - Web: no new view; `GitTool.tsx` falls back to `GenericTool` for unknown `git_*` names. ### Critical files & anchors - `apps/server/src/bridge/git-tools/commit.ts` `buildCommitTool` ~139-223 — code moved into `identity.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 — both `createAgentSession` calls: gate wrapper and `extensions`. - `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 - Unit (`bun test`, `bun:test` like `status-parse.test.ts`): new `rebase-plan.test.ts` for the plan parser — reorder accepted; reword with indented message yields that message; `drop`/`squash`/`exec` lines rejected with line number; missing, duplicate, ambiguous-prefix and unknown sha rejected; reword without message rejected. - Integration test `rebase.test.ts` using `fs.mkdtemp` temp repos (`git init`, commits via `Bun.spawn`) calling `buildRebaseTool(dir, {}).execute(...)` with a stub ctx `{ localProtocolOptions: { getArtifactsDir: () => tmpArtifacts, getSessionId: () => "t" } }`: 1. main gets a commit touching `b.txt`; feature touches `a.txt`; `start onto:"main"` → `state:"done"`, mapping length 1, `git log main..feature` has 1 commit whose parent is main's tip. 2. Both sides edit `a.txt` same line → `start` returns `state:"stopped"`, `conflicted:["a.txt"]`; `continue` with markers still present fails `conflict markers remain`; after writing resolved content `continue` → `done`. 3. `plan` then `apply` with lines swapped and one `reword` → commit order swapped, reworded commit message equals the indented text; `apply` without `plan` fails `no pending plan`. 4. `start` on `main` fails `refusing to rebase the default branch`. 5. Gate: with a stopped rebase, wrapped `git_push` and `git_branch action:"switch"` return the gate error; `git_branch action:"list"` and `git_commit` are not gated. 6. `git_push force_after_rebase:true` without marker fails; with a bare temp remote: push feature, rebase, `force_after_rebase` succeeds; second force push fails (marker cleared). - Manual (deck dev server): in a session on a feature branch, ask the agent to rebase onto `origin/main` with a staged conflict, then tell it to stop mid-conflict → it receives the continuation context instead of ending; after `abort` the turn ends normally. ### Assumptions & contingencies - If `git rebase --no-update-refs` is unsupported by the installed git, omit the flag. - If `session_stop` continuation turns cannot repeat indefinitely in the SDK (only one per settle), keep the handler as is; the gate still blocks the dangerous tools. - If importing `@oh-my-pi/pi-coding-agent/internal-urls/local-protocol` fails under the package `exports` map (`"./*"` → `./src/*.ts`), compute the path as `path.join(resolveLocalRoot(...))` from the same module or, failing that, `path.join(ctx.localProtocolOptions.getArtifactsDir(), "local", "rebase-plan.md")`.
lucas closed this issue 2026-10-20 09:58:42 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
lucas/omp-deck#40
No description provided.