Skip to content

feat(ui): git status indicator in the bottom bar, with a panel of uncommitted and unpushed work - #537

Merged
Ark0N merged 7 commits into
Ark0N:masterfrom
opticon454:feat/git-status-indicator
Oct 6, 2026
Merged

Ark0N merged 7 commits into
Ark0N:masterfrom
opticon454:feat/git-status-indicator

Conversation

@opticon454

Copy link
Copy Markdown
Contributor

What

An optional Git status indicator at the right of the bottom bar, for when an agent leaves work uncommitted or unpushed.

For the active session's repository it shows ● 3 (uncommitted files), ↑ 2 (commits not pushed), or ✓ (everything committed and pushed): amber when there is something outstanding, red on a merge conflict, muted green when clean. Click it and a draggable window opens (same look as the Files window, default spot just to its left so both can be open) listing exactly:

  • Uncommitted changes: grouped Merge conflicts / Staged / Not staged / Untracked, with the git status letter per file, renames shown as new ← old. Click a file to preview it (deleted files and untracked folders are not clickable).
  • Not pushed: each commit's short hash, subject, author and age. For a branch with no upstream it lists the commits no remote has and says so.
  • A branch line (main → origin/main, ↑ ahead, ↓ behind, stash count) and a footer saying it is read-only.
  • When the session's folder holds several repositories (rather than being one), each gets its own collapsible section (open when it has something outstanding, collapsed with a ✓ when clean) and the indicator adds them up.

