Skip to content

feat(sessions): reassociate commits with their sessions after a rebase - #901

Open
matt2e wants to merge 4 commits into
mainfrom
reassociate-sessions-after-rebase
Open

feat(sessions): reassociate commits with their sessions after a rebase#901
matt2e wants to merge 4 commits into
mainfrom
reassociate-sessions-after-rebase

Conversation

@matt2e

@matt2e matt2e commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

A rebase rewrites every SHA on the branch, which orphaned each commits row keyed by the old one: the timeline lost the authoring session, the head commit got mis-attributed to the mechanical "Rebase branch" session, and reviews keyed by commit_sha vanished behind review_is_visible_in_timeline. Even once ownership was restored, commits sank below every note because the timeline sorts on committer date, which a rebase resets to "now".

Changes

Reassociation — new commit_reassociation module recovers the mapping from the DB plus git alone, with no pre-rebase capture: git rebase preserves author email, author date, and subject, and the old objects stay resolvable, so the reconciler also survives an app restart mid-pipeline. It reads the old identities back from the orphaned SHAs, pairs them against the rewritten commits (oldest-to-oldest on identity collisions, skipping SHAs a row already owns), and Store::remap_commit_shas repoints the rows and their reviews in one transaction. Both rebase completion paths call it before the pending row is resolved, so the existing "target SHA already owned" branch drops the duplicate pending row instead of stealing the head commit.

It bails out unless HEAD is attached to the branch it was asked about — the same question as "is the rebase over?", since a rebase only moves the branch ref at the end. A turn ending with unresolved conflicts leaves HEAD detached on a partially applied commit, and repointing rows there is worse than leaving them orphaned.

Ordering — commit timeline items now sort by author date (%at), the rebase-stable key the matcher already trusts, so notes stay interleaved instead of floating above the commits they followed. Author dates aren't monotonic along a branch (a cherry-pick keeps its week-old date), so both the branch timeline and the agent-facing branch history clamp with a shared running max in branch order, keeping the rendered order matching git log. CommitTimelineItem carries a separate sortTimestamp so the clamp drives order while the displayed date stays truthful. CommitInfo.timestamp stays committer time for latest_git_commit_ms, where a rebase should read as new activity; repo-browse and parent-branch listings stay on committer time too.

ParsingBRANCH_COMMIT_LOG_FORMAT moved the subject last and one shared parse_branch_commit_line takes it as the remainder, replacing four hand-rolled index parsers. Previously a subject containing | shifted every field after it, corrupting the timestamp parse.

No schema change.

Testing

Matcher unit tests, store tests for the remap and its collision guard, parse tests for the field order and a piped subject, clamp tests for both timelines, and integration tests driving a real git rebase --signoff through each completion path plus one into a conflict, asserting the stopped rebase leaves every row and review untouched.

🤖 Generated with Claude Code

matt2e added 3 commits August 4, 2026 14:19
A rebase gives every commit on the branch a new SHA, which orphaned each
`commits` row keyed by the old one: the timeline lost the authoring
session, the head commit got mis-attributed to the mechanical "Rebase
branch" session, and reviews keyed by `commit_sha` disappeared behind
`review_is_visible_in_timeline`.

The mapping is recoverable from the DB plus git alone, with no pre-rebase
capture — `git rebase` preserves author email, author date, and subject,
and the old objects stay resolvable — so the reconciler also survives an
app restart mid-pipeline. New `commit_reassociation` module reads the old
identities back from the orphaned SHAs, pairs them against the rewritten
commits (oldest-to-oldest when identities collide, skipping SHAs a row
already owns), and `Store::remap_commit_shas` repoints the rows and their
reviews in one transaction. Both rebase completion paths call it before
the pending row is resolved, so the existing "target SHA already owned"
branch drops the duplicate pending row instead of stealing the head
commit.

Covered by matcher unit tests, store tests for the remap and its
collision guard, and integration tests driving a real `git rebase
--signoff` through each completion path.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Reassociating commits with their sessions after a rebase (f5103d9)
restored each commit's ownership but not its place in the timeline. The
branch timeline is one list sorted by timestamp, and the two clocks it
merges diverge under a rebase: notes, reviews, and images carry DB times,
while commits carried git's committer date, which a rebase rewrites to
"now". So a timeline that read commit A -> plan note -> commit B came
back as plan note -> A' -> B', with every commit sunk below every note.

Author date is the rebase-stable key the reassociation matcher already
trusts, so extend it from ownership to position: `git log` now carries
both clocks (`BRANCH_COMMIT_LOG_FORMAT`, with a six-field fallback so a
stray old-format producer still parses), and `CommitTimelineItem` takes
its timestamp from `%at`. Every item on the timeline now sorts by a key
a rebase never touches. `CommitInfo.timestamp` stays committer time for
`latest_git_commit_ms`, where a rebase *should* read as new activity.
Session transcripts get the same `%ct` -> `%at` switch on both the local
and remote paths, where the prefix is purely an ordering key.

