feat(git-tools): add guarded git_rebase tool #41

Merged
lucas merged 8 commits from 40-add-a-guarded-git-rebase-tool into main 2026-10-20 09:58:41 +02:00
Collaborator

Adds a git_rebase tool so agents can rebase their own (non-default) branch onto an updated base, resolve conflicts with continue, and reorder/reword commits through a two-step plan file in local:// storage, without raw git. While a rebase is stopped or a plan is pending, history/branch-changing tools refuse and a session_stop extension sends the agent back whenever it tries to end its turn. After a completed rebase, git_push force_after_rebase:true force-pushes the branch once with a lease on the remote sha seen at rebase start. Commit identity/signing setup was extracted from git_commit first so both tools share it.

Decisions

Posted in comment 16296 and comment 16298:

  1. The start record also stores onto and the expected order (old sha + expected subject), so the old→new mapping works across continue calls and pairs by subject.
  2. The done result lists dropped sha7s for commits git dropped as already upstream.
  3. start returns its before-report (commits, upstreamGained) on both stopped and done outcomes.
  4. A stop without conflicts (e.g. a failed reword amend) puts git's output in next.
  5. Plan file: blank lines inside a reword message are kept when indented text follows; trailing blanks are trimmed; indented lines after pick are rejected.
  6. forced? on GitPushResult landed with the other protocol types in the git_rebase commit.
  7. During a conflict, git_commit passes the gate but answers with its existing "already staged" guard; the test pins that.
  8. The stop guard has an automated test; the manual deck-session check was not run.
  9. permissions-system-rule.test.ts needed no edit (it asserts the constant).
  10. docs/git-tools.md: the default-branch sentence was corrected, and a git_rebase section and a CHANGELOG entry were added.
  11. Extra test: force_after_rebase is refused when the remote branch moved after the rebase started.

Verification

  • bun run typecheck: protocol, server and web clean.
  • bun test src/bridge/git-tools/rebase-plan.test.ts: 13 pass (reorder, reword message, rejected verbs with line numbers, missing/duplicate/ambiguous/unknown shas, reword without message).
  • bun test src/bridge/git-tools/rebase.test.ts: 12 pass (start onto new base; conflict stop → marker refusal → continue; plan/apply reorder + reword; rejected plan stays pending → abort; default branch / detached HEAD refused; gate refuses push/branch switch/fetch ff while stopped, lets branch list and git_commit through, releases after abort; stop guard; force_after_rebase once with bare remote, refused without marker and after the remote moved).
  • bun test in apps/server: 797 pass, 0 fail.
  • Not run: the manual deck-session check (agent told to stop mid-conflict).

Closes #40

Adds a `git_rebase` tool so agents can rebase their own (non-default) branch onto an updated base, resolve conflicts with `continue`, and reorder/reword commits through a two-step plan file in `local://` storage, without raw git. While a rebase is stopped or a plan is pending, history/branch-changing tools refuse and a `session_stop` extension sends the agent back whenever it tries to end its turn. After a completed rebase, `git_push force_after_rebase:true` force-pushes the branch once with a lease on the remote sha seen at rebase start. Commit identity/signing setup was extracted from `git_commit` first so both tools share it. ## Decisions Posted in [comment 16296](https://git.sclu1034.dev/lucas/omp-deck/pulls/41#issuecomment-16296) and [comment 16298](https://git.sclu1034.dev/lucas/omp-deck/pulls/41#issuecomment-16298): 1. The start record also stores `onto` and the expected order (old sha + expected subject), so the old→new mapping works across `continue` calls and pairs by subject. 2. The done result lists `dropped` sha7s for commits git dropped as already upstream. 3. `start` returns its before-report (`commits`, `upstreamGained`) on both stopped and done outcomes. 4. A stop without conflicts (e.g. a failed reword amend) puts git's output in `next`. 5. Plan file: blank lines inside a reword message are kept when indented text follows; trailing blanks are trimmed; indented lines after `pick` are rejected. 6. `forced?` on `GitPushResult` landed with the other protocol types in the git_rebase commit. 7. During a conflict, `git_commit` passes the gate but answers with its existing "already staged" guard; the test pins that. 8. The stop guard has an automated test; the manual deck-session check was not run. 9. `permissions-system-rule.test.ts` needed no edit (it asserts the constant). 10. `docs/git-tools.md`: the default-branch sentence was corrected, and a `git_rebase` section and a CHANGELOG entry were added. 11. Extra test: `force_after_rebase` is refused when the remote branch moved after the rebase started. ## Verification - `bun run typecheck`: protocol, server and web clean. - `bun test src/bridge/git-tools/rebase-plan.test.ts`: 13 pass (reorder, reword message, rejected verbs with line numbers, missing/duplicate/ambiguous/unknown shas, reword without message). - `bun test src/bridge/git-tools/rebase.test.ts`: 12 pass (start onto new base; conflict stop → marker refusal → continue; plan/apply reorder + reword; rejected plan stays pending → abort; default branch / detached HEAD refused; gate refuses push/branch switch/fetch ff while stopped, lets branch list and git_commit through, releases after abort; stop guard; force_after_rebase once with bare remote, refused without marker and after the remote moved). - `bun test` in `apps/server`: 797 pass, 0 fail. - Not run: the manual deck-session check (agent told to stop mid-conflict). Closes #40
Move the identity check, GNUPGHOME setup, GPG import and signing args
from git_commit into prepareCommitEnv so git_rebase can reuse them.
Behaviour and error texts are unchanged.