Optional and per-device: Settings → Header & Panels → Bottom bar → Git status, default off. While it is off nothing polls and the button never shows. Phones are unaffected (the toolbar's right group is already hidden there).

How it works

  • GET /api/sessions/:id/git-status (src/git-workspace-status.ts, routes/git-status-routes.ts). Read-only and offline: it never fetches, pulls, commits or writes. git status runs with --no-optional-locks (it does not even refresh the index, so polling cannot contend with the agent's own git commands) and core.fsmonitor=false. "Behind" is therefore as of the last git fetch, and the panel footer says so; "ahead" and the unpushed list are exact against the remote-tracking refs on disk.
  • Async, bounded (10 s timeout, 8 MB output cap), no shell: the working directory is the process cwd. Lists are capped (300 files, 50 commits) while the counts stay exact. Concurrent polls of one folder share one in-flight computation and a result under 4 s old is reused; ?fresh=1 (the panel's Refresh button and opening the panel) skips that reuse.
  • Remote (SSH) and Docker sessions are never inspected (state: 'unsupported', no indicator): a Docker workspace is writable from inside its sandbox, and running git on it would run on the host. A local session already runs as the same OS user, so polling adds no privilege. Ownership goes through findSessionOrFail, so another user's session is a 404.
  • git's stderr and remote URLs are passed through redactGitCredentials before they reach a client.
  • Frontend (git-status-ui.js, a new mixin): polls every 15 s while the page is visible, immediately on a session switch or window focus, and stops completely when the setting is turned off (no reload needed). Everything git supplies (file names, commit subjects, authors) is written with textContent only. Stale responses (another session, setting turned off mid-request) are dropped.
  • The per-device key follows the existing convention: in displayKeys, stripped from the strict PUT /api/settings, not in SettingsUpdateSchema.

Which repositories

git finds a repository by walking up from the working directory, so the rules are explicit (and tested against real repos):

  • Inside a repository (or at its root): that one repository, whole. A subfolder reports its enclosing repo (and says where it is). A nested repo below it is just an untracked folder to the outer one and is not scanned; start the session inside it to see it.
  • Not inside one (a folder that holds several projects): every repository found up to two levels down, alphabetical, at most 12 (the response says when there were more). Dot-folders, node_modules, dist, build, target, vendor, venv and __pycache__ are skipped, symlinks are never followed, and a repository's own contents are not searched. The list is re-scanned at most every 30 s.
  • An unrelated repo above the workspace is ignored when it is the home folder or higher (a dotfiles repo in $HOME, or /): without this, a workspace under ~/codeman-cases would report the dirty files of your dotfiles repo. A workspace that is that repo's root is not ignored. A worktree (.git is a file) counts as a repository. A submodule's own uncommitted files are not reported (only a changed pointer).

Tests

  • test/git-workspace-status.test.ts (50): the porcelain-v2 parser (branch/upstream/ahead-behind, detached, staged+modified = two rows, renames, unmerged, untracked with spaces/quotes/newlines); real git: clean/dirty/staged/renames/conflicts/odd names/stashes/detached/the file cap, a bare remote for in-sync/ahead/no-upstream/pushed-branch, behind only after a fetch (it never fetches); it only reads (the index bytes are unchanged and no lock is left, and a repo-configured core.fsmonitor hook is not run, each verified to fail without the hardening flags); cache single-flight/TTL/fresh; error mapping, credential redaction, partial failures; no write or network verb is ever issued. Overview: folder of several repos (sorted, own status each), depth 2 found / depth 3 not, subfolder reports the enclosing repo, no scan inside a repo, a session inside a nested repo, the $HOME dotfiles repo and a repo above home ignored (verified to fail with the guard disabled) while a workspace that is the root is not, node_modules/dot-folders/symlinks skipped, worktrees counted, the 12-repo cap, no repo at all, git failure as an error, and the discovery cache with fresh.
  • test/routes/git-status-routes.test.ts (10): envelope, not-a-repo, 404, runs in the session's working directory, remote and Docker never run git, ?fresh=1, multi-user ownership.
  • test/git-status.browser.test.ts (11, real Chromium + real server + a real repo with a remote): off by default with no request to the route; turning it on through the real Settings save (HTTP 200, so the key stays out of the strict PUT); counts and tooltip; sits at the right of the bottom bar before the version; the panel contents; hostile file names render as text; file click previews by absolute path; drag; clean state after a push (and a manual refresh shows it at once); a non-repo session hides the button and the open panel stops showing the previous repo; a folder holding two repos shows two sections (the dirty one open, the clean one collapsed) and the indicator adds them up; turning it off hides everything and stops polling; the amber colour is the computed one (skin rules out-rank a bare class).
  • Full CI gate: typecheck, lint, format, public assets, catalogue and all unit and integration tests pass.

Merge order

Rebased onto 1.34.0 (the earlier config/test-suites.ts overlap with the merged PRs is resolved). Independent of my other open PRs apart from adjacent insertions in config/test-suites.ts and docs/api-reference.md, which will need a trivial rebase for whichever lands second. I will do that as they merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS

opticon454 and others added 2 commits October 5, 2026 07:19
…ommitted and unpushed work

Optional and per-device (showGitStatus, default off). GET /api/sessions/:id/git-status is
read-only and offline (no fetch, --no-optional-locks), skips remote and Docker sessions, caps its
lists, and single-flights concurrent polls. The toolbar indicator shows uncommitted files,
commits not pushed, or a check; clicking opens a draggable panel in the style of the Files window.

Which repositories: the enclosing one when there is one; otherwise every repository up to two
levels below the working directory (capped, skipping dot-folders and node_modules, never
following symlinks), each in a collapsible section, with the indicator summing them. A repository
that merely sits above the workspace and is the home folder or higher (a dotfiles repo) is
ignored. Git-supplied text is only ever written with textContent.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
Rows open an in-panel diff (staged, not staged, untracked as additions, deleted as removals) via GET /api/sessions/:id/git-diff, with Back and Open file. The route matches repo and path against the current status, runs git diff read-only (--no-ext-diff --no-textconv), and caps output at 400 KB.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
@opticon454

Copy link
Copy Markdown
Contributor Author

Added a follow-up (cd9218c): clicking a file in the Git panel now shows what changed in it (an in-panel diff with Back and Open file) instead of just opening the file. It uses a new read-only GET /api/sessions/:id/git-diff, which matches repo/path/kind against the current status rather than trusting them, runs git diff --no-ext-diff --no-textconv, and caps output at 400 KB. Tests and docs are included.

🤖 Generated with Claude Code

opticon454 and others added 3 commits October 5, 2026 09:41
…ing, default on)

The Git window shows each group's files under their folders, collapsed until clicked, with single-child folder chains merged and open folders surviving the refresh. App Settings → Bottom bar → 'Git status: group files by folder' (per device) switches back to the flat list.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
…ed repositories

README, Working With Files (new Git changes section), Settings Reference, The Dashboard and the changeset.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
@opticon454

Copy link
Copy Markdown
Contributor Author

Follow-ups pushed to this PR:

  • Changed files are grouped under collapsible folders (collapsed until clicked, single-child chains merged, open folders survive the 15 s refresh). New per-device setting Git status: group files by folder (on by default) switches back to the flat list.
  • With several repositories, every repo section now starts collapsed; the summary line still shows branch and what is outstanding, and opened ones stay open across refreshes.
  • Docs: README entry, a new Git changes section in Working With Files, Settings Reference, The Dashboard, and the changeset.

Browser test file passes (13).

🤖 Generated with Claude Code

https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS

…und-trip

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
@Ark0N

Ark0N commented Oct 5, 2026

Copy link
Copy Markdown
Owner

Thanks @opticon454 for this. It adds an opt-in Git indicator to the bottom bar for the active session's repository, with a draggable panel of uncommitted files, unpushed commits and per-file diffs, backed by two read-only routes. The hardening is careful and the tests (real git, real Chromium) are excellent, and everything is green here. A few things need to change before merge:

1. The Docker exclusion is keyed on the session, not the path (src/web/routes/git-status-routes.ts:32, with the walk-up at src/git-workspace-status.ts:504 and the scan at :527). A local session whose folder holds a Docker case workspace up to two levels down, or sits inside one, still runs host git on that container-writable repo. git still runs a repository's own filter.<name>.clean command during git status and git diff, and its gpg.program from git log when log.showSignature is set: I reproduced both through a single getGitWorkspaceOverview() call on a parent folder. Containers run as --user <hostUid>:0, so safe.directory does not stop it. Please drop any repository whose root is equal to or inside a Docker case workspace (readDockerCases() in src/docker-hosts.ts has the paths), for the walk-up, the discovery scan and the diff route, with a test.

2. A branch whose upstream is gone reports everything pushed (src/git-workspace-status.ts:295). After the remote branch is deleted and pruned, porcelain v2 still prints # branch.upstream but no # branch.ab, so rev-list @{upstream}..HEAD fails, safe() turns it into 0, and the indicator goes green. Repro: push feature, git push origin --delete feature, git fetch --prune, commit once more: the module returns unpushedCount: 0 while git rev-list --count HEAD --not --remotes is 2. Please treat a missing branch.ab as no usable upstream (fall back to HEAD --not --remotes), show "upstream is gone" in the branch line, and add a real-git test.

3. Turning the setting off during a poll leaves it dead after turning it back on (src/web/public/git-status-ui.js:75). The off branch bumps _gitStatusEpoch, so the in-flight request's finally never clears _gitStatusInFlight, and after re-enabling refreshGitStatus() returns early for that session on every tick until a session switch or reload. Setting this._gitStatusInFlight = false in the off branch fixes it.

4. The docs overstate what the flags prevent (docs/api-reference.md:707, the module header at src/git-workspace-status.ts:32 and the comment at :558). "so repository config never runs a program" is not accurate, since clean filters still run. Please add -c log.showSignature=false to runGit (it removes the gpg path for free) and reword those spots to say clean filters still run, as with any git status.

5. CLAUDE.md's frontend load order (CLAUDE.md:325) is the authoritative list: please add git-status-ui.js(12.57) between home-sessions.js(12.56) and entrance-animations.js(12.6).

6. The changeset (.changeset/git-status-indicator.md:5) names only the git-status route; please mention GET /api/sessions/:id/git-diff too.

Smaller things, fold them in if you like or I will pick them up at merge:

  • src/git-workspace-status.ts:461 slices readdir to 300 entries before sorting, so in a big folder the inspected children are in filesystem order rather than alphabetical, and the readdir itself is not bounded (only the lstat calls after it).
  • src/git-workspace-status.ts:551 refuses a file whose name starts with - ("Invalid path") although every call already passes -- before operands, and the 500 at src/web/routes/git-status-routes.ts:59 returns git's error text without redactGitCredentials.
  • src/web/public/git-status-ui.js:297: the 15 s re-render replaces every row, so keyboard focus on a file row is lost each poll.
  • src/web/public/styles.css:19327: right: 320px with width: 380px puts the panel's left edge off-screen on 600 to 699px tablets, and the aria-expanded background does not show on light skins.

Once 1 to 6 are in, I will merge. Thanks again, this fills a real gap.

…stream, in-flight reset, docs)

- never inspect a repository at or inside a Docker case workspace (walk-up, scan, diff route): git would run its clean filters on the host
- a branch whose upstream was deleted and pruned reports upstreamGone and falls back to commits on no remote, instead of green
- turning the setting off during a poll releases the in-flight flag
- log.showSignature=false; reword the docs: clean filters still run
- CLAUDE.md frontend load order, changeset names git-diff
- discovery reads a bounded, sorted directory listing; leading-dash paths allowed; diff 500 redacts credentials
- keyboard focus survives the poll re-render; panel stays on screen on narrow viewports; aria-expanded visible on light skins

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS
@opticon454

Copy link
Copy Markdown
Contributor Author

Thanks, all six are in (0c4bb51), plus the smaller items.

  1. Docker exclusion by path. getGitWorkspaceOverview takes the Docker case workspaces (readDockerCases() host paths) and drops any repository at or inside one, using real paths so a symlink is not a way round it: the cwd is checked before any git runs, the walk-up root is checked after, the child scan skips and does not descend into them, and the diff route is fed from the same filtered overview (404). Tests include a repo with a filter.mark.clean that drops a marker file: a control run proves git does run it, and with the exclusion it never does (below a folder, at the workspace, inside it, through a symlink, plus a sibling whose name merely shares the prefix).
  2. Upstream gone. parsePorcelainV2 sets upstreamGone when branch.upstream has no branch.ab; the unpushed range then falls back to HEAD --not --remotes, the branch line shows Upstream is gone, and the unpushed section says why. Real-git test of your repro (push, push --delete, fetch --prune, commit): unpushedCount is 2.
  3. Off during a poll. The off branch now clears _gitStatusInFlight. The browser test holds a read in flight with a slow route, toggles the setting off and on, and checks the indicator recovers; it fails without the fix.
  4. Docs. runGit adds -c log.showSignature=false, and the module header, the diff function comment, docs/api-reference.md and the Working With Files page now say clean filters still run, and that a container-writable repository is excluded because of that.
  5. CLAUDE.md frontend load order now has git-status-ui.js(12.57) between home-sessions.js and entrance-animations.js.
  6. Changeset names GET /api/sessions/:id/git-diff.

Smaller items, all done: discovery now reads a bounded (5000) listing, sorts, then takes 300; a leading - in a path is allowed (every operand follows --); the diff 500 goes through redactGitCredentials; keyboard focus returns to the same file row after the 15 s re-render; the panel's right is clamped so its left edge stays on screen below ~700px (tested at 650px); the aria-expanded background uses color-mix on currentColor with the toolbar-skin specificity so it shows on light skins.

Unit/route/browser tests for the module pass (88 + 16), typecheck clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JrzFKEdBLwVfu6ev2ZscJS

@Ark0N
Ark0N merged commit 2063d15 into Ark0N:master Oct 6, 2026
2 checks passed
Ark0N pushed a commit that referenced this pull request Oct 6, 2026
- A cached list of repositories below a folder is re-checked against the
  Docker case workspaces as they are now, so a repository linked as a Docker
  workspace within the 30 s list cache is no longer inspected.
- A diff past runGit's 8 MB output bound is cut short from git's partial
  output instead of failing with a 500.
- The browser test waits for its slow route handler on unroute
  (unrouteAll behavior 'wait'), so a late route.continue() cannot fail the run.
- "Upstream is gone" now reads "Upstream not on remote", true for a branch
  that was never pushed as well as one deleted on the remote; docs mirrored.
- The diff route checks the repository against the workspace's own cached
  repository list (findWorkspaceRepo) and refreshes only that repository,
  instead of a fresh status of every repository in the folder.
- CLAUDE.md: a Key Patterns entry for the git read surface and its rules.
- The enclosing repository is identified with one cached rev-parse before
  any full status, so an unrelated repository above the workspace costs one
  process and its failure no longer hides the repositories below.
- Wiki: the bottom-bar indicator moves out of the header-controls table.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ark0N pushed a commit that referenced this pull request Oct 6, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Ark0N

Ark0N commented Oct 6, 2026

Copy link
Copy Markdown
Owner

Merged in 1.35.0, thanks @opticon454! This one is going to get a lot of use, and you turned all six items from the last round around in twenty minutes. Applied on the way in (2c38e77):

  • The cached repository list is re-checked against the current Docker workspaces on every call, so a Docker case linked over an existing repo drops out right away instead of after the 30 s cache.
  • A diff bigger than runGit's 8 MB buffer is cut short at 400 KB as documented, instead of answering 500.
  • The diff route checks repo/path against the cached list and refreshes only that one repository, instead of a fresh overview of every repo in the folder.
  • An enclosing repo at $HOME or above is identified with one cached rev-parse before any full status runs, so a dotfiles repo whose status fails no longer hides the repositories below it.
  • "Upstream is gone" now reads "Upstream not on remote", which is also true for a branch that was never pushed.
  • The browser test waits for in-flight routes on unroute, CLAUDE.md has a Key Patterns entry for the new git read surface, and the wiki row moved under its own Bottom bar section.

@opticon454
opticon454 deleted the feat/git-status-indicator branch October 6, 2026 10:02
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.

2 participants