cli.sgit.ai / Research / Design 07: rebase (history rebase, pull --rebase, interactive todo)

Design 07: rebase (history rebase, pull --rebase, interactive todo)

Date2026-10-28 · 03:17 UTCKinddesignResearch topicsgitWritten bythe sgit-ai CLI team (Claude Code sessions), for Dinis CruzSourceteam/humans/dinis_cruz/claude-code-web/10/28/design__07__rebase.md in SGit-AI__CLI at 036e33b. Published here as written; the CLI repository is where the work happened.

Read with design__00__index-and-reconciliation.md: where the two disagree (shared foundations F1–F9, the in-progress rules), the index wins.

Roadmap item: 2.12 in roadmap__git-parity.md, moved to priority 2 on 28 Oct 2026. Status: design only. Nothing here is implemented. Checked against: this repository at 4a9fcfc (branch claude/charming-babbage-5hzcxq) and git 2.43.0. Git behaviour cited as measured was run in a scratch repository on 28 Oct; the transcripts are summarised in 1.2.

Depends on:

DocWhat this design uses from it
design__01__line-merge-and-cherry-pick.mdthree_way_merge(..., content_merger=, read_key=), Vault__Content_Merger, Vault__Merge_Style_Resolver, Enum__Merge_Operation, the new Schema__Merge_State fields, Vault__Merge.unresolved, Vault__Merge_Unresolved_Error, write_marker_files, Vault__Cherry_Pick.pick_message, _fetch_blobs_verified. Section 5.5 lists the exact API needed.
design__02__signing-with-your-own-key.mdSigning__Config__Resolver, User__Signer, Schema__Signing_Flags, create_commit(..., user_signer=)
design__03__partial-commits-and-hooks.mdVault__Hooks, Enum__Hook_Name, VERIFY_HOOKS, Vault__Hooks.environment (reused for exec)
design__05__bisect.mdthe generation-ordered painting walk in Vault__Bisect__Graph (proposed here to move into a shared Vault__Commit_Graph), Vault__Bisect__State.check_not_in_progress
design__06__worktree.mdnothing new; rebase state is per working copy, so it lives in each worktree's own .sg_vault/local/

1. Summary and the exact Git behaviour matched

1.1 In one paragraph

Rebase replays this clone's own commits that the named branch does not yet hold on top of another commit, usually the named branch's newest head. Each replayed commit is a cherry-pick: the change it made relative to its parent is applied to the new tip. Merge commits in the range are dropped and the history becomes linear. Commits whose change is already upstream are skipped. A conflict stops the rebase with the state saved; the user resolves and runs --continue, or --skip, or --abort to get exactly back to where they were. pull --rebase fetches (with every existing guard) and then rebases instead of merging. Interactive mode (-i) edits a todo list: pick, reword, edit, squash, fixup, drop, exec. Agents give the todo as a file or JSON, with no editor.

Two sgit rules on top of Git:

  1. Rebase never rewrites a commit the server already has. Every commit in the todo must be unreachable from the named head this clone holds and from the remote head it last accepted (its baseline). This is the rule commit --amend and history undo already use (Vault__Sync__History_Edit.is_pushed, sgit_ai/core/actions/history/Vault__Sync__History_Edit.py:26-36). Because of it, the next push is always a fast-forward of the named branch, and no other clone ever sees a rewind.
  2. A replayed commit is signed by this clone, like every commit sgit makes. A commit signed by another clone's key is not replayed unless the user asks (--allow-foreign), because replaying would re-attribute it.

1.2 Git behaviour matched (git 2.43, measured where marked)