Co-Authored-By: Claude Opus 5.5 via omp-deck
Resolve paths in the git dir (worktree-safe), detect a stopped rebase,
and read/write the deck's markers for a pending interactive plan, the
rebase start record and the one-time force-push lease. rebaseOpen fails
closed on an unparseable plan marker.

Co-Authored-By: Claude Opus 5.5 via omp-deck
Render and parse the plan file an agent edits between git_rebase plan
and apply. Only pick (any order) and reword with a full indented message
are accepted, and every commit must appear exactly once.

Co-Authored-By: Claude Opus 5.5 via omp-deck
Rebase the current non-default branch onto a new base, stop on conflicts
and continue once they are resolved, or reorder/reword its commits via a
plan file in local:// storage. Results name commits and files only.
Identity and signing come from the server config; user config cannot
widen the rewrite (no autosquash, autostash or update-refs).

git_push gains forced?: boolean in its result type for the upcoming
force_after_rebase option.

Co-Authored-By: Claude Opus 5.5 via omp-deck
Author
Collaborator

Decisions so far (git_rebase core):

  1. Start record carries onto and the expected order. The plan's record shape (branch, remote, remoteSha, origHead) can't build the old→new mapping after a continue in a later call, so rebase-start.json also stores onto and order (old full sha + expected subject, in new order; reword uses the new subject). The mapping pairs onto..HEAD against that order by subject, so it stays correct when git drops a commit as already upstream.
  2. dropped in the done result. Old commits without a rewritten counterpart are listed as dropped (sha7s) rather than silently vanishing from mapping.
  3. start includes its before-report in the result. commits (sha7 + subject) and upstreamGained are returned on both stopped and done outcomes, which is where the brief's "what changes before/after" lands.
  4. Non-conflict stops. If git stops without conflicts (e.g. a reword amend fails on signing), next carries git's output instead of the conflict instructions.
  5. Plan-file reading. Blank lines inside a reword message are kept when more indented text follows; trailing blanks are trimmed. Indented lines after pick are rejected with their line number.
  6. forced? on GitPushResult landed in the git_rebase commit with the rest of the protocol types; git_push starts setting it in the force-push commit.
Decisions so far (git_rebase core): 1. **Start record carries `onto` and the expected order.** The plan's record shape (`branch, remote, remoteSha, origHead`) can't build the old→new mapping after a `continue` in a later call, so `rebase-start.json` also stores `onto` and `order` (old full sha + expected subject, in new order; reword uses the new subject). The mapping pairs `onto..HEAD` against that order by subject, so it stays correct when git drops a commit as already upstream. 2. **`dropped` in the done result.** Old commits without a rewritten counterpart are listed as `dropped` (sha7s) rather than silently vanishing from `mapping`. 3. **`start` includes its before-report in the result.** `commits` (sha7 + subject) and `upstreamGained` are returned on both stopped and done outcomes, which is where the brief's "what changes before/after" lands. 4. **Non-conflict stops.** If git stops without conflicts (e.g. a reword amend fails on signing), `next` carries git's output instead of the conflict instructions. 5. **Plan-file reading.** Blank lines inside a reword message are kept when more indented text follows; trailing blanks are trimmed. Indented lines after `pick` are rejected with their line number. 6. **`forced?` on `GitPushResult`** landed in the git_rebase commit with the rest of the protocol types; `git_push` starts setting it in the force-push commit.
While a rebase is stopped or an interactive plan is pending, git_push,
git_branch create/switch/delete, git_fetch fast_forward and the Forgejo
PR create/update tools refuse, and a session_stop extension sends the
agent back to continue or abort whenever it tries to end its turn.
git_commit stays available.

Co-Authored-By: Claude Opus 5.5 via omp-deck
force_after_rebase pushes the rebased branch with a lease on the remote
sha recorded when the rebase started, only to the same remote and never
for the default branch. The permission is consumed by a successful push.

Co-Authored-By: Claude Opus 5.5 via omp-deck
Name git_rebase in the bash git-rule message and the deck orientation,
block it in idea mode, and document the rebase flow, the open-rebase
guards and force_after_rebase.

Co-Authored-By: Claude Opus 5.5 via omp-deck
Author
Collaborator