Author dates aren't monotonic along a branch the way committer dates are
— a cherry-pick keeps its week-old date — so `build_branch_timeline`
clamps them with a running max in branch order, keeping the rendered
order matching `git log`. Repo-browse listings and parent-branch commits
stay on committer time; they don't interleave with notes. No schema or
frontend change.

Covered by parse tests for both field counts, clamp unit tests, and an
integration test driving a real `git rebase --signoff` over a
commit/note/commit branch and asserting the interleaving survives.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
…-stable sort

Sorting commits by author date (aa8220f) kept notes interleaved across a
rebase, but review e4f7f39 found the ordering key leaking into places it
shouldn't and the reassociation trusting a HEAD it shouldn't.

`BRANCH_COMMIT_LOG_FORMAT` still kept `%s` mid-line, so a subject
containing `|` shifted every field after it — a `%at` parse failure that
the new clamp then masked by pulling the commit up to its predecessor's
time. Subject moves last, as `commit_reassociation`'s format already had
it, and one shared `parse_branch_commit_line` takes it as the remainder.
That replaces four hand-rolled index parsers (branch commits, remote
commits, parent-branch commits, repo-browse) and the six-field fallback:
the last old-format producer, `BATCH_FAST_SCRIPT`, now emits the shared
field list too, with a test tying the script to the const.

The `%ct` -> `%at` switch had landed in `build_branch_context` without
the monotonic clamp, so a cherry-picked commit whose author date predates
its parent's could reorder the agent-facing section titled "Branch
History (oldest first)" (`order` only breaks ties, so it can't recover an
inverted timestamp). The clamp is now `timeline::clamp_timestamps_
monotonic`, shared by both timelines.

Reassociation fired on any HEAD move for a rebase-pipeline session, with
nothing checking that the rebase had finished. A turn that ends with
conflicts unresolved leaves HEAD detached on a partially applied commit,
and repointing every row plus its reviews there is worse than leaving
them orphaned — `git rebase --abort` restores the originals and the rows
would then name commits on no branch at all. `reassociate_after_rebase`
now returns 0 unless HEAD is attached to the branch it was asked about,
which is the same question as "is the rebase over?" since a rebase only
moves the branch ref at the end.

Finally the clamp overwrote the field the frontend renders, so a
week-old cherry-pick displayed as its successor's time. `CommitTimeline
Item` carries `sortTimestamp` for order and keeps `timestamp` truthful;
the merge, the commit anchors, `BranchCard`'s latest-item check and the
hashtag list sort on the former, the rendered date reads the latter.
Adding the field is the reviewer's own suggestion, per AGENTS.md.

Covered by parse tests for the new field order and a piped subject, a
clamp test asserting the rendered dates stay put, a branch-history clamp
test, and an integration test driving a real `git rebase` into a conflict
and asserting the stopped rebase leaves every row and review untouched
(it repoints them onto the throwaway SHAs without the guard).

Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e requested review from baxen and wesbillman as code owners August 4, 2026 06:41

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dba9dad58c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

tertiaryMeta: showAuthor ? commit.author : undefined,
deleting: isDeleting,
timestamp: commit.timestamp,
timestamp: commit.sortTimestamp,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve cached timeline commit sort keys

For users who already have a branch timeline cached from before this response-shape change, cached commits do not contain sortTimestamp, and CACHE_SCHEMA_VERSION is still unchanged. getBranchTimelineWithRevalidation can render that cached value immediately and may skip the network request while the entry is fresh, so using commit.sortTimestamp here produces undefined sort keys; comparisons against notes/reviews become NaN, which can leave commits in the wrong position and break review anchoring until the cache expires. Defaulting to commit.sortTimestamp ?? commit.timestamp or invalidating/normalizing the cached schema avoids the regression.

Useful? React with 👍 / 👎.

…nside them

Review d051cfc0 on dba9dad pointed out that "the only field that can
contain the delimiter" wasn't airtight: subjects were the only field
*expected* to contain `|`, but git also permits it in `user.name` (and
technically in emails). An author configured as `Foo | Bar` shifted
every field after it — `committer_timestamp` tried to parse the email
(0 via `unwrap_or`), `author_timestamp` silently picked up the `%ct`
value, and the monotonic clamp then hid the damage — the same masking
the piped-subject fix in dba9dad closed.

Since the field order is a documented invariant shared by four
producers, close the whole class as the reviewer suggested: separate
fields with `%x1f` (the unit separator) instead of relying on field
order to keep `|` harmless. `BRANCH_COMMIT_LOG_FIELDS` and the four
producers behind it pick the change up from the one const;
`BATCH_FAST_SCRIPT`'s inlined copy is updated in kind and stays
ASCII-safe because git expands `%x1f` itself, with the existing drift
test still tying the script to the const. `commit_reassociation`'s
sibling format gets the same treatment — its `%ae` sits directly
before `%at`, exactly the shape the review's failure corrupts, and
there a shifted timestamp breaks identity matching, not just ordering.
The subject still goes last, so even the separator byte itself in a
subject can't reach the timestamps.

Covered by the reworked parse tests: a fixture with `|` in the name,
the email, and the subject asserting nothing shifts, plus the
separator-in-subject remainder case, in both parsers.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant