Time-travel: scrub the repo's history and watch the city grow#125
Merged
Conversation
…blob stats) Task 1 of #113: resolve_ref/ls_tree_files/blob_stats_batch in gitobj.py, built on git ls-tree/cat-file/rev-parse only (no checkout/reset/worktree). blob_stats_batch needs media dims without a filename to dispatch on, so media.py gains a bytes-sibling probe (probe_media_dims_from_bytes) that content-sniffs instead: PIL's own magic-byte detection for images, an <svg>-root check for SVG (guards against mistaking arbitrary XML for one now that there's no .svg extension to gate on), hachoir last for video. Image/video internals are shared between the path and bytes entry points (Image.open and createParser both accept a path or a file-like object) so the disk probe keeps its lazy, non-buffering I/O; SVG's DOM parse has no such distinction so it's fully shared.
blob_stats_batch ran hachoir's full format probe on every blob's bytes, including plain source files — wasteful, and hachoir spams stderr with "[warn] Skip parser ..." per rejected format. Gate the probe behind a new media_shas keyword param so only blobs the caller identifies as media (by extension, mirroring the live scanner's media_kind gate) get probed; default frozenset() keeps existing callers/tests probing nothing.
Two review findings on Task 1's gitobj.py plumbing: - scan.py had its own copy of the binary-detection heuristic (_BINARY_CHUNK_SIZE/_TEXT_CHARACTERS/_is_binary) duplicating gitobj.py's _is_binary_bytes. The two must stay in lockstep — they classify the same file content for the live scan and the time-travel reconstruction respectively — so scan._is_binary now reads the first chunk and delegates to gitobj.is_binary_bytes (the single source). Renamed _BINARY_CHUNK/_is_binary_bytes to unprefixed BINARY_CHUNK/ is_binary_bytes since they're now an intentional cross-module contract (pyright strict flags underscore-prefixed cross-module access as reportPrivateUsage). - gitobj.py repeated the `git -c safe.directory=* -C <root>` argv prefix in _git() and inline in blob_stats_batch's cat-file --batch call. Extracted _git_argv(root, *args) as the one place those safety flags live.
- cache_clear_all docstring now lists blob-stats among the caches it deletes (the delete loop already removed it, docs just lagged) - cache_save_blobs docstring references the manifest-cache swallow policy specifically, since files/git-history let OSError propagate - blob-cache roundtrip test now asserts media_height alongside media_width so both halves of the pair are verified
_collect_git_history/_collect_git_dates take an optional ref (a resolved sha) so the git log walk starts there instead of implicit HEAD, giving correct created/modified dates and commit list as of a past ref. The git-history cache key generalizes head_sha -> commit_sha (any ref sha is immutable, so caching on it is permanent); cache version bumped 12 -> 13 since the stored key name changed.
Split _build_tree's two filesystem touch-points into injected callables (list_children, make_file_node) so a git-object reconstruction can drive the identical frame/rollup/ordering loop. The live scan wires up FS adapters that reuse the real os.DirEntry, keeping _file_node's stat/hash byte-for-byte identical. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…objects Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-scan parity ls_tree_files kept every ls-tree entry with git type "blob", but a committed symlink is also mode 120000/type blob. The live scan drops symlinks entirely (_live_list_children only keeps is_file/is_dir with follow_symlinks=False), so a ref reconstruction with a committed symlink produced an extra file node the live scan never shows, breaking the bit-identical structure/layout invariant. Skip mode-120000 entries in ls_tree_files, and harden the reconstruction guard test with a committed symlink so a regression here is caught by the same test that pins structure/layout signature equality.
…cache Exposes reconstruct_manifest over the existing SSE manifest route so a client can request the tree as of a past commit. A ref's manifest is immutable, so it gets its own permanent on-disk cache keyed by (repo_key, ref_sha); the content-signature and ref manifest caches now share gzip load/save helpers instead of duplicating the envelope logic. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds URL_PARAMS.REF (wire param the backend's ?ref= endpoint from Task 6 reads), plumbs it through manifestUrlFor, and adds ref? to SourcePayload so a time-travel jump can carry a commit sha end to end.
…oll suspend Adds the view-state pin for viewing a repo at a past commit without switching CURRENT_SOURCE, plus the load path (final-only, shares the generation guard with loadSource) and exit path (clears the pin, then reloads HEAD). The live-update poll and exclude-refresh reaction both yield while pinned so they can't pull HEAD back in over the pinned view.
…ears loadRef reused pumpManifestStream (which writes SCAN_PROGRESS) but never reset it in finally like loadSource does, leaving the loading overlay stuck over the pinned city. Mirror loadSource's authoritative-gen cleanup + add a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
TimeTravelBar: a bottom-bar slider over the full (stable) HEAD commit history, mounted in CenterPane. Settled drags call loadRef/exitTimeTravel; axis is locked in per source so a shorter past-ref manifest never shrinks the track. Also guards FilePreviewPane's fullPath fetch during time travel (the endpoint reads disk, not the reconstructed ref).
…anifest cast) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
I1: TIME_TRAVEL_REF only got cleared by exitTimeTravel, but switching repos goes through loadSource in place — the pin from repo A survived onto repo B, wedging its poll/excludes reactions and file preview. loadSource always loads a source's live HEAD, so clear the pin there. M2: ReadmePane fetched the working copy unconditionally, unlike FilePreviewPane's guard, so the InfoPane showed HEAD's README while pinned to a past ref. Mirror FilePreviewPane's guard.
…ding overlay) loadRef now streams the final directly like the live-update refresh instead of via pumpManifestStream, so it never writes SCAN_PROGRESS: scrubbing no longer pops the full-screen loading overlay, the scene tween carries the morph. The slider tracks the thumb locally and debounces the load so a drag fires one rebuild on settle, not one per step. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stack the commit info under a full-width slider and give the bar a fixed width, so the centered bar no longer re-centers (and the slider no longer jumps) as the commit subject changes width while scrubbing. Subject truncates with ellipsis. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
loadRef now marks REBUILD_STATUS=Rebuilding from the start, so the bottom-left footer indicator shows 'rebuilding…' through the fetch (the slow part on a cold ref) and the apply, instead of no feedback now that the overlay is gone. On a failed ref load it marks the footer error rather than reopening the source picker, matching the live-update refresh path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the oversized btn-secondary text button with the shared 22px btn-icon--sm (a RotateCcw icon + Back to live tooltip), matching the app's other icon buttons. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Drop the drop-shadow; the bar is now dark semi-transparent with a backdrop blur of the scene behind it. - Move the reset to the right of the slider (its own track row). - Use the app's shared .setting-row-reset (RotateCcw), always shown: accent when time-traveling (can go back to live), disabled/faded when already live - matching how reset works on settings rows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Glass fill derives from --cc-bg-modal (honors the theme surface) instead of a pure-black wash. Tighten the slider-to-reset gap and the reset-to-right-edge padding so the button sits snug at the edge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t line Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Uses the shared commitUrl() helper (as CommitPane does). Falls back to plain text when the source has no remote (a local repo without an origin). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Symmetric bar padding + drop the reset's side padding so both edge icons are bare glyphs equidistant from their edge and the slider. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Auto-deselecting a scrubbed-away element mid-drag closed the right sidebar, reflowing the scrubber track under the pointer and jumping the position to the end. Gate the per-frame prune on a new SCRUB_DRAGGING signal (driven by the TimeTravelBar's pointer down/up, replacing its local ref) so the deselect — and the layout change it triggers — waits until the drag ends. Keyboard/edge-jump scrubs aren't drags, so they still prune immediately. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Track edits out of the box instead of requiring the user to enable polling. (Only affects fresh state; a persisted LIVE_UPDATES keeps the user's choice.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ty" hint The section lists only the user's browser-saved excludes; a reader can't tell node_modules/.git/etc. are already skipped. Add a link to the README's "Skipped by default" list. Section.hint widens from string to ComponentChildren so it can carry the link (every existing string hint still works). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rides The timeline pose force-enabled RUINS and BLUEPRINTS so the shot showed every representation, but that misrepresents the default experience — future files are off by default. Drop both overrides; the shot now enters Timeline mode with no settings layer of its own, so the README screenshot matches the defaults (deleted stubs on, future files off). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Captured via `just screenshots timeline` — reflects the default settings (deleted stubs on, future files off). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stays HEAD The file tree and search now read a new HISTORY_MANIFEST signal — the all-time union manifest while scrubbing, the live HEAD manifest otherwise — so deleted paths still appear and can be inspected in Timeline. The README pane instead reads HEAD (MANIFEST) directly and no longer shows a "not available in time travel" state: it fetches the current checkout, which is always HEAD. Removed that now-dead InfoBodyKind.TimeTravel branch and its imports. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New HISTORY_NODE_STATE signal maps each union path to its state at the current scrub position — present / deleted (ruin) / future (not yet created) — reusing the replay timelines + ruinStateAt, with directories inheriting the strongest descendant state (present > deleted > future), matching how a street's state is decided from its buildings. The file tree and search results style deleted rows faded + struck through and future rows faded + italic, so the sidebar tracks the city as you scrub. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Clicking a deleted or not-yet-created path in the left-sidebar tree/search selects it (right sidebar opens with its info / deleted state) even though the city renders nothing for it at this commit. Previously the per-frame prune cleared it instantly. The prune now fires only when the scrub actually MOVES — so a selection removed BY scrubbing is still dropped (no dangling outline), but a static explicit pick is kept until you scrub away from it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…off) The left-sidebar tree + search now filter the union to the same set of paths the city renders: deleted (ruin) paths drop out when "Show deleted files" is off, and future paths drop out when "Show future files" is off — with emptied directories pruned. Filtering lives in HISTORY_MANIFEST (a fast path returns the raw union when both toggles are on), so the tree and search consume it unchanged. Added a SCRUB_COMMIT signal (floor of SCRUB_POS): per-path state only changes at integer commits, so the marking + filter recompute once per commit crossing instead of every sub-commit interpolation frame. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…b commit Reverses the earlier "show deleted/future, marked" approach: the file tree and search now always filter the union to just the files alive at the scrubbed commit — no deleted, no future — so they match exactly what the city renders, regardless of the ruins/future toggles. Replaces the 3-state HISTORY_NODE_STATE map (and its deleted/future row styling) with a simpler PRESENT_PATHS set (present files + their ancestor dirs); the history manifest filters to it. SCRUB_COMMIT keeps the recompute per commit, not per frame. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…EADME Extract the file/dir "content is HEAD, not the timeline commit" toast into a shared <TimelineStaleNote> component (its own colocated CSS), and use it on the README pane too: while scrubbing, the README floats a toast saying it shows HEAD, not the README at the timeline commit. ReadmePane now owns a positioning-context container with an inner scroller so the toast pins like the other panes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delete 5 genuinely-dead exports (no production caller; verified via knip + per-export grep, adversarially — most knip hits were over-exports or the uniform *Config convention, left alone): - scene.focusCurrentSelection — the F-key handler calls rig.focus* directly - useSettings.useDiffersFromDefault — siblings used, this one never - dates.dateMs, dates.humanAge — humanAge was test-only (removed its test too) - shots.ShotName — unused type Also un-export tierLevelFor/tierFromPrefix (fadeTiers) — used only internally. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cache volume is compose-project-scoped (codecity-<slug>_codecity-cache), so the old `docker volume rm codecity-cache` never touched a worktree's cache. Now it resolves the slug, stops the dev stack to free the volume, and removes the right one — a full reset of the clones + manifest/timeline/blob caches, which all live in that single volume. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…live view The loading overlay is shared by the cold-boot live load and the timeline enter, but its Cancel was hardcoded to open the project list — so cancelling a timeline load dropped you into the switcher (and the bundle kept loading in the background). Now each load registers its own cancel via a LOADING_CANCEL signal: timeline enter aborts the history SSE (fetchTimelineBundle gained an AbortSignal) and stays on the live city, since the scene isn't repacked until after the fetch. The live cold-boot load has no prior view, so it keeps the project-list fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…l 5s Cancelling the live-HEAD reload that runs when you leave Timeline dropped you on the project switcher. loadSource resets Timeline and streams a skeleton into the shared scene at its start, so "stay in Timeline" isn't possible — instead exit registers a cancel that re-enters Timeline where you were (bundle is warm-cached, scrub position restored). The registration survives the load reaction's overlay because showLoadingOverlay now only sets the cancel handler when one is passed. Also: default live-update poll 3s -> 5s. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…leted, flip rebuilding instantly - Exclude re-scans live HEAD, which Timeline's fixed union scene can't reflect, so hide the exclude button while scrubbing instead of offering a no-op. - Excluding a file also drops it from the HEAD manifest, so the deleted-file detection (absent from HEAD) wrongly flagged excluded files as "deleted". It's still on disk + fetchable — subtract ACTIVE_EXCLUDES from the check. - The footer only flipped to "rebuilding" once the exclude re-scan streamed back; mark it rebuilding when the refetch starts so it responds on click. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Show a centered spinner instead of an sr-only span while a file preview streams, and mark the file-content fetches priority:'high' so the active pane jumps ahead of background manifest/blob requests on big repos. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Thread the active excludes through /api/timeline the same way live scans carry them: build_timeline_bundle folds them into the .codecityignore skip filter, so an excluded path is absent from the union manifest, every delta, and the blob tables. Excludes are part of the timeline cache key so a filtered bundle never collides with the unfiltered one at the same HEAD. The frontend sends activeExcludePathsFor(src) when entering Timeline. With excluded files gone from the union, they can't be selected there, so the RightSidebar's excluded-vs-deleted guard is now moot and removed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A lone commit has no history to scrub. The date axis collapsed (span floored to 1), so indexToFraction returned 0 and the handle stuck to the left edge. Treat a single commit as the present: handle + tick + played-fill sit at the right, and the track is inert (default cursor, no drag, out of the tab order) instead of a grab-and-freeze slider. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…pses Commit dates are day-precision, so a repo whose whole history lands on one calendar day has a zero time span: every commit mapped to fraction 0 and the handle stuck to the left, unscrubbable. Detect the degenerate span and fall back to even index spacing (oldest left, present right) so you can scrub all the commits. The time-clustered axis still applies whenever dates spread. Generalizes the earlier single-commit fix (n=1 is just the degenerate case with one point → pinned to the present, inert). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t set Scrub height normalized floors against presentLineStats (the line range of files present at that commit), so a building could shrink as its file grew and a lone-present file collapsed to MIN_FLOORS (degenerate range). Use the fixed union range (heightCtx.lineStats) instead, matching how width already uses the union byteStats, so a building's height is a function of its own line count. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…'s tree A 2-commit repo sampled only ~8 grid cells; on a small island where the city covers the center, most collide and accepted falls below the commit count, dropping trees (HEAD's tree vanished). Floor the grid at TREE_MIN_CELLS so every commit lands a tree; large repos are unaffected (treeTarget*4 exceeds it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dates) Scrub color/age normalized against the present-set date range (correct: a scan at commit N sees commit N's files), but read DAY-precision commit dates. At HEAD those can't match live's FULL-precision file dates: same-day commits collapsed to one instant, so every building painted at recency 1 and HEAD diverged from live. Prefer each file's own modified/created date once it has reached its final (HEAD) state; fall back to the commit day-date for earlier history (all that's available) and for fixtures without dates. Keeps the per-scrub present-set normalization, so scrubbing still re-weathers each commit, but HEAD now equals the live view. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…red fixture test_blob_stats_batch_media_probe_gated_by_extension read logo.png from tests/fixtures/sample-repo/, which is gitignored — present locally but absent on a fresh CI checkout, so the job failed with FileNotFoundError. Generate a real PNG with PIL (the probe's own decoder) so the test is self-contained. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
thalida
force-pushed
the
feat/issue-113-time-travel
branch
from
July 24, 2026 04:28
238fd6a to
6494cee
Compare
Trivy flagged 10 HIGH CVEs in Pillow 12.2.0 (DoS / heap OOB write across the font, GD, JPEG2000, PDF and ImageCms parsers), all fixed in 12.3.0. Raise the floor above the vulnerable range and relock. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A Timeline mode that replays the repo's whole git history as the city being built, aged, and torn down.
Flip the Live/Timeline toggle on the scene and drag the scrubber. Buildings rise as files are added, weather and crumble into ruins as they're deleted, and files that don't exist yet at the scrubbed commit show as faint tinted slabs. The full history loads once as a union city, then scrubs entirely client-side, so dragging stays smooth on large repos.
Closes #113.
What you get
Notable implementation notes
deltas[0..i]to reconstruct any commit's file set, and the replay is tied to the same reconstruction the scan path already proved (tests assert they match at every commit).noteon the bundle.Testing
Backend unit + TestClient coverage for the delta walk, union manifest, replay-matches-reconstruction, the union cap, excludes filtering, and cache keying. Frontend vitest for the scrub controller, timeline mode enter/exit/cancel, the API layer, and the sidebar/preview states. Full pre-push gate (pytest, ruff, vitest, eslint, prettier, typecheck) green.
🤖 Generated with Claude Code