Decisions for the guards, force push and wiring:

  1. Gate test for git_commit during a conflict. While stopped on a conflict, the conflicted path counts as staged, so git_commit answers with its existing "already staged" guard (include_staged:true needed). The gate passes it through as the plan wants; the test pins that the answer comes from git_commit, not the gate. git_commit itself is unchanged.
  2. Stop guard covered by an automated test instead of only the manual check: it continues while stopped or plan-pending, returns nothing once clear, and never blocks outside a repository. The manual deck-session check from the plan has not been run; reviewers may want to try it.
  3. permissions-system-rule.test.ts needed no edit: it asserts against the GIT_BASH_RULE_MESSAGE constant, not a literal, so the new text flows through. Only git-bash-rule.test.ts gained git rebase origin/main in DENY.
  4. Docs: default-branch sentence. docs/git-tools.md said there is no default-branch guard; it now says a plain git_push has none, while git_rebase and force_after_rebase refuse the default branch. Also added a CHANGELOG entry and a ### git_rebase section.
  5. Force-push lease test with a moved remote added beyond the plan's scenario 6: another clone pushes to the branch after the rebase started, and force_after_rebase is refused with the stale-info message.
Decisions for the guards, force push and wiring: 7. **Gate test for `git_commit` during a conflict.** While stopped on a conflict, the conflicted path counts as staged, so `git_commit` answers with its existing "already staged" guard (`include_staged:true` needed). The gate passes it through as the plan wants; the test pins that the answer comes from git_commit, not the gate. `git_commit` itself is unchanged. 8. **Stop guard covered by an automated test** instead of only the manual check: it continues while stopped or plan-pending, returns nothing once clear, and never blocks outside a repository. The manual deck-session check from the plan has **not** been run; reviewers may want to try it. 9. **`permissions-system-rule.test.ts` needed no edit**: it asserts against the `GIT_BASH_RULE_MESSAGE` constant, not a literal, so the new text flows through. Only `git-bash-rule.test.ts` gained `git rebase origin/main` in DENY. 10. **Docs: default-branch sentence.** `docs/git-tools.md` said there is no default-branch guard; it now says a plain `git_push` has none, while `git_rebase` and `force_after_rebase` refuse the default branch. Also added a CHANGELOG entry and a `### git_rebase` section. 11. **Force-push lease test with a moved remote** added beyond the plan's scenario 6: another clone pushes to the branch after the rebase started, and `force_after_rebase` is refused with the stale-info message.
@ -0,0 +35,4 @@
base: string;
/** Full shas in original order, oldest first. */
commits: string[];
sessionId: string | null;
Owner

Why | null? When would this be needed without a session?

Why `| null`? When would this be needed without a session?
Author
Collaborator

It wasn't needed. The | null only mirrored the SDK's getSessionId?: () => string | null, and nothing ever read the field. Removed sessionId from the plan marker in a92af7b.

It wasn't needed. The `| null` only mirrored the SDK's `getSessionId?: () => string | null`, and nothing ever read the field. Removed `sessionId` from the plan marker in a92af7b.
lucas marked this conversation as resolved
@ -0,0 +102,4 @@
const marker = await readPlanMarker(root, signal);
if (marker?.planPath) await rm(marker.planPath, { force: true });
await removeGitPath(root, PLAN_MARKER, signal);
}
Owner

Why do you need three separate sets of functions for each marker type that implement exactly the same things?

Why do you need three separate sets of functions for each marker type that implement exactly the same things?
Author
Collaborator

No good reason; they differed only in file name and type. a92af7b replaces them with readMarker / writeMarker / clearMarker, keyed by kind ("plan" | "start" | "force") through a kind → type map, so each call is still typed. The one real difference stays in clearMarker: clearing the plan marker also deletes the plan file. RebaseStartRecord now holds onto/order directly, which removes the extending type and its cast in rebase.ts. git-tools tests: 153 pass; server typecheck clean.

No good reason; they differed only in file name and type. a92af7b replaces them with `readMarker` / `writeMarker` / `clearMarker`, keyed by kind (`"plan" | "start" | "force"`) through a kind → type map, so each call is still typed. The one real difference stays in `clearMarker`: clearing the plan marker also deletes the plan file. `RebaseStartRecord` now holds `onto`/`order` directly, which removes the extending type and its cast in `rebase.ts`. git-tools tests: 153 pass; server typecheck clean.
lucas marked this conversation as resolved
Replace the per-marker read/write/clear functions with readMarker,
writeMarker and clearMarker keyed by marker kind. The start record type
now carries onto and order itself instead of being extended in
rebase.ts, and the never-read sessionId field is gone from the plan
marker.

Co-Authored-By: Claude Opus 5.5 via omp-deck
lucas approved these changes 2026-10-20 09:58:34 +02:00
lucas merged commit a3a6017373 into main 2026-10-20 09:58:41 +02:00
lucas deleted branch 40-add-a-guarded-git-rebase-tool 2026-10-20 09:58:42 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
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!41
No description provided.