BehaviourGitsgit
What is replayedgit rev-list --reverse --topo-order --no-merges <upstream>..HEAD minus patch-equivalent upstream commits. Measured: a local commit identical in effect to an upstream one was left out of the todo before it started (end = 1 with two local commits).Same set, computed with the generation-ordered painting walk (5.4). Equivalence by plaintext content hash (5.4.3).
Merge commits in the rangeDropped; the commits from both sides of the merge are linearised in topological order. Measured: after, a merge of side1, and mer1 became mer1, side1, after.Same. --rebase-merges is a non-goal.
Commits that become empty after replay--empty=drop by default (drop, keep, ask/stop).--empty drop|keep|stop, default drop.
--reapply-cherry-picksOff by default: patch-equivalent commits skipped.Same flag, same default.
ConflictStops with exit 1. The file holds markers; ours is the upstream side (HEAD), theirs is the commit being replayed. Measured marker labels: <<<<<<< HEAD / >>>>>>> b8e5f4f (mine1).Stops with exit 1. With --merge files (default) the working file is the new tip's version and <path>.conflict holds the replayed commit's; with --merge lines, markers (design__01). ours = new tip, theirs = the replayed commit, as in Git.
State directory.git/rebase-merge/: git-rebase-todo, done, onto, orig-head, head-name, msgnum, end, message, author-script, stopped-sha, interactive, drop_redundant_commits, … Measured..sg_vault/local/rebase/state.json (one Type_Safe document) plus the editable todo text file (5.3).
--continueCommits the resolved changes with the original message (author kept), then carries on.Same.
--skipDrops the commit that stopped and carries on. Measured: skipping the conflicting mine left mine2 replayed.Same.
--abortRestores the original branch head and working tree. Measured reflog line: rebase (abort): returning to refs/heads/master.Same: clone ref, working copy, autostash. Reflog reason rebase (abort).
--quitForgets the rebase, leaves HEAD and the tree as they are.Same.
ReflogMeasured: rebase (start): checkout up, rebase (pick): mine2, rebase (continue): mine1, rebase (finish): returning to refs/heads/master. @{1} of the branch is the pre-rebase head.Reflog entries carry a reason. The finishing entry records the pre-rebase head as its old value, so history undo after a rebase goes back to before it in one step (5.11).
ORIG_HEADSet to the pre-rebase head..sg_vault/local/ORIG_HEAD, resolvable by Vault__Revision (history reset ORIG_HEAD).
TimestampsAuthor date kept, committer date new; --reset-author-date, --committer-date-is-author-date.sgit has one timestamp_ms (no author/committer split). Kept by default (it is what log shows, like Git's author date); --reset-date sets it to now.
AuthorKept; committer is the rebaser.The signer is the author in sgit. Own commits: unchanged identity. Foreign commits: refused unless --allow-foreign (decision D3).
Hooks (measured, -i with one reword)pre-rebase <upstream>; post-checkout <old> <onto> 1; a reword runs pre-commit, prepare-commit-msg … commit HEAD, commit-msg, post-commit, post-rewrite amend; each pick runs prepare-commit-msg … message and post-commit; at the end post-rewrite rebase with <old> <new> lines on stdin.Same set and order (5.13).
--onto <newbase>, --keep-baseSupported.Supported.
-x/--exec <cmd>Inserts exec after each pick; runs through the shell.Supported; runs an argv, no shell by default (decision D6).
--autosquashMoves fixup! X / squash! X under X.Supported.
--autostashStashes before, pops after; on a failed pop "Your changes are safe in the stash."Same, with vault stash.
pull --rebase, pull.rebasetrue, false, merges, interactive.--rebase / --no-rebase; per-clone pull_rebase = off or on. merges and interactive refused with a message (non-goals).
Upstream default@{upstream} with --fork-point (reflog of the upstream).The tracked named branch as this clone holds it (local named ref). Fork point only on an accepted rewind (5.12.3).

2. Scope and non-goals

In scope (v1):

Non-goals (v1), each with the reason:

Not in v1Why
Rewriting commits already on the server (--rewrite-pushed)It makes every other clone refuse the next pull as a rewind (by design). The deliberate path exists: history reset + push --force + pull --accept-rewind. Decision D2.
--rebase-merges / pull.rebase=mergesNeeds label/reset/merge todo verbs and a merge replay; rarely used by agents.
--rootRebasing the vault's first commit is a vault rewrite (vault move territory).
--update-refssgit has no stacked local branches pointing into the range.
Splitting a commit during edit (several commits between stops)--continue after edit amends the stopped commit (5.9.1). Making several commits at an edit stop is a follow-up (decision D8).
sgit commit during a stopRefused with "use history rebase --continue" (5.15). Git allows it; the follow-up is the same as D8.
A sgit config commandShared with design__01 D10: the field and the flag land now, the command with the next settings need.
Fork point from the upstream's reflogsgit keeps no reflog of the named ref's server history. The baseline replaces it for the one case that matters (an accepted rewind, 5.12.3).

3. CLI surface

3.1 Placement (Rule 9)

Decision D1 asks whether a top-level sgit rebase alias is wanted. The recommendation is no: pull --rebase covers the daily case, and Git users find history rebase by sgit help history.

3.2 sgit history rebase

sgit history rebase [<upstream>] [directory]
                    [--onto NEWBASE | --keep-base]
                    [-i | --interactive] [--todo FILE|-] [--todo-format text|json]
                    [-x CMD | --exec CMD]...
                    [--autosquash] [--autostash]
                    [--empty drop|keep|stop] [--reapply-cherry-picks]
                    [--merge files|lines] [--conflict merge|diff3]
                    [--reset-date] [--allow-foreign] [--no-verify]
                    [--sign | --no-sign] [--dry-run] [--json]
sgit history rebase --continue [-m MSG] [--json] [directory]
sgit history rebase --skip     [--json] [directory]
sgit history rebase --abort    [--json] [directory]
sgit history rebase --quit     [--json] [directory]
sgit history rebase --show-todo [--json] [directory]
sgit history rebase --edit-todo [--todo FILE|-] [directory]
FlagDefaultMeaning
<upstream>the tracked named branch as this clone holds it (local named ref, verified on the last pull)Any revision Vault__Revision.resolve_soft accepts (sgit_ai/core/actions/history/Vault__Revision.py:44): a commit id or prefix, a tag, HEAD~3, @{2}, ORIG_HEAD. A named branch name resolves to its local named ref.
--onto NEWBASE<upstream>Replay upstream..HEAD on NEWBASE.
--keep-baseoffNEWBASE = merge base of upstream and HEAD (reword or squash without moving). Excludes --onto.
-ioffOpen the todo in the sequence editor: SGIT_SEQUENCE_EDITOR, then GIT_SEQUENCE_EDITOR, VISUAL, EDITOR, then vi.
--todo FILE / --todo -noneNon-interactive todo: a file, or stdin. Implies interactive semantics without an editor.
--todo-formatdetected: a first non-blank character [ or { means json, else textFormat of --todo.
-x CMDnoneAdd exec CMD after every pick (repeatable, in order).
--autosquashoffReorder fixup! <subject> / squash! <subject> commits under their target.
--autostashoffvault stash before, vault stash pop after (and after --abort).
--emptydropA commit whose replay changes nothing: drop it, keep it (an empty commit), or stop.
--reapply-cherry-picksoffDo not skip local commits whose change is already upstream.
--merge, --conflictthe clone's merge_style / conflict_style (design__01)How a file both sides changed is merged.
--reset-dateoffReplayed commits get the current time, not the original timestamp_ms.
--allow-foreignoffReplay commits signed by another clone's key, re-signed by this clone (D3).
--no-verifyoffSkip pre-rebase (Git's scope for rebase).
--sign / --no-signdesign__02's resolutionAdd or omit the user signature on replayed commits.
--dry-runoffPrint the computed todo and what would be skipped; write nothing.
--jsonoffOne JSON object on stdout (3.6).

--continue -m MSG sets the message of the commit being concluded: a conflict stop, a reword stop in non-interactive mode, an edit stop, or the end of a squash group.

3.3 sgit pull --rebase

sgit pull [--rebase | --no-rebase] [--autostash] [--merge files|lines] [--conflict merge|diff3]
          [--accept-rewind] [--no-checkout] [directory]

New parser lines in sgit_ai/cli/CLI__Main.py:398-406; CLI__Vault.cmd_pull (sgit_ai/cli/CLI__Vault.py:963) passes rebase= and autostash=.

3.4 Todo formats

Text (Git's format, so -i feels familiar). File: .sg_vault/local/rebase/todo.

pick 3f9a1c2b7e01 Add the field
reword 9b2c4d5e6f70 Fix typo in README
edit 0a1b2c3d4e5f Split parser
squash 7e7e7e7e7e7e squash! Add the field
fixup -C 4c4c4c4c4c4c Better message for the fixup
drop 5d5d5d5d5d5d WIP
exec pytest -q tests/unit/schemas
break

# Rebase 1d2c3b4a5e6f..3f9a1c2b7e01 onto 1d2c3b4a5e6f (6 commands)
#
# Commands:
# p, pick <commit> = use commit
# r, reword <commit> = use commit, but edit the commit message
# e, edit <commit> = use commit, but stop for amending
# s, squash <commit> = use commit, but meld into previous commit
# f, fixup [-C | -c] <commit> = like "squash" but keep only the previous
#                    commit's message, unless -C is used, in which case
#                    keep only this commit's message; -c is same as -C but
#                    opens the editor
# x, exec <command> = run command (an argv, split like a shell would, no shell)
# b, break = stop here (continue rebase later with 'sgit history rebase --continue')
# d, drop <commit> = remove commit
#
# Lines starting with # are ignored. An empty todo aborts the rebase.

Rules: one action per line; the commit is any prefix of 4+ hex characters after obj-cas-imm- that resolves uniquely among local commits (Vault__Revision._commit_by_prefix, Vault__Revision.py:128); the rest of a pick line is a comment. Unknown verbs and unresolvable commits refuse the whole todo before anything is written, naming the line.

JSON (agents). Same verbs; messages inline, so nothing has to stop for a reword:

{
  "todo": [
    {"action": "pick",   "commit": "3f9a1c2b7e01"},
    {"action": "reword", "commit": "9b2c4d5e6f70", "message": "Fix typo in README"},
    {"action": "edit",   "commit": "0a1b2c3d4e5f"},
    {"action": "squash", "commit": "7e7e7e7e7e7e", "message": "Add the field (with its tests)"},
    {"action": "fixup",  "commit": "4c4c4c4c4c4c", "use_message": "this"},
    {"action": "drop",   "commit": "5d5d5d5d5d5d"},
    {"action": "exec",   "argv": ["pytest", "-q", "tests/unit/schemas"]},
    {"action": "break"}
  ]
}

A bare JSON array is accepted too. fixup.use_message is previous (default, Git's plain fixup) or this (Git's -C). An exec takes argv (a list) or command (a string split with shlex.split). Validation is a Type_Safe parse into Schema__Rebase_Todo (5.2); any error refuses before anything is written.

reword with no message: interactive mode opens the editor; non-interactive mode stops with reason reword and waits for --continue -m MSG.

3.5 Output (plain text)

Success:

Rebasing 3 commit(s) of clone-7f3a onto main @ 1d2c3b4a5e6f
  pick   3f9a1c2b7e01 → 8e8e8e8e8e8e  Add the field
  skip   9b2c4d5e6f70                Fix typo in README (already upstream)
  pick   0a1b2c3d4e5f → 2b2b2b2b2b2b  Split parser
Rebased: 2 replayed, 1 skipped, 0 dropped empty. Head 2b2b2b2b2b2b (was 0a1b2c3d4e5f).
  hint: sgit push to publish; sgit history undo returns to before the rebase.

Stop on a conflict (--merge files):

Rebasing 3 commit(s) of clone-7f3a onto main @ 1d2c3b4a5e6f
  pick   3f9a1c2b7e01 → 8e8e8e8e8e8e  Add the field
CONFLICT applying 0a1b2c3d4e5f "Split parser" (2/3):
  src/parser.py          both changed: yours (the new base) is in the file,
                         the commit's version is in src/parser.py.conflict
Rebase stopped. Resolve each file (edit it, or sgit resolve <file> --ours/--theirs), then:
  sgit history rebase --continue      (or --skip to drop this commit, --abort to undo the whole rebase)

Stop on edit, break, a failed exec and reword (non-interactive) use the same layout with one reason line: Stopped at 0a1b2c3d4e5f "Split parser" (edit): change files, then sgit history rebase --continue [-m MSG]; Stopped (break); exec failed (exit 3): pytest -q tests/unit/schemas — fix, then --continue (the exec is not re-run); Stopped at 9b2c… (reword): sgit history rebase --continue -m "<new message>".

Abort: Rebase aborted: head back at 0a1b2c3d4e5f, working copy restored (2 file(s) rewritten, 1 removed).

3.6 Exit codes and --json

ExitWhen
0Finished, or nothing to do ("Current branch is up to date"), or --dry-run, --quit, --abort, --show-todo succeeded
1Stopped with a rebase in progress (conflict, edit, break, reword, exec failure), or refused before anything was written (sgit's convention for refusals, CLI__Main._print_friendly_error, sgit_ai/cli/CLI__Main.py:1195-1218). --json tells them apart through status.

--json shape (stdout, one object; human text goes to stderr):

{
  "status": "done | stopped | up_to_date | aborted | quit | refused | dry_run",
  "reason": "conflict | edit | break | reword | exec_failed | empty | missing_blob | null",
  "onto": "obj-cas-imm-…", "upstream": "obj-cas-imm-…", "orig_head": "obj-cas-imm-…",
  "head": "obj-cas-imm-…",
  "total": 3, "done": 2,
  "steps": [
    {"action": "pick", "commit": "obj-cas-imm-…", "new_commit": "obj-cas-imm-…", "result": "replayed"},
    {"action": "pick", "commit": "obj-cas-imm-…", "new_commit": null, "result": "skipped_upstream"},
    {"action": "pick", "commit": "obj-cas-imm-…", "new_commit": null, "result": "conflict"}
  ],
  "stopped_at": {"commit": "obj-cas-imm-…", "action": "pick", "subject": "Split parser"},
  "conflicts": [{"path": "src/parser.py", "kind": "whole-file", "hunks": 0}],
  "exec": {"argv": ["pytest","-q"], "exit_code": 3},
  "rewritten": [["obj-cas-imm-old", "obj-cas-imm-new"]],
  "autostash": {"stashed": true, "popped": false, "stash": "stash__1761600000000.zip"},
  "message": "human-readable one line"
}

result values: replayed, skipped_upstream, dropped_empty, kept_empty, dropped (todo drop), squashed, conflict, stopped, exec_ok, exec_failed.

3.7 sgit status during a rebase

Vault__Sync__Status.status (sgit_ai/core/actions/status/Vault__Sync__Status.py:216-229) gains a rebase_info block next to merge_info:

rebase_in_progress : bool
rebase_onto, rebase_orig_head, rebase_stopped_at : str
rebase_reason      : str
rebase_done, rebase_total : int

CLI text:

Rebase in progress onto 1d2c3b4a5e6f: 1 of 3 done, stopped at 0a1b2c3d4e5f "Split parser" (conflict)
  (resolve, then sgit history rebase --continue; --skip drops this commit; --abort undoes the rebase)

4. How the code works today

AreaWhereWhat it does now (and why it matters here)
Pull pipelinesgit_ai/workflow/pull/Workflow__Pull.py:18-24derive_keys → load_branch_info → fetch_remote_ref → fetch_missing → merge. Rebase reuses the first four steps unchanged.
Rewind guardStep__Pull__Fetch_Remote_Ref.py:15-56 (_guard_rewind), :102-106A remote head that does not descend from the accepted baseline is refused (or, with --accept-rewind, recorded as workspace.rewound_from). Nothing is written in this step.
Verify-then-acceptStep__Pull__Fetch_Missing.py:26 (require_signatures), :71-85The only writer of the local named ref and the baseline. Rebase never writes either.
Merge stepStep__Pull__Merge.py:20-216FF (:99-118), three-way (:119-185, merge commit at :181, ref at :182), conflicts write .conflict + merge state (:143-154).
Rewind re-applyStep__Pull__Merge.py:245-310 (_take_rewind)Already a rebase-like replay: the clone's own work since lca(clone, rewound_from) is re-applied onto the new head, squashed into one commit, and any conflict refuses. pull --rebase replaces this with a per-commit replay (5.12.3).
Working-copy writeStep__Pull__Merge.py:218-243 (_write_selective), :312-321 (_changed_entries), :327-362 (_guard_working_tree)Deletions first, only changed entries, selective working copies honoured, unwritable files refuse before the head moves. Rebase materialisation reuses all three (5.7).
ResetVault__Sync__Pull.py:22-78Flattens the target, removes, checks out every entry, writes the clone ref. Used by merge-abort and history undo. Writes every file; rebase --abort uses the changed-entries path instead (5.9.3).
Merge stateVault__Merge__State.py:9 (merge_state.json), :42-49 check_not_in_progress (called by pull Vault__Sync__Pull.py:88, push Vault__Sync__Push.py:50)The pattern for "an operation is in progress". design__01 adds operation and more fields; rebase adds operation = rebase (5.8.1).
Merge abort / resolveVault__Merge__Abort.py:13-39; Vault__Merge__Resolve.py:9-94Abort resets to ours_commit_id; resolve removes or promotes .conflict files. Both are reused at a rebase conflict stop; abort is redirected to the rebase abort (5.15).
Three-way classifierVault__Merge.py:14-80Compares blob ids. Blob encryption uses a random IV (Vault__Sub_Tree.encrypt_or_reuse_blob, Vault__Sub_Tree.py:517-529), so the same content written by two clones has two blob ids and counts as "both changed differently". Rebase normalises by content hash first (5.5.3).
History editsVault__Sync__History_Edit.py:26-36 is_pushed; :39-57 undo; :60-113 revert_commit; :116-124 _require_cleanThe "already on the server" rule and the clean-tree requirement, both reused.
AmendVault__Sync__Commit.py:205-229 (_amend_plan)Refuses on a pushed head or a merge; keeps parents. The edit stop's --continue follows the same rule (5.9.1).
Commit creationVault__Commit.py:34-36 signing_bytes; :42-76 create_commit (timestamp_ms parameter exists)Rebase passes the original timestamp_ms and message_enc (5.5.6).
Signing keyVault__Sync__Commit.py:20 _load_signing_key, used at :162, :327The clone key for replayed commits.
Ref writes and reflogVault__Ref_Manager.write_ref (sgit_ai/storage/Vault__Ref_Manager.py:16-32) appends old → new to Vault__Reflog (sgit_ai/storage/Vault__Reflog.py:31-46, MAX_ENTRIES = 1000 at :22). No reason field.Rebase adds an optional reason and an old override (5.11).
BaselinesVault__Sync__Base.py:163-283 (_remote_baselines, _accepted_head :257, _write_remote_baseline :269)"Only a guarded pull or merge, an accepted rewind, or this clone's own successful push moves a baseline." Rebase moves none.
RevisionsVault__Revision.py:27 resolve, :44 resolve_soft, :53 head, :62 head_ref_id, :128 _commit_by_prefix, :149 _reflogUpstream, --onto and todo commits are resolved here; ORIG_HEAD is added.
LCAVault__Fetch.find_lca (sgit_ai/core/actions/fetch/Vault__Fetch.py:78)Collects at most 200 ancestors of each side (limit=200). On a long history it can return a wrong or no base. Rebase uses the generation walk instead (5.4.1).
GenerationsVault__Pack_Index.generation_of (sgit_ai/core/actions/pack/Vault__Pack_Index.py:137-156)Memoised in .sg_vault/local/index/generations.json; works on loose vaults.
Tree diffVault__Sub_Tree.diff_trees (Vault__Sub_Tree.py:259-304)Opens only folders whose ids differ: a commit's change set at the cost of the change.
Tree buildbuild (:32), build_from_flat (:105-131), core _build_tree_from_dir_contents (:556-642), _entry_from_flat (:94-103), _store_tree (:644, deterministic encryption)build_from_flat re-encrypts every folder (it passes no old_tree_ids). On 58,553 folders that is the cost a rebase must not pay per commit; hence apply_changes (5.6).
PushVault__Sync__Push.push (Vault__Sync__Push.py:26): implicit pull at :177-182, chain at :252 (fetch_commit_chain(..., stop_at=named_commit_id)), named ref CASAfter a rebase the clone head descends from the named head: a fast-forward push of the new chain.
Branch-only pushVault__Sync__Push.py:634-735, stop_at = remote_head or local named head at :664If the clone's own server ref was pushed before a rebase, remote_head is no longer an ancestor and the chain walk would not stop there. Fixed in 5.16.
StashVault__Stash.stash (sgit_ai/core/actions/stash/Vault__Stash.py:27), pop (:76)Zip-based; reused for --autostash.
History CLIsgit_ai/plugins/history/CLI__History.py:70-170, plugin.jsonWhere rebase is registered.
StatusVault__Sync__Status.py:216-229Reports merge state; gains rebase state.

5. Design

5.1 Terms and invariants

TermMeaning
H / orig_headThis clone's head (its clone-branch ref) when the rebase starts.
U / upstreamThe commit whose ancestors are "already upstream". Default: the local named ref of the tracked named branch.
O / ontoWhere the replay starts. Default U; --onto X; --keep-base → merge base of U and H.
BThe set of baselines and held heads: {U} ∪ local named ref ∪ the accepted baseline for the tracked branch (_read_remote_baseline).
rangeCommits reachable from H and from none of B, merges excluded.
tipThe newest commit of the rebased history so far (starts at O).
materializedThe commit whose tree the working copy holds (plus conflict files at a stop).

Invariants (each has a test in 9):

5.2 New types and schemas

All Type_Safe, all in the paths named; no raw primitives.

NameFileKindDefinition
Enum__Rebase_Actionsgit_ai/safe_types/Enum__Rebase_Action.pyEnumPICK='pick', REWORD='reword', EDIT='edit', SQUASH='squash', FIXUP='fixup', DROP='drop', EXEC='exec', BREAK='break'
Enum__Rebase_Empty…/Enum__Rebase_Empty.pyEnumDROP='drop', KEEP='keep', STOP='stop'
Enum__Rebase_Stop_Reason…/Enum__Rebase_Stop_Reason.pyEnumCONFLICT, EDIT, BREAK, REWORD, EXEC_FAILED, EMPTY, MISSING_BLOB (lower-case values)
Enum__Rebase_Step_Result…/Enum__Rebase_Step_Result.pyEnumREPLAYED, SKIPPED_UPSTREAM, DROPPED_EMPTY, KEPT_EMPTY, DROPPED, SQUASHED, CONFLICT, STOPPED, EXEC_OK, EXEC_FAILED
Enum__Fixup_Message…/Enum__Fixup_Message.pyEnumPREVIOUS='previous', THIS='this', THIS_EDIT='this-edit'
Enum__Pull_Rebase…/Enum__Pull_Rebase.pyEnumOFF='off', ON='on'
Safe_Str__Rebase_Message…/Safe_Str__Rebase_Message.pySafe_StrCommit message text: max_length = 65536, newlines allowed, NUL refused (regex replaces \x00), trim_whitespace = False, allow_empty = True
Safe_Str__Exec_Arg…/Safe_Str__Exec_Arg.pySafe_StrOne argv element: max_length = 4096, NUL refused, everything else verbatim
Safe_UInt__Rebase_Step…/Safe_UInt__Rebase_Step.pySafe_UIntA todo index
Schema__Rebase_Todo_Itemsgit_ai/schemas/rebase/Schema__Rebase_Todo_Item.pyType_Safeaction : Enum__Rebase_Action, commit_id : Safe_Str__Commit_Id = None, message : Safe_Str__Rebase_Message = None, use_message : Enum__Fixup_Message = None, argv : list[Safe_Str__Exec_Arg]
Schema__Rebase_Todo…/Schema__Rebase_Todo.pyType_Safeitems : list[Schema__Rebase_Todo_Item]
Schema__Rebase_Done_Item…/Schema__Rebase_Done_Item.pyType_Safeitem : Schema__Rebase_Todo_Item, result : Enum__Rebase_Step_Result, new_commit_id : Safe_Str__Commit_Id = None
Schema__Rebase_Rewrite…/Schema__Rebase_Rewrite.pyType_Safeold_commit_id, new_commit_id : Safe_Str__Commit_Id (for post-rewrite and --json)
Schema__Rebase_State…/Schema__Rebase_State.pyType_Safebelow
class Schema__Rebase_State(Type_Safe):
    schema_version     : Safe_UInt__Vault_Version  = None   # 1
    started_at         : Timestamp_Now             = None
    clone_branch_id    : Safe_Str__Branch_Id       = None   # whose ref this rebase moves
    clone_ref_id       : Safe_Str__Ref_Id          = None
    named_branch_name  : Safe_Str__Branch_Name     = None   # display only
    orig_head          : Safe_Str__Commit_Id       = None   # H
    upstream           : Safe_Str__Commit_Id       = None   # U
    onto               : Safe_Str__Commit_Id       = None   # O
    held_heads         : list[Safe_Str__Commit_Id]          # B, for re-checking I1 on an edited todo
    tip                : Safe_Str__Commit_Id       = None   # newest rebased commit so far
    materialized       : Safe_Str__Commit_Id       = None   # what the working copy and the clone ref hold
    materializing_to   : Safe_Str__Commit_Id       = None   # set while the working copy is being written (5.7)
    todo               : list[Schema__Rebase_Todo_Item]
    done               : list[Schema__Rebase_Done_Item]
    total              : Safe_UInt__Rebase_Step
    rewritten          : list[Schema__Rebase_Rewrite]
    stopped_reason     : Enum__Rebase_Stop_Reason  = None
    stopped_item       : Schema__Rebase_Todo_Item  = None
    squash_base        : Safe_Str__Commit_Id       = None   # parent of the squash group being built
    squash_messages    : list[Safe_Str__Rebase_Message]     # messages collected so far in a squash group
    interactive        : bool                      = False
    empty              : Enum__Rebase_Empty        = None   # None = drop
    reapply_cherry_picks : bool                    = False
    reset_date         : bool                      = False
    allow_foreign      : bool                      = False
    merge_style        : Enum__Merge_Style         = None   # design__01
    conflict_style     : Enum__Conflict_Style      = None
    signing            : Schema__Signing_Flags     = None   # design__02
    autostash          : Safe_Str__File_Path       = None   # the stash zip name, when one was made
    from_pull          : bool                      = False  # started by pull --rebase (output wording only)

Additions to existing schemas (all optional; older builds load newer files and the reverse, because Type_Safe.from_json accepts unknown keys and missing fields, verified on Schema__Object_Commit):

SchemaFieldPurpose
Schema__Local_Config (sgit_ai/schemas/Schema__Local_Config.py:9-22)pull_rebase : Enum__Pull_Rebase = NoneGit's pull.rebase; None = off
rebase_autostash : bool = FalseGit's rebase.autoStash
Schema__Reflog_Entry (sgit_ai/schemas/Schema__Reflog_Entry.py)reason : Safe_Str__Reflog_Reason = Nonerebase (start), rebase (pick): <subject>, rebase (finish), rebase (abort); history reflog prints it
Enum__Merge_Operation (design__01)REBASE='rebase'A conflict stop writes merge state with this operation
Enum__Hook_Name (design__03)PRE_REBASE='pre-rebase', POST_REWRITE='post-rewrite'5.13

New Safe_Str__Reflog_Reason: printable, max_length = 200, newlines replaced.

5.3 State files and persistence

.sg_vault/local/rebase/
    state.json      Schema__Rebase_State (0600, Vault__Secret_File: unique temp file, fsync, rename)
    todo            the editable text todo (0600), written for -i / --edit-todo / --show-todo
    message         the message being edited for reword/squash (0600), like Git's COMMIT_EDITMSG
.sg_vault/local/ORIG_HEAD     one commit id (0600)
.sg_vault/local/merge_state.json   only during a conflict stop (operation = rebase)

New class Vault__Rebase__State(Type_Safe) in sgit_ai/core/actions/rebase/Vault__Rebase__State.py, modelled on Vault__Merge__State (Vault__Merge__State.py:12-49):

MethodBehaviour
dir_path(directory) -> str<local>/rebase
exists(directory) -> boolstate.json exists
read(directory) -> Schema__Rebase_StateParses; a damaged file raises Vault__Rebase_Error naming the file and the remedies --abort (needs orig_head, so it is read leniently first) and --quit
write(directory, state)Atomic (temp + fsync + rename), 0600
delete(directory)Removes the folder
check_not_in_progress(directory, action)Raises Vault__Rebase_In_Progress_Error: Cannot <action>: a rebase is in progress (<done>/<total>, stopped at <id>). Finish it with 'sgit history rebase --continue' or undo it with '--abort'.

Every mutation follows write-ahead order: create objects (immutable, harmless if orphaned) → write state → write working copy → write the clone ref → update state (materialized). A crash at any point leaves a state from which --continue or --abort is correct (5.7.3).

5.4 Building the todo

New class Vault__Rebase__Planner(Type_Safe) in sgit_ai/core/actions/rebase/Vault__Rebase__Planner.py.

5.4.1 The range

plan(directory, c, upstream_spec, onto_spec, keep_base, reapply, allow_foreign) -> Schema__Rebase_Plan

  1. Resolve. H = clone ref (Vault__Revision.head). U = resolve_soft(upstream_spec), or the local named ref of the tracked named branch (branch_manager.tracked_named_branch as in is_pushed, Vault__Sync__History_Edit.py:30-34). No upstream and no argument: refuse ("this clone has never pulled its named branch: name a commit to rebase onto").
  2. Held heads B = {U, local named ref, _read_remote_baseline(tracked named ref)} minus empty.
  3. Range walk. A generation-ordered painting walk, Git's paint_down_to_common with generation numbers instead of commit dates (sgit timestamps are not monotonic once rebased commits keep their dates):

This walk is the one design__05 specifies in Vault__Bisect__Graph.candidates (design__05__bisect.md:277-283). Proposal (decision D9): move it into sgit_ai/core/actions/history/Vault__Commit_Graph.py (parents, generation, paint(include, exclude) -> dict, range(include, exclude) -> list, merge_bases(a, b) -> list) and have bisect and rebase both use it. The existing find_lca (200-ancestor limit, 4 above) is not used.

  1. Shallow boundaries. A boundary commit (Vault__Scope.boundary_ids()) has no local parents: the walk stops there. If the OURS side reaches a boundary that the HELD side never reached, the range cannot be proven (part of it may be upstream): refuse with "this shallow clone does not hold enough history to tell your commits from the upstream's; sgit fetch --unshallow". An unpushed range that sits wholly above the boundary always meets the held side first.
  2. Order. Topological, oldest first, ties by generation then by timestamp_ms then id (Git's --reverse --topo-order).
  3. Merges. Drop commits with more than one parent (Git's default; result = dropped is not recorded for them: they are simply not in the todo, as in Git).
  4. --keep-base. O = the single merge base of U and H (Vault__Commit_Graph.merge_bases); several bases: refuse naming them.
  5. Up to date. If the range is empty and O is an ancestor of H (or equal), the result is up_to_date: "Current head <H> is up to date." Exit 0, nothing written.
  6. Fast-forward. If the range is empty and H is an ancestor of O: move to O (no replay; reflog rebase (finish)), like Git's "Fast-forwarded".

5.4.2 Checks on every todo commit (also re-run on an edited todo)

CheckRuleRefusal text
I1 pushedVault__Commit_Graph painting from B: the commit must not be HELD<id> is already on the server (the named branch holds it); a rebase only rewrites commits that are not pushed. To undo a pushed commit for everyone: sgit history revert --as-commit --commit <id>.
LocalThe commit object and its first parent's tree are local (obj_store.exists)<id> is not in this clone (sgit fetch first)
Foreigncommit.author_key_id (or, for older commits, the branch index mapping, as Vault__Signatures._key_for does, sgit_ai/core/actions/verify/Vault__Signatures.py:28-39) equals this clone's public key id, or --allow-foreign<id> was written by another clone (key <kid>); replaying it would sign it as this clone. Merge instead, or pass --allow-foreign to re-sign it.
ScopeOn a scoped clone, the commit's change set (5.5.1) is inside the held folders (Vault__Scope.paths_outside)<id> changes folders this clone does not hold (<paths>); rebase it from a full clone or widen this one (sgit fetch <folder>)
Signature policyWith signatures-required, a foreign commit must verify (Vault__Signatures.status_of == VERIFIED) before it is re-signed: the user must not launder an unsigned commit through a rebase<id> is <status>; this vault requires signed commits

5.4.3 Skipping commits already upstream (--reapply-cherry-picks off)

Git compares patch ids. sgit compares change signatures, computed from the plaintext content hashes that tree entries already carry (content_hash = sha256(plaintext)[:12], design__01 4):

signature(c) = sorted( (path, old content_hash or '-', new content_hash or '-') for each changed path )
             where the change set is Vault__Sub_Tree.diff_trees(c.tree, c.parent.tree)

5.4.4 Interactive and file todos

5.5 Replaying one commit

New class Vault__Rebase__Replay(Type_Safe) in sgit_ai/core/actions/rebase/Vault__Rebase__Replay.py. replay(c, state, item, tip) -> Schema__Rebase_Replay_Result with result : Enum__Rebase_Step_Result, new_tree_id, changes : dict (see below), conflicts, marker_conflicts, line_merged.

Let C be the commit, P its first (only) parent, T the current tip.

5.5.1 The change set

delta = Vault__Sub_Tree.diff_trees(C.tree_id, P.tree_id, read_key) (Vault__Sub_Tree.py:259). It returns added, deleted, modified by walking only the folders whose ids differ (I7). paths = added ∪ deleted ∪ modified.

On a pack vault whose commit index is present and verified, the commit index's change-list member gives the same set without a walk (Vault__Pack_Index__Reader); this is an optimisation behind the same interface, not a requirement.

5.5.2 The three sub-maps

For each p in paths:

5.5.3 Normalising equal content

Before classifying, for each p where ours[p] and theirs[p] (or base[p]) exist with equal content_hash and equal size but different blob_id: rewrite the blob_id of the later side to the earlier side's id (theirs[p].blob_id = ours[p].blob_id; base likewise). The random-IV blob encryption (4) otherwise makes "both sides made the same change" a conflict. This lives in Vault__Rebase__Replay._unify_equal_content and is proposed to design__01 for three_way_merge itself (cross-doc item X1, 11).

5.5.4 Classify and merge (the API needed from design__01)

merge = Vault__Merge(crypto=…).three_way_merge(base, ours, theirs,
                                               content_merger=merger_or_None, read_key=read_key)

with the restricted maps above. The classifier is per path (Vault__Merge.py:19-78), so restricting the maps to the changed paths gives exactly the result of the full maps on those paths, and paths outside delta keep T's entries. Required from design__01:

Neededdesign__01 nameNotes
Per-path three-way classification with an optional content mergerthree_way_merge(base_map, ours_map, theirs_map, content_merger=None, read_key=None) returning merged_map, conflicts, added, modified, deleted, line_merged, marker_conflicts, fallbacksCalled with restricted maps
A merger built for the clone's style and the stop labelsVault__Content_Merger + Vault__Merge_Style_Resolver.resolve(flag, local_config)Labels: ours rebase onto <O short> / new tip, theirs <C short> (<subject>), both through Safe_Str__Conflict_Label
Missing-blob fetch, verifiedVault__Sync__Base._fetch_blobs_verified(directory, blob_ids)Only on sparse/selective clones (7)
Writing conflict fileswrite_conflict_files(..., skip=) and write_marker_files(...)At a stop only
The unresolved checkVault__Merge.unresolved(directory, state, seen) + Vault__Merge_Unresolved_ErrorAt --continue
The pick messageVault__Cherry_Pick.pick_message(P, record_origin=False)Decrypt-or-fallback of the message

If design__01 changes a name, design__01 wins; this table is the contract.

Folder/file collisions. The classifier does not see that an added file a/b collides with a file a in T, or an added file a with a folder a/… in T. Vault__Rebase__Replay._df_conflicts(T, merged) checks, for each path being added: no ancestor prefix is a file in T (one entry_at per prefix, cached), and the path is not a folder in T (resolve_path_target returns tree). A collision is a whole-file conflict on that path, reported as kind = folder-file.

5.5.5 Results

5.5.6 Creating the replayed commit

Vault__Commit.create_commit(read_key      = read_key,
                            tree_id       = new_tree_id,
                            parent_ids    = [T],
                            message_enc   = C.message_enc        # unchanged bytes unless reworded or squashed
                                            if not new_message else None,
                            message       = new_message or '',
                            branch_id     = state.clone_branch_id,
                            timestamp_ms  = C.timestamp_ms if not state.reset_date else None,
                            signing_key   = clone_key,          # Vault__Sync__Commit._load_signing_key
                            author_key_id = clone_public_key_id,
                            user_signer   = user_signer)        # design__02, if resolved on

5.6 Vault__Sub_Tree.apply_changes (the spine rebuild)

apply_changes(root_tree_id: str, changes: dict, read_key: bytes) -> str in sgit_ai/storage/Vault__Sub_Tree.py. changes maps a vault path to a flat entry dict (add or replace) or None (delete).

  1. Group paths by parent folder; collect the set of affected folders = every ancestor of every changed path, including '' (the root).
  2. Load the affected folders that exist in the old tree, top-down along each path, with _load_tree_entries (Vault__Sub_Tree.py:326): decrypt each entry name once (_entries_by_name, :306). Missing folders (a new folder) start empty.
  3. Process affected folders deepest first:
  1. Return the new root id. If no change altered anything, return root_tree_id unchanged.

Equivalence (I6). For any tree T and change map Δ: apply_changes(T, Δ) == build_from_flat(flatten(T) ⊕ Δ). Tested with randomised trees (9.2). The fields that make up an entry (name_enc, size_enc, content_hash_enc, content_type_enc with the same fallback, large) come from the one helper _entry_from_flat, so the bytes are identical. Folder entries carry only tree_id and name_enc, as the builder writes them (:610-614).

Use elsewhere. design__01's cherry-pick and history revert --as-commit can use it too (follow-up; not required for this design).

5.7 The working copy: lazy, idempotent materialisation

New class Vault__Rebase__Working_Copy(Type_Safe) in sgit_ai/core/actions/rebase/Vault__Rebase__Working_Copy.py.

Why lazy. A rebase of ten commits that never stops does not need the ten intermediate working copies. The working copy and the clone ref move only when the user can see them: at a stop, before an exec, at the end, and on abort. On the 76,000-file vault this turns ten checkouts into one (8).

materialize(directory, c, state, target) -> dict:

  1. If target == state.materialized and state.materializing_to is empty: return.
  2. state.materializing_to = target; write state.
  3. change = diff_trees(target.tree, materialized.tree) → apply_map = added + modified (new side), deletions = deleted.
  4. Guard (only blocked targets; the tree was clean at start): Vault__Pull__Guard.blocked_targets(directory, writes, deletions) (Vault__Pull__Guard.py): an untracked or ignored file in the way refuses, naming it. Uncommitted edits at a stop (the user edited during edit/break) are handled by --continue before any materialisation, so they are never overwritten here.
  5. Write through the same code pull uses: the extracted Vault__Working_Copy__Writer.write(directory, read_key, selective, ours_map=materialized side, new_map=target side, apply_map). That is Step__Pull__Merge._write_selective (Step__Pull__Merge.py:218-230) moved into sgit_ai/core/actions/checkout/Vault__Working_Copy__Writer.py so pull, cherry-pick and rebase share it: deletions first, selective split, _refuse_unwritten on failure. The maps are the changed paths only (never full flattens).
  6. ref_manager.write_ref(clone_ref_id, target, read_key, reason=…, reflog_old=…) (5.11).
  7. state.materialized = target, state.materializing_to = None; write state.
  8. Seed the scan cache for written files (every working-copy write sgit makes does; CHANGELOG 0.26.0 "Fixed — from the newsroom's first morning").

Crash recovery (5.3 order). Any history rebase invocation first calls recover(directory, state): if materializing_to is set, re-run steps 3–7 with the same target. The diff is from materialized (what the disk held before) to materializing_to, and each write is idempotent (a file already at the target content is rewritten with the same bytes; a deletion of an absent file is a no-op), so a half-written working copy converges. If the clone ref already equals materializing_to, step 6 is a no-op.

Selective working copies (clone --no-checkout / --sparse, Pull__Selective): the writer writes only checked-out paths and records the rest. At the end of a pull --rebase, selective.record_pull(final, added, modified, deleted) with the lists from diff_trees(final, orig_head), so sgit checkout --since-pull writes what the rebase brought in.

5.8 Stops

At a stop the engine: materialises tip (5.7), writes what the stop needs, sets stopped_reason, stopped_item, and the remaining todo, and exits 1.

5.8.1 Conflict

5.8.2 Edit

C is replayed and committed normally (new created, tip = new), then materialize(tip) and stop. The user may change files. --continue amends (5.9.1).

5.8.3 Reword (non-interactive, no message)

C is replayed and committed with its original message, materialize(tip), stop. --continue -m MSG re-creates the tip commit with the same tree and parent and the new message (an amend of a commit that is not pushed, so no check is needed beyond I1). Interactive mode never stops for a reword: the editor opens on message with the original text, and runs prepare-commit-msg/commit-msg (5.13).

5.8.4 Exec

materialize(tip), then run the argv (5.10, exec). Exit 0: carry on. Non-zero: stop with EXEC_FAILED, record the exit code; --continue carries on without re-running it (Git without --reschedule-failed-exec).

5.8.5 Break, empty, missing blob

break: materialise and stop. EMPTY: 5.5.5. MISSING_BLOB: a sparse/selective clone needed a blob to merge and the server could not be reached: stop with the blob ids listed; --continue retries the same item.

5.9 --continue, --skip, --abort, --quit

All four start with Vault__Rebase__State.read and recover (5.7).

5.9.1 --continue [-m MSG]

stopped_reasonSteps
CONFLICT1. Vault__Merge.unresolved(...): any unresolved path → Vault__Merge_Unresolved_Error (design__01 §5.8), exit 1, nothing written. 2. Scan the working copy against tip exactly as commit does (Vault__Sync__Commit.commit, :80-143: _scan_local_directory, secret guard, scoped check), building the tree with Vault__Sub_Tree.build(..., old_flat_entries, old_tree_ids) so only changed folders are rebuilt. 3. If the tree equals tip's: apply the empty policy (drop by default, Git's "nothing to commit" during a rebase becomes a skip). 4. Else create the commit with C's message (or -m MSG), timestamp and signing as 5.5.6; tip = new; materialized = new; write the clone ref (reflog rebase (continue): <subject>). 5. Delete the merge state. 6. Carry on with the todo.
EDITIf the working copy differs from tip (scan as above) or -m was given: re-create tip as an amend (same parent, new tree and/or message), tip = amended, record the rewrite, write the ref. Then carry on.
REWORD-m MSG required, else refuse ("--continue needs -m MSG to reword <id>"). Re-create tip with the new message. Carry on.
EXEC_FAILED, BREAKThe working copy must be clean against tip (Git refuses to continue with uncommitted changes after an exec); else refuse naming the files. Carry on.
EMPTY--continue drops it; --continue --keep-empty keeps it.
MISSING_BLOBRetry the same item.

5.9.2 --skip

Allowed at CONFLICT, EMPTY, MISSING_BLOB, REWORD and EDIT (Git allows --skip at any stop). Steps: remove this stop's conflict files (Vault__Merge__Abort._remove_conflict_files, path-guarded); materialize(tip) with materialized treated as unknown on the touched paths (the paths of C's change set are rewritten to tip's content even if the user edited them, as Git's --skip does); delete the merge state; record result = dropped; carry on. At EDIT/REWORD the commit already created for the item is discarded: tip = its parent.

5.9.3 --abort

  1. Remove conflict files of the current stop (if any).
  2. materialize(orig_head) with materialized = the state's value. The working copy goes back on exactly the paths the rebase changed, plus the paths the user edited during a stop on those paths. Files the user edited at a stop on other paths: Git's --abort does reset --hard and discards them. sgit does the same but prints them first (Discarding uncommitted changes: …), because reset already behaves that way (Vault__Sync__Pull.reset, :60-66) and the user asked to abort. To scope the reset, a scan of the working copy against materialized lists the dirty paths, and those paths are added to the rewrite set.
  3. Clone ref = orig_head, reflog reason rebase (abort).
  4. Delete merge state and rebase state.
  5. Autostash: pop (5.12.4).

5.9.4 --quit

Delete the rebase state and any operation = rebase merge state; leave the ref, the working copy and conflict files as they are. The autostash is kept in the stash list and named in the output (Git keeps it too).

5.10 Todo actions, precisely

ActionEffect on tipStops?Commit messageHooks (5.13)
pick Creplay C onto tipon conflict / empty-stopC's (message_enc reused)prepare-commit-msg <file> message, post-commit
reword Creplay Cinteractive: no (editor); --todo with message: no; --todo without: yes (REWORD)newpre-commit, prepare-commit-msg <file> commit HEAD, commit-msg, post-commit, post-rewrite amend
edit Creplay C, then stopyes (EDIT)C's (or -m)pick's at replay; amend's at --continue if amended
squash Creplay C onto tip, then fold into the previous commit of the group: the group's single commit has parent squash_base, the latest tree, the messages joined (5.10.1)interactive: editor on the joined message; non-interactive: joined text, or the item's messagejoinedprepare-commit-msg <file> message, commit-msg (message edited), post-commit
fixup C (use_message=previous)as squashnothe group's first messageas pick
fixup -C C (this) / -c (this-edit)as squash-c opens the editorC'sas pick / as squash
drop Cnonenononenone
exec ARGVnone (materialises tip)on non-zero exitnonenone
breaknone (materialises tip)yes (BREAK)nonenone

5.10.1 Squash groups. A run of squash/fixup items after a pick/reword/edit forms a group. The engine keeps squash_base (the parent of the group's first commit) and squash_messages. Each item is replayed onto the current tip (which may be an intermediate group commit), then the group commit is re-created as parent = squash_base, tree = latest tree, message = combined. Intermediate group commits are orphan objects (never referenced; harmless; no GC needed since they are never pushed). Combined message, Git's layout without comment lines: messages in order, separated by a blank line; fixups contribute nothing (previous) or replace the whole text (this).

5.10.2 exec. Run with subprocess.run(argv, cwd=<vault root>, env=Vault__Hooks().environment(directory, None), stdin=DEVNULL, check=False) (design__03 5.5: secrets stripped, SGIT_DIR etc. set; GIT_* removed). No shell; argv comes from the JSON list or shlex.split of the text line. A user who wants a shell writes exec sh -c "make && make test". stdout/stderr inherited (under --json, stdout goes to stderr). No timeout unless SGIT_EXEC_TIMEOUT (seconds) is set, matching design__03 D8.

5.11 Refs, reflog, ORIG_HEAD, undo

5.12 pull --rebase

5.12.1 Workflow

New Workflow__Pull__Rebase in sgit_ai/workflow/pull/Workflow__Pull__Rebase.py, registered like Workflow__Pull (Workflow__Pull.py:13):

steps = [Step__Pull__Derive_Keys, Step__Pull__Load_Branch_Info, Step__Pull__Fetch_Remote_Ref,
         Step__Pull__Fetch_Missing, Step__Pull__Rebase]

The first four are unchanged: the rewind guard, the signature policy and verify-then-accept run exactly as for a merge pull, and they are the only writers of the local named ref and the baseline (I3).

Vault__Sync__Pull.pull(directory, on_progress=None, accept_rewind=False, merge_from=None, checkout=True, rebase: bool = None, autostash: bool = None, merge_style=None, conflict_style=None) (Vault__Sync__Pull.py:80): rebase is None → the clone's pull_rebase. Before running the workflow it also checks Vault__Rebase__State.check_not_in_progress(directory, 'pull').

5.12.2 Step__Pull__Rebase

sgit_ai/workflow/pull/Step__Pull__Rebase.py, same input/output schema as Step__Pull__Merge (Schema__Pull__State):

CaseSame test as the merge stepAction
no named head / equalStep__Pull__Merge.py:75-80up_to_date
clone head ancestor of named:99 (lca == clone)fast-forward exactly as the merge step (the same code, moved into a shared helper Pull__Fast_Forward)
named ancestor of clone:96up_to_date (nothing to rebase onto)
diverged, no rewind:119Vault__Sync__Rebase.start(directory, upstream=named, onto=named, from_pull=True, …)
accepted rewind:87-955.12.3

The result maps onto Schema__Pull__State.merge_status: rebased (new value), rebase_stopped (new value), with merge_commit_id = the final tip and conflict_paths at a stop. _pull_state_to_dict (Vault__Sync__Pull.py:159) returns status = 'rebased' | 'rebase_stopped'; CLI__Vault.cmd_pull prints 3.5's text.

5.12.3 Accepted rewind: the fork point

When Fetch_Remote_Ref accepted a rewind, workspace.rewound_from is the old named head the clone last accepted (Step__Pull__Fetch_Remote_Ref.py:52-55). The clone's own commits are those reachable from H and not from rewound_from; they go onto the new named head:

start(upstream = rewound_from, onto = named_commit_id, held_heads = {rewound_from, named_commit_id})

That is Git's --fork-point result, with the baseline standing in for the upstream's reflog. Differences from today's _take_rewind (Step__Pull__Merge.py:245-310): commits are replayed one by one (history kept instead of squashed), and a conflict stops for resolution instead of refusing. Merge pulls keep _take_rewind as it is.

5.12.4 Autostash

autostash = flag, else local_config.rebase_autostash. Start: if the working copy is dirty (_require_clean's status), Vault__Stash.stash and record the zip name in state.autostash; print Created autostash: <name>. Finish or abort: Vault__Stash.pop; on any pop failure the stash is kept and the output says Applying autostash failed; your changes are safe in the stash (sgit vault stash pop). Without autostash, a dirty working copy refuses: cannot rebase: you have uncommitted changes (commit them, sgit vault stash them, or pass --autostash).

5.12.5 Push's implicit pull

Vault__Sync__Push.push pulls first (Vault__Sync__Push.py:177-182). It now passes rebase=None (the clone's setting) and autostash=False. Results:

Decision D4 asks whether the implicit pull should rebase when pull_rebase is on (recommended) or always merge.

5.13 Hooks (design__03's runner)

Enum__Hook_Name gains PRE_REBASE and POST_REWRITE. VERIFY_HOOKS gains PRE_REBASE (Git's --no-verify covers it for rebase).

WhenHookArgs / stdinFailure
Before anything (after argument checks, before the state exists)pre-rebase$1 upstream as the user typed it (or the named branch name), $2 absent (sgit rebases the current clone only)non-zero refuses the rebase, nothing written
First materialisation, and the final onepost-checkout<old> <new> 1ignored (warning), as design__03
Each pick/squash/fixup commitprepare-commit-msg (<file> message), post-commitmessage file = .sg_vault/local/rebase/messageprepare-commit-msg non-zero stops the rebase (reason CONFLICT-free stop: EXEC_FAILED with the hook named)
A reword or -c fixup or interactive squash (message edited)pre-commit, prepare-commit-msg <file> commit HEAD, commit-msg, post-commit, post-rewrite amend (<old> <new>)as Git (measured)pre-commit / commit-msg non-zero stops with the hook named; --continue retries
End (done, not aborted)post-rewrite$1 = rebase; stdin: one <old> <new> line per rewritten commit, full idsignored
pull --rebaseas above; not post-merge (Git's pull --rebase does not run it)

Hooks are only run when design__03's Vault__Hooks.find finds one, so the cost without hooks is a stat per hook name per rebase.

5.14 Signing

5.15 Other commands during a rebase

Vault__Rebase__State.check_not_in_progress is called at the start of:

CommandBehaviour during a rebaseWhere
pull, push, branch mergerefusedVault__Sync__Pull.pull (:88), Vault__Sync__Push.push (:50)
commit, commit --amend, write, write --pushrefused: "a rebase is in progress: use sgit history rebase --continue (it commits your resolution)" (decision D8)Vault__Sync__Commit.commit (:32), write_file (:231)
branch switch/new/checkout/rename, history reset/undo/revert --as-commit, cherry-pick, vault stash / stash pop, vault move/rekey/format, merge-abortrefused (merge-abort: "use sgit history rebase --abort")the respective entry points
bisect startrefused; and history rebase refuses during a bisect (Vault__Bisect__State.check_not_in_progress)design__05
resolveallowed (works on the operation = rebase merge state)unchanged
status, history log/diff/show/reflog, fetch, checkout <paths> (selective), cat, lsallowed—
history rebase (a new one)refused: "a rebase is already in progress"—

Vault__Merge__State.check_not_in_progress (Vault__Merge__State.py:42-49) reads the state's operation; when it is rebase the message becomes the rebase one, so the commands that already check merge state say the right thing. Commit with merge state operation = rebase must not create a two-parent merge commit: Vault__Sync__Commit refuses it (design__01 §5.8 step 4 lists the operations; rebase is added to the refusal branch).

A start-of-rebase check also refuses when a merge, cherry-pick or bisect is in progress.

5.16 File-by-file changes

FileChange
sgit_ai/core/actions/rebase/__init__.pynew (source tree only)
sgit_ai/core/actions/rebase/Vault__Sync__Rebase.pynew: start(...), continue_(directory, message=None, keep_empty=False), skip(directory), abort(directory), quit(directory), show_todo(directory), edit_todo(directory, todo_text=None), _run(state) (the todo loop), _finish(state)
sgit_ai/core/actions/rebase/Vault__Rebase__State.pynew (5.3)
sgit_ai/core/actions/rebase/Vault__Rebase__Planner.pynew (5.4)
sgit_ai/core/actions/rebase/Vault__Rebase__Todo.pynew: parse_text(text, c) -> Schema__Rebase_Todo, parse_json(text, c), render_text(todo, header), autosquash(todo, subjects), add_exec(todo, argvs)
sgit_ai/core/actions/rebase/Vault__Rebase__Replay.pynew (5.5): replay, _change_set, _sub_maps, _unify_equal_content, _df_conflicts
sgit_ai/core/actions/rebase/Vault__Rebase__Working_Copy.pynew (5.7): materialize, recover, dirty_paths
sgit_ai/core/actions/rebase/Vault__Rebase__Exec.pynew (5.10.2)
sgit_ai/core/actions/checkout/Vault__Working_Copy__Writer.pynew: _write_selective + _refuse_unwritten moved out of Step__Pull__Merge (:218-243), which calls it
sgit_ai/core/actions/history/Vault__Commit_Graph.pynew or shared with design__05 (D9): parents, generation, paint, range, merge_bases
sgit_ai/storage/Vault__Sub_Tree.pyapply_changes (5.6), entry_at (5.5.2)
sgit_ai/storage/Vault__Ref_Manager.pywrite_ref(..., reason=None, reflog_old=None)
sgit_ai/storage/Vault__Reflog.pyappend(..., reason=None)
sgit_ai/schemas/Schema__Reflog_Entry.pyreason
sgit_ai/schemas/Schema__Local_Config.pypull_rebase, rebase_autostash
sgit_ai/schemas/rebase/*.pythe schemas of 5.2
sgit_ai/safe_types/*.pythe enums and Safe types of 5.2
sgit_ai/core/Vault__Errors.pyVault__Rebase_Error, Vault__Rebase_In_Progress_Error (both refusals, added to _print_friendly_error's list at CLI__Main.py:1210-1214)
sgit_ai/core/actions/history/Vault__Revision.pyORIG_HEAD
sgit_ai/core/actions/merge/Vault__Merge__State.pyrebase-aware message in check_not_in_progress
sgit_ai/core/actions/commit/Vault__Sync__Commit.pythe two check_not_in_progress calls; refuse commit on operation = rebase merge state
sgit_ai/core/actions/pull/Vault__Sync__Pull.pypull(..., rebase=None, autostash=None, …); rebase check
sgit_ai/workflow/pull/Workflow__Pull__Rebase.py, Step__Pull__Rebase.pynew (5.12)
sgit_ai/workflow/pull/Step__Pull__Merge.pyfast-forward block moved to Pull__Fast_Forward (shared); _write_selective calls the new writer
sgit_ai/schemas/workflow/pull/Schema__Pull__State.pymerge_status values rebased, rebase_stopped (merge_status is a plain Safe_Str, Schema__Pull__State.py:48, so no type change; update its comment), rebase_steps : list[Schema__Rebase_Done_Item] for output
sgit_ai/core/actions/push/Vault__Sync__Push.pyimplicit pull passes rebase=None, handles rebase_stopped; branch-only fix: at :664, if remote_head is not an ancestor of clone_commit_id (Vault__Ref_Guard.is_ancestor), use the local named head as stop_at, so a rebased WIP branch uploads only the new chain and its CAS write replaces the old server clone ref
sgit_ai/core/actions/status/Vault__Sync__Status.pyrebase_info (3.7)
sgit_ai/plugins/history/CLI__History.pythe rebase subparser (3.2) dispatching to a new CLI__Rebase
sgit_ai/cli/CLI__Rebase.pynew: argument handling, editor launch, output (3.5), --json
sgit_ai/plugins/history/plugin.json"rebase" in commands
sgit_ai/cli/CLI__Main.pypull parser --rebase/--no-rebase/--autostash (:398-406); friendly errors
sgit_ai/cli/CLI__Vault.pycmd_pull passes the flags and prints rebase results (:963); cmd_status prints 3.7; cmd_reflog prints the reason
sgit_ai/core/hooks/* (design__03)PRE_REBASE, POST_REWRITE

5.17 Compatibility


6. Security and threat model

IDRiskActorMitigationTest
T1A rebase rewrites pushed history, so the next push (with --force) or pull makes every other clone see a rewindA4 (self, by mistake)I1: the todo excludes, and every edited todo re-checks, commits reachable from the local named ref and the accepted baseline; there is no override in v1 (D2)test_Security__Fixed__Rebase_Boundaries.test_pushed_commit_is_never_replayed (named Fixed as it pins a refusal; see D10), plus a todo naming a pushed commit
T2The baseline moves, so the rewind guard goes blindA1 + selfI3: the engine never calls _write_remote_baseline or writes the named ref; pull --rebase leaves those writes to the unchanged fetch stepstest_Rebase__Refs.test_baselines_and_named_ref_untouched (bytes of remote_heads.json and the named ref file compared before/after, including after abort)
T3Laundering an unsigned or foreign commit into this clone's signed history under signatures-requiredA3Foreign commits refused without --allow-foreign; with it, they must verify under the vault's policy before re-signing (5.4.2)test_Rebase__Signing.test_unsigned_foreign_commit_refused_under_policy
T4Re-attribution (a teammate's work signed as mine)A4Same check; post-rewrite and --json list rewritten idstest_Rebase__Signing.test_foreign_commit_refused_by_default
T5exec runs commandsuser, agent (A9)Only from the user's todo (--exec, -i, --todo), never from vault content: the state and todo live under .sg_vault/local/, which vault content cannot write (path guard refuses .sg_vault segments, TM-F03); argv, no shell; secrets stripped from the environment (design__03 5.5)test_Rebase__Exec.test_argv_not_shell (an argument containing ; touch x creates no x), test_environment_has_no_secrets
T6Conflict labels from commit subjects inject marker linesA3design__01 Safe_Str__Conflict_Label (T2 there)design__01's test, plus test_Rebase__Conflicts.test_subject_with_markers_in_label
T7A tampered state file points the abort or the replay elsewherelocal userSame trust as config.json (local, 0600); ids are Safe_Str__Commit_Id; orig_head, onto and every todo commit must be local commits; I1 re-checked on load; exec argv only from the user's own todotest_Rebase__State.test_damaged_state_refuses_with_remedy
T8Plaintext on disk: message and the text todo hold commit subjectsA60600, inside .sg_vault/local/ like COMMIT_EDITMSG (design__03) and the flat cache (TM-R41); deleted at finish/abort/quittest_Rebase__State.test_files_are_private_and_removed
T9Hostile paths in replayed changes (../x, .sg_vault/...)A3 (a commit from another clone with --allow-foreign)Every working-copy write goes through the existing writer and Vault__Path_Guard.safe_join; apply_changes takes names only from decrypted tree entries, which the tree reader already validatesreuse test_Security__Fixed__Hostile_Paths with a rebase variant

Threat-model rows to add (team/explorer/appsec/threat-model/v0.21.0__threat-model.md):


7. Interactions

WithBehaviour
Full cloneAs designed.
Scoped clone (--path)Flats are never built; diff_trees and apply_changes open only held spine folders; out-of-scope siblings are carried inside the loaded trees. A commit touching unheld folders is refused (5.4.2). pull --rebase on a scoped clone works like its merge pull.
Shallow clone (--depth)The range walk stops at boundaries; refusal when the range cannot be proven (5.4.1 step 4). The unpushed range is always above the boundary in normal use.
Sparse / selective working copyMaterialisation writes only checked-out paths and records the rest; conflict merging may need upstream blobs not local: fetched verified on demand, else stop MISSING_BLOB (5.8.5). record_pull after pull --rebase.
Read-only cloneRefused before anything: Vault__Read_Only_Error (no clone branch, no signing key).
Pack vaultsReplayed commits and trees are local loose objects, exactly as commit creates them; the next push sends them as a commit pack and writes its index pack. The commit index's change list can serve 5.5.1.
--pack-store clonesReads go through the pack store as today; new objects are written where commit writes them.
Worktrees (design__06)Each worktree has its own .sg_vault/local/ (state, ORIG_HEAD, reflog) and its own clone branch: independent rebases. New objects go to the worktree's own store per design__06; reads fall through to the main store. Two worktrees rebasing concurrently do not share refs.
Bisect (design__05)Mutually exclusive (5.15).
Hooks (design__03)5.13. --no-verify skips pre-rebase only.
Line merge (design__01)--merge lines gives marker conflicts and clean line merges inside the replay; default stays whole-file .conflict (the agreed default).
Cherry-pick (design__01)Shares Vault__Merge wiring, pick_message, the writer and (follow-up) apply_changes. A cherry-pick in progress blocks a rebase and the reverse.
Stash--autostash uses it; vault stash is refused during a rebase.
Rewind re-applyMerge pulls keep _take_rewind; rebase pulls use the fork point (5.12.3).
history undoOne undo after a finished rebase returns to orig_head (5.11).
push --branch-onlyA rebased WIP branch pushes cleanly after the stop_at fix (5.16).
Web UI / other clientsSee only ordinary commits after the push.

8. Performance

Measured costs from the 0.26.0 release-readiness note (76,000 files in 58,553 folders, in-memory host): full clone 61 s, push of a 5-file commit 4.5–5.2 s, history log of 4 commits 0.45 s. Estimates below are per step, from the code paths each step uses; the acceptance list (9.4) requires measuring them.

StepCost driverEstimate on 76k files
Range walkone commit decrypt per commit not yet in the generation memo; then O(range + upstream since base)< 0.5 s for a 50-commit range; first run on a loose vault walks the history once (626 commits ≈ 1 s)
Cherry detectiondiff_trees per upstream commit since the merge base (only differing folders)~10–30 ms per commit; capped at 2,000 commits
Replay of one commit changing k files at depth ddiff_trees (≈ k·d folder loads) + entry_at for k paths (cached spine) + apply_changes (≈ k·d folder rebuilds) + one commit create/sign~20–100 ms for k ≤ 10
Materialisationdiff_trees(target, materialized) + writing the changed filesproportional to files changed by the whole rebase; one write per file per rebase (not per step)
pull --rebasethe merge pull's fetch cost (unchanged) + the abovedominated by the fetch, as a merge pull is

What is avoided. build_from_flat re-encrypts all 58,553 folders per call (4); a replay that used it would cost tens of seconds per commit on this vault. apply_changes rebuilds only the spines. No step builds a full flat map; the only full scan is the clean-tree check at start (the scan cache makes it a stat per file: 6.5 s on the 228,000-file vault, CHANGELOG 0.26.0) and the scan at --continue.

Caps. REBASE_CHERRY_SCAN_MAX = 2000; the line engine's caps are design__01 D8's. A todo longer than REBASE_TODO_WARN = 500 items prints a warning (no refusal).


9. Test plan

All unit tests use real objects: two clones over Vault__API__In_Memory via Vault__Test_Env (fixed test key), no mocks. No __init__.py in tests. File and class names follow the repository convention.

9.1 Planner and todo — tests/unit/core/actions/rebase/

test_Vault__Rebase__Planner.py → Test_Vault__Rebase__Planner:

  1. Linear: 3 local commits, 2 upstream → todo is the 3, oldest first.
  2. Merge commits in the range are dropped; both sides linearised in topological order (the measured Git case: mer1, side1, after).
  3. A local commit equal in effect to an upstream commit (same path, same old and new content hash, different blob ids) is skipped_upstream; with reapply_cherry_picks it is in the todo.
  4. Range when the local named ref and the baseline differ (both excluded).
  5. Explicit upstream HEAD~2 that would include a pushed commit → refused (I1), naming it.
  6. --onto X: replays U..H on X.
  7. --keep-base: onto = merge base; several merge bases → refused.
  8. Up to date; fast-forward case.
  9. Shallow clone: range above boundary works; range crossing a boundary refused with the --unshallow hint.
  10. Foreign commit refused; --allow-foreign admits it; under signatures-required an unsigned foreign commit is refused even with the flag.
  11. Scoped clone: a commit touching an unheld folder refused.
  12. The generation walk agrees with a brute-force reachability on 200 random DAGs (property test; the oracle is a set computed by full ancestor walks).

test_Vault__Rebase__Todo.py → Test_Vault__Rebase__Todo: text parse (every verb, short forms, prefixes, comments, blank lines, unknown verb refused with line number, ambiguous prefix refused); JSON parse (array and object forms, argv vs command, use_message); render then parse round trip; autosquash ordering (fixup!, squash!, nested, no target → left in place); --exec insertion after squash groups; Type_Safe round trip of every schema (from_json(obj.json()).json() == obj.json()).

9.2 Replay and trees

tests/unit/storage/test_Vault__Sub_Tree__Apply_Changes.py → Test_Vault__Sub_Tree__Apply_Changes:

tests/unit/core/actions/rebase/test_Vault__Rebase__Replay.py → Test_Vault__Rebase__Replay: clean replay; replay onto a tip that changed other files; equal-content-different-blob normalisation (5.5.3) is not a conflict; modify/delete and delete/modify conflicts; folder/file collisions both ways; empty after replay with each --empty policy; message bytes reused (same message_enc); timestamp kept / --reset-date; signature verifies with the clone key and author_key_id set.

9.3 End to end — tests/unit/core/actions/rebase/test_Vault__Sync__Rebase.py and tests/unit/workflow/pull/test_Pull__Rebase.py

ClassCases
Test_Vault__Sync__Rebase__Basicthe common agent case: clone A pushes, clone B commits twice and rebases onto the new named head; B's push is a fast-forward (push uploads only the new chain); a third clone pulls with no rewind warning
Test_Vault__Sync__Rebase__Conflictsstop on conflict (files style): working file = tip version, .conflict = commit version, merge state operation=rebase; resolve --theirs + --continue → commit with the original message; --continue with unresolved → refused, nothing written; lines style: markers, clean line merge, unresolved check; --skip drops the commit and restores the touched paths; --abort restores ref, files, deletes state
Test_Vault__Sync__Rebase__Interactiveevery verb via --todo JSON and text; reword with and without message; edit then amend at --continue (with and without file changes, with -m); squash and fixup groups incl. -C; drop; break; exec success and failure (exec writes a marker file through argv to prove it ran in the vault root with tip's files present); --edit-todo re-checks I1; -i with SGIT_SEQUENCE_EDITOR set to a small Python script that rewrites the file (a real program, no mock)
Test_Vault__Sync__Rebase__Recoverycrash simulation without mocks: run the engine with a Vault__Rebase__Working_Copy subclass that raises after writing N files (a real subclass overriding one method); then --continue converges (I5) and --abort restores (I4); damaged state.json refuses with the remedy; --quit leaves everything
Test_Vault__Sync__Rebase__Refsreflog reasons; finish entry's old_commit == orig_head; one history undo restores; ORIG_HEAD resolves; I3 byte comparison of remote_heads.json and the named ref file
Test_Vault__Sync__Rebase__Guardsevery refused command of 5.15 during a stop, each with nothing written; rebase refused during merge, cherry-pick, bisect, dirty tree (and --autostash path incl. failed pop keeping the stash)
Test_Vault__Sync__Rebase__Partial_Clonesscoped, shallow, selective (--no-checkout: only checked-out paths written, checkout --since-pull writes the rest after pull --rebase), read-only refused, pack vault (push after rebase sends one commit pack; pack index verify passes)
Test_Pull__Rebasediverged pull rebases; FF and up-to-date unchanged; conflict stop → status = rebase_stopped; pull_rebase = on default and --no-rebase override; push's implicit pull rebases and refuses on a stop; accepted rewind replays own commits individually onto the new head (compare with today's squashed _take_rewind)
Test_Push__Branch_Only_After_RebaseWIP pushed with --branch-only, rebased, pushed again: only the new chain uploaded, server clone ref replaced, no error

Git as the oracle. tests/unit/core/actions/rebase/test_Rebase__Git_Oracle.py → Test_Rebase__Git_Oracle builds the same history in a temporary Git repository (git 2.43 is installed; the test is skipped with a reason if git is absent) and in sgit, runs both rebases, and compares: the list of replayed subjects in order, the skipped ones, the final file contents per path, and which commit stops on conflict. Histories: the measured cases of 1.2 plus 50 generated ones (random linear and merge-bearing local ranges over a shared base). File contents are compared exactly for clean replays; conflict stops are compared by commit and path set (marker bytes are design__01's concern).

9.4 Security tests — tests/security/

test_Security__Fixed__Rebase_Boundaries.py → Test_Security__Fixed__Rebase_Boundaries: a todo (text and JSON) naming a pushed commit; an explicit upstream reaching below the baseline; a planted exec in a file committed to the vault under .sg_vault/local/rebase/todo (refused by the path guard at commit; never read by the engine); exec argument with shell metacharacters; hostile path in a foreign commit with --allow-foreign; I3 bytes check after a stop, a continue and an abort.

9.5 CLI — tests/unit/cli/test_CLI__Rebase.py

Parser (every flag, mutual exclusions --onto/--keep-base, --continue with other options refused), exit codes (0/1 per 3.6), --json shape keys, help text present, history plugin lists rebase, TOP_LEVEL_COMMANDS unchanged (test_CLI__B07__Namespace_Moves.py passes untouched).

9.6 Acceptance checklist (implementation and deployment)

Implementation:

Deployment (on the dev pre-release, then the release):


10. Implementation steps (ordered commits)

#CommitContentsEstimate
1feat(storage): apply_changes and entry_at — rebuild only the changed spines5.6, 5.5.2; I6 property tests; load-count test0.5 day
2refactor(history): shared commit graph (generations, painting, range, merge bases)Vault__Commit_Graph (or the shared part of design__05's graph, D9); brute-force property tests0.5 day
3feat(rebase): state, todo parse/render, planner5.2, 5.3, 5.4; planner and todo tests; --dry-run CLI1 day
4feat(rebase): replay loop, lazy working copy, finish, abort, quit5.5, 5.7, 5.9.3–5.9.4, 5.11 (reflog reason, ORIG_HEAD); basic end-to-end tests; recovery tests1 day
5feat(rebase): conflicts, --continue, --skip, guards in other commands5.8.1, 5.9.1–5.9.2, 5.15; needs design__01's merge state fields and unresolved (or lands with them)1 day
6feat(rebase): interactive todo — reword, edit, squash, fixup, drop, exec, break, autosquash5.10, 5.8.2–5.8.5, editor launch1 day
7feat(pull): pull --rebase, pull_rebase, autostash; push's implicit pull; branch-only fix5.12, 5.16 push changes0.5–1 day
8feat(rebase): hooks and user signing5.13, 5.14 (after design__02/03 land; otherwise a no-op seam)0.5 day
9test(rebase): Git oracle, security tests, 76k measurement; docs9.3 oracle, 9.4, CHANGELOG, threat model, README, release-note numbers0.5–1 day

Total: 6.5–7.5 days for one developer, after design__01's merge-state and classifier changes (rebase step 5 depends on them; steps 1–4 do not). The roadmap's earlier "2–3 days" assumed whole-file merges only, no interactive mode and no pull integration.


11. Open decisions for Dinis

#DecisionOptionsRecommendation
D1A top-level sgit rebase alias(a) only history rebase + pull --rebase; (b) also top-level rebase (a TOP_LEVEL_COMMANDS edit)(a). Rule 9 puts commit rewriting under history; the daily case is pull --rebase
D2Rewriting pushed commits(a) never in v1; (b) --rewrite-pushed with a warning, needing push --force and pull --accept-rewind on every other clone(a). The deliberate path already exists (history reset + push --force), and keeping rebase local-only is what makes it safe for agents
D3Commits signed by another clone in the range(a) refuse unless --allow-foreign; (b) replay and re-sign silently (Git keeps the author; sgit cannot)(a). Signing is attribution in sgit; silent re-attribution would be a surprise
D4Push's implicit pull when pull_rebase = on(a) rebase (consistent with pull); (b) always merge(a), refusing the push if the rebase stops
D5Default for pull_rebase(a) off (merge, today); (b) on(a): the agreed principle is opt-in; teams can turn it on per clone
D6exec lines(a) argv (shlex), sh -c explicitly when wanted; (b) through the shell as Git does(a), the TM-F06 lesson: no implicit shell
D7Timestamps of replayed commits(a) keep the original (Git's author date); (b) now (Git's committer date)(a) with --reset-date; log keeps showing when the work was done
D8Work at an edit/conflict stop(a) v1: --continue commits or amends, sgit commit refused; (b) allow sgit commit / --amend at stops and several commits per edit (Git)(a) now; (b) as a follow-up (+1 day, needs commit to target the rebase tip)
D9Shared commit-graph class(a) Vault__Commit_Graph used by bisect and rebase; (b) each keeps its own walk(a): one painting walk, one set of property tests
D10Security test namingtest_Security__Fixed__Rebase_Boundaries (pins refusals, like Fixed__Baselines) or a new test_Security__Invariant__* familyFixed__: CLAUDE.md names only Fixed and Known_Gaps; these pin refusals that must hold
X1Cross-doc: equal content with different blob ids(a) rebase normalises locally (5.5.3); (b) design__01's three_way_merge compares content_hash first for every caller(b), with (a) as the fallback if design__01 lands first without it. Pull merges, cherry-pick and revert have the same false conflict today

This page as markdown · its source, admin/content/research/git-parity-design-rebase.md