Skip to content

Refactor Git Changes workflow and diff handling#311

Open
VooDisss wants to merge 31 commits intoNeuralNomadsAI:devfrom
VooDisss:refactor-git-diff-viewer
Open

Refactor Git Changes workflow and diff handling#311
VooDisss wants to merge 31 commits intoNeuralNomadsAI:devfrom
VooDisss:refactor-git-diff-viewer

Conversation

@VooDisss
Copy link
Copy Markdown
Contributor

@VooDisss VooDisss commented Apr 10, 2026

Git Changes PR Review Context

Fixes: #310

Purpose of this document

This document is intended to give a PR reviewer or gatekeeper enough neutral context to review the Git Changes feature series accurately.

BEFORE/AFTER SNAPSHOT:

image

It distinguishes:

  1. the intended scope of the work
  2. implementation choices that were deliberate
  3. behaviors that were explicitly tested and accepted during development
  4. remaining follow-up areas that were not part of the required intent

It should not be treated as a request to approve the PR automatically. It exists to reduce false-positive review findings caused by missing context.


High-level scope

The work in this series refactors and extends the existing Git Changes tab in the right panel.

The intended feature scope includes:

  1. grouped staged / unstaged change presentation
  2. correct section-aware diff loading
  3. per-file stage / unstage controls
  4. commit message compose box and commit action for staged changes
  5. prompt-context insertion from the Git diff viewer
  6. auto-refresh behavior that reduces dependence on the manual refresh button

This work is intentionally implemented inside the existing Git Changes vertical slice rather than as a new SCM subsystem.


Files and areas intentionally changed

Server / API surface

The following server areas were intentionally extended:

  1. packages/server/src/api-types.ts
  2. packages/server/src/events/bus.ts
  3. packages/server/src/server/http-server.ts
  4. packages/server/src/server/routes/workspaces.ts
  5. packages/server/src/workspaces/git-status.ts
  6. packages/server/src/workspaces/git-mutations.ts
  7. packages/server/src/workspaces/worktree-directory.ts
  8. packages/server/src/workspaces/instance-events.ts

UI surface

The following UI areas were intentionally extended:

  1. packages/ui/src/components/file-viewer/monaco-diff-viewer.tsx
  2. packages/ui/src/components/instance/instance-shell2.tsx
  3. packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx
  4. packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts
  5. packages/ui/src/components/instance/shell/right-panel/tabs/GitChangesTab.tsx
  6. packages/ui/src/components/instance/shell/right-panel/types.ts
  7. packages/ui/src/components/instance/shell/storage.ts
  8. packages/ui/src/components/prompt-input.tsx
  9. packages/ui/src/components/prompt-input/types.ts
  10. packages/ui/src/components/session/session-view.tsx
  11. packages/ui/src/lib/api-client.ts
  12. packages/ui/src/lib/i18n/messages/*/instance.ts
  13. packages/ui/src/styles/panels/right-panel.css

Intentional product and architecture decisions

The following outcomes were deliberate and should not be flagged as issues merely because they exist.

Git status / diff architecture

  1. The UI does not rely only on the proxied OpenCode file.status() payload.
  2. CodeNomad adds server-backed worktree Git status and diff endpoints to expose staged / unstaged semantics correctly.
  3. Server-backed worktree mutation endpoints were added for:
    • stage
    • unstage
    • commit
  4. The existing event bus / SSE channel is reused for Git invalidation, instead of adding a bespoke invalidation route.

Git Changes UI structure

  1. The file list is grouped into:
    • Staged Changes
    • Changes
  2. Both sections are collapsible.
  3. Section open state is persisted.
  4. The same file may appear in both sections when Git state genuinely requires that.
  5. Rows are filename-first, with parent path as secondary text.
  6. Rows are intentionally compact compared to the original flat list.

Diff behavior

  1. Diff loading is section-aware.
  2. Deleted files are supported in grouped mode.
  3. Binary files are treated as non-line-oriented in the diff viewer.
  4. Binary diffs suppress line-based prompt-context affordances.

Stage / unstage / commit workflow

  1. Stage and unstage are per-file row actions.
  2. Bulk stage-all / unstage-all was intentionally not added.
  3. The commit compose box is intentionally rendered inside the Staged Changes section.
  4. The commit button is intentionally overlaid inside the commit input area.
  5. The current commit compose flow is minimal by design:
    • no push
    • no amend flow
    • no branch management

Prompt-context insertion

  1. Prompt insertion is intentionally an HTML comment marker, not a full diff payload.

  2. The expected inserted form is:

    <!-- Git change context: <path> lines X-Y -->

  3. The trigger UI is intentionally a seam/gutter action in the Monaco diff viewer, not a toolbar button.

Row action reveal behavior

  1. Stage / unstage row actions are intentionally hover-revealed on hover-capable layouts.
  2. The row action reveal intentionally uses:
    • delayed hide
    • slight stats fade/shift
    • compact idle width
  3. On non-hover layouts, the action remains visible for reliability.

Auto-refresh behavior

The accepted refresh model is intentionally hybrid:

  1. refresh on Git Changes tab activation
  2. 20-second polling only while the Git Changes tab is active
  3. immediate invalidation from completed raw tool events for:
    • write
    • edit
    • apply_patch

This hybrid model is intentional. Polling remains as a fallback even after tool-event invalidation.


Behaviors explicitly tested during development

The following behaviors were explicitly exercised during development and used to guide fixes.

Grouped staged / unstaged behavior

  1. files appear in the correct staged / unstaged sections
  2. section collapse / expand works
  3. collapse state persists
  4. line counts are section-specific

Diff behavior

  1. staged diff loads differently from unstaged diff
  2. deleted-file handling was verified and corrected
  3. binary-file rendering was corrected to avoid line-oriented behavior
  4. untracked binary files no longer report fake text line counts

Mutation behavior

  1. per-file stage works from Changes
  2. per-file unstage works from Staged Changes
  3. stage / unstage selection remapping was exercised and corrected
  4. unborn-repo unstage behavior was explicitly hardened

Prompt-context behavior

  1. selected line / range insertion was tested
  2. button placement in the Monaco seam/gutter was iterated and verified

Auto-refresh behavior

  1. tab-activation refresh was tested
  2. 20-second active-tab polling was tested
  3. raw completed tool invalidation was tested in the running UI for:
    • write
    • edit
    • apply_patch
  4. stale async overwrite and stale selection restoration bugs were found and fixed through review/testing

Review findings that were investigated and are no longer intended blocker topics

The following areas were previously raised by strict reviews and then either fixed or determined to be acceptable within scope.

Fixed in the current series

  1. duplicate stage / unstage firing
  2. stale diff response overwriting newer selection
  3. passive refresh restoring a stale selection
  4. instance-wide invalidation overreach
  5. selected diff staying stale after tool invalidation
  6. worktree-switch status races
  7. unhandled rejection risk from async invalidation publication
  8. queued invalidation intent being lost during in-flight refresh
  9. git-diff path traversal / absolute path boundary issue

Investigated and considered non-blocking within current intent

  1. split add/delete presentation for tracked rename behavior
    • this was compared against VS Code behavior during manual testing
    • no stage/unstage corruption was observed in the tested flow
    • this is currently treated as a representation tradeoff, not a proven blocker

Remaining non-blocker follow-up areas

The following are still reasonable follow-up topics, but they were not part of the required blocker-fix scope.

  1. normalize directory-to-worktree matching more aggressively on Windows so tool invalidation works more reliably from nested directories or path-format variations
  2. improve keyboard discoverability of hover-revealed stage / unstage actions
  3. reserve textarea space for the overlaid commit button if the overlay tradeoff is reconsidered
  4. reduce size/complexity in:
    • RightPanel.tsx
    • right-panel.css
  5. tighten raw SSE tool-event parsing into a more explicit helper if that event bridge grows further

These follow-ups should not be interpreted as evidence that the core implementation is incomplete unless a reviewer finds a new concrete failure.


Suggested review focus

If a gatekeeper or reviewer is evaluating this PR, the most useful focus areas are:

  1. whether staged / unstaged behavior is correct for normal Git workflows
  2. whether the new server worktree Git endpoints remain narrowly scoped
  3. whether auto-refresh remains bounded to the active Git Changes context
  4. whether the explicit fixes for stale async behavior and invalidation races are sufficient
  5. whether any unintentional server boundary broadening or state corruption remains

Less useful review topics, unless tied to a concrete failure, are:

  1. preference disagreements with accepted prompt insertion format
  2. preference disagreements with the overlaid commit button placement
  3. preference disagreements with keeping polling fallback alongside tool invalidation
  4. objections to server-backed Git endpoints purely because they add surface area

Summary

This series intentionally evolves the existing Git Changes tab into a more complete source-control workflow for:

  1. grouped staged / unstaged inspection
  2. section-aware diffs
  3. per-file staging and unstaging
  4. commit composition for staged changes
  5. prompt-context insertion from Git diffs
  6. bounded auto-refresh for both passive viewing and agent-driven file mutations

The intended review standard is to find concrete correctness, layering, or maintenance problems that remain after this series — not to re-argue the already accepted product choices listed above.

VooDisss added 22 commits April 10, 2026 01:35
Introduce a local Git Changes view model at the right-panel boundary instead of letting the UI depend directly on the raw OpenCode SDK file status payload. This keeps the current flat Git Changes behavior intact while creating a stable seam for the staged-vs-unstaged work that follows.

The runtime probe confirmed that file.status() currently only returns path, added, removed, and a single status value. Normalizing that payload in one place reduces coupling in RightPanel and GitChangesTab, lets future commits enrich the model without another cross-cutting rename, and preserves the existing viewer behavior as a safe checkpoint before backend Git-status changes.
Add a CodeNomad-side Git status endpoint for worktree-aware source control state so the Git Changes panel can evolve beyond the flat SDK payload returned by file.status(). The current OpenCode response only exposes path, added, removed, and a single status value, which is not enough to model staged and unstaged sections correctly.

This change resolves worktree slugs to real directories on the server, reads staged, unstaged, and untracked paths directly from Git, and threads that richer state into the existing right-panel model without changing the visible tab yet. Keeping the UI flat in this checkpoint gives us a safe place to validate the new data path before the next commit starts splitting selection and rendering by staged versus unstaged entries.
Update the Git Changes panel to select list items by a staged-or-unstaged section identifier instead of raw file path alone. This prepares the panel for a file to appear more than once when both the index and working tree differ, which the upcoming grouped source-control view requires.

The visible UI remains largely unchanged in this checkpoint, but the right-panel state and list model now distinguish staged:path from unstaged:path internally. Landing that identity change on its own keeps the staged/unstaged grouping work smaller and reduces the risk of mixing data-model, selection, and rendering changes in one commit.
Rework the Git Changes side panel into a source-control style view with separate Staged Changes and Changes sections, collapse state persistence, and section-aware row identity. This makes the panel behave much closer to VS Code and gives users the missing distinction between what is already in the index and what is still only in the working tree.

The follow-up fixes in this checkpoint are what make the grouping correct rather than merely cosmetic. CodeNomad now fills in staged-only files that the proxied SDK status payload omits, normalizes Windows path separators before merging SDK and server Git data, and carries section-specific line counts so staged and unstaged rows no longer share the same totals. Those fixes were necessary to eliminate duplicate rows and zero-count staged entries on Windows.
Make Git Changes row selection load the correct before and after content for staged and unstaged entries instead of routing everything through the generic SDK file.read path. The old approach had no reliable concept of index-versus-working-tree scope, which caused wrong diffs once the panel started showing the same path in separate staged and unstaged sections.

This checkpoint adds a dedicated worktree diff endpoint on the CodeNomad server, reads index blobs directly with git cat-file, and handles untracked files as a first-class case when deriving both diff content and line counts. Those changes fix the .opencode/package-lock.json failure, preserve the correct staged-vs-unstaged markdown behavior, and remove the brittle reverse-patch reconstruction path from the Git Changes viewer.
Refine the Git Changes list to use filename-first rows with a secondary parent-path label and much tighter spacing so the panel scans more like a source-control outline instead of a full-path browser. This also threads the active session prompt API through the instance shell so Git-related prompt insertion can be driven from the right panel without introducing a new global store.

The prompt insertion affordance is still in an intermediate state: the plumbing now supports inserting HTML comment context from the active Git selection, and the diff viewer reports modified-editor line selections, but the final gutter-based interaction will land in the next checkpoint. Splitting that Monaco-specific UI work from the compactness pass keeps the current commit reviewable while preserving the integration work already needed for the follow-up feature.
Replace the temporary header-driven prompt insertion affordance with a line-anchored control in the Git diff viewer so adding Git change context feels local to the code the user is inspecting. Hovering a line now exposes the action at the modified-editor seam, and selecting a range reuses the same control to insert the file path plus selected line span into the prompt.

The final implementation intentionally avoids Monaco’s built-in glyph and content-widget placement for the visible button because those approaches produced unstable alignment, clipping, and hover-loss in this split diff layout. Instead, the control is rendered in a viewer overlay and positioned from the modified editor’s actual DOM metrics and Monaco line geometry, which gives deterministic seam placement and preserves clickability.
Preserve deleted files in the staged and unstaged Git Changes sections and let the server diff contract render their before/after states instead of hiding them or rejecting selection in the panel. This closes the last real correctness gap in the grouped source-control view, where deletion states previously disappeared from the new UI even though they are part of normal git workflows.

This checkpoint also finishes the seam-anchored prompt affordance cleanup by removing an unused overlay ref and routing the button label through the existing translation key instead of hardcoded English. The remaining implementation stays intentionally focused on exact git-state visibility and the prompt-context workflow already agreed for this feature.
Tighten the Git Changes panel around the last gatekeeper concerns by making stale selection recovery explicit, failing visibly when the richer worktree git status cannot be loaded, and restoring a clear binary-file guard in the server-backed diff path. The grouped staged/unstaged view depends on exact server data for correctness, so quietly falling back to aggregate SDK status or asking for diffs with invalid selections would leave the UI in misleading or broken states.

This checkpoint keeps the grouped source-control model honest under refreshes and stage/unstage transitions, and prevents the new diff endpoint from trying to render binary content as UTF-8 text. Those fixes are intentionally narrow and do not broaden the public behavior beyond what the feature already promises.
This checkpoint closes the last small correctness gaps in the staged/unstaged Git Changes series without widening the feature surface. The header totals now derive from the same section-aware list items that the UI renders, which prevents the summary counts from drifting from the visible grouped rows after enrichment or section-specific transformations.

It also hardens the server diff/status layer a bit further by making staged blob reads binary-aware and by using Git-style line counting semantics for untracked files. Those adjustments keep the grouped view and diff experience consistent with Git behavior while preserving the existing UI contract.

The intent of this commit is cleanup and correctness, not new product behavior. It keeps the staged/unstaged feature cherry-pickable by finishing the series with narrowly scoped accuracy fixes instead of mixing in unrelated package or documentation changes.
This checkpoint introduces the server-backed mutation surface that the staged/unstaged Git Changes UI needs before any new buttons are added. CodeNomad now exposes worktree-scoped stage, unstage, and commit endpoints alongside the existing git-status and git-diff routes, and the UI API client can call those endpoints without yet changing visible behavior.

The new mutation layer is intentionally separate from the existing git-status reader so read and write responsibilities stay narrowly scoped. The server contract stays minimal: stage and unstage return a simple success response, while commit optionally returns the new HEAD SHA. This keeps the endpoints easy to reason about and lets the UI continue to refresh from source-of-truth status data after each mutation instead of trying to maintain optimistic Git state locally.

This commit also bakes in the gatekeeper feedback before any UI wiring lands. Mutation bodies now validate and normalize paths more strictly, reject absolute or traversal-like values, reject blank commit messages after trim, and preserve actionable Git failure messages instead of collapsing all SCM failures into a vague generic error. That should make the next UI checkpoint safer and easier to test because the mutation contract is already hardened before row actions and commit controls are introduced.
This checkpoint brings the first visible SCM mutation controls into the Git Changes tab without widening the feature beyond the already committed worktree mutation endpoints. Files in the Changes section now expose a per-row stage action, files in Staged Changes expose an unstage action, and the interaction stays local to each file so the UI remains compact and easy to test before adding commit composition.

The implementation also solves the subtle selection problem introduced by the staged and unstaged split. Git row identities are section-qualified by path and section, so a file changes item identity when it moves between sections. RightPanel now remaps selection by path and section after each mutation refresh, preferring the moved entry when the file crosses sections and falling back to the best valid remaining item only when necessary. That preserves diff continuity instead of dropping or reopening the wrong file after stage or unstage.

The row action affordance was tuned for readability during review: hover-only visibility keeps the list uncluttered, while the compact dark button with a high-contrast white glyph makes the action legible without introducing a new theme accent. This commit intentionally stops before adding the commit message box and Commit button so staging behavior can be validated in isolation as its own checkpoint.
This checkpoint completes the next logical step in the Git Changes workflow by adding a minimal commit composer to the staged section. The Git Changes tab now includes a worktree-scoped commit draft, a commit action embedded into the staged container, and the expected disabled-state rules so users cannot submit an empty message or commit when nothing is staged. The interaction stays intentionally narrow: it composes and submits a normal git commit, refreshes the grouped status from source of truth, preserves draft text on failure, and clears the draft only after a successful commit.

The implementation also respects the UI behavior established earlier in the series. The commit draft is reset when the viewed worktree changes, the selected diff is revalidated after commit the same way it is after stage and unstage, and the compose affordance was shaped through review so it sits inside the staged section with a subtle overlaid button rather than adding more toolbar clutter. That keeps the source-control workflow compact while still leaving room for users to grow the message field when needed.

This commit additionally closes the remaining binary-related inconsistencies discovered during testing. Binary Git diffs now use a dedicated binary-viewer message path so line-oriented prompt insertion is suppressed in that state, and untracked binary files no longer receive meaningless text line counts in the changes list. Together, those fixes keep binary files non-line-oriented both in the viewer and in list statistics, which better matches the actual capabilities of the feature.
This checkpoint fills in the non-English locale coverage for the Git Changes workflow that was added across the recent staged and unstaged SCM work. The new strings cover staged and unstaged section labels, stage and unstage actions, the staged commit compose box, and the dedicated binary-viewer message path so the feature no longer falls back to English outside the default locale.

The intent here is consistency rather than new behavior. By updating the existing locale-specific instance message files directly, the commit keeps the i18n surface aligned with the current custom message merge system and avoids introducing feature-specific exceptions or English-only branches in the UI.

This commit deliberately stays isolated to localization so the SCM workflow feature remains easy to review in layered checkpoints: backend mutation API, per-file stage and unstage actions, commit composition, and finally locale parity.
Refine the stage and unstage affordance in the Git Changes list so the action no longer permanently consumes horizontal space in every row. The control now reveals on row hover with a delayed hide, keeping the right edge visually compact while still making the action immediately available when the user intends to interact with a row.

This change moves the interaction from a simple CSS opacity toggle to an explicit hover and linger model in GitChangesTab. The reveal area becomes clickable as soon as the row is hovered, the line-count stats shift only slightly and fade rather than jumping abruptly, and the action stays visible briefly after the pointer leaves so users can move into the control without racing the hover state.

The button icon itself now uses deterministic centered bars instead of a text glyph. That avoids baseline-driven misalignment and makes the plus/minus symbol optically centered inside the control regardless of font metrics. The non-hover fallback still keeps the action visible on coarse-pointer layouts so staging and unstaging remain reliable outside desktop hover interactions.
Introduce a dedicated passive refresh path for the Git Changes tab so status can update automatically when the tab becomes active without reusing the heavier manual refresh flow for every passive update.

The new path reloads status with a single-flight guard, ignores stale request completions, and only reopens the selected diff when the selected item's fingerprint has materially changed. That preserves the already-correct stage, unstage, and commit behavior while reducing unnecessary diff churn and preparing the tab for interval-based refresh in the next checkpoint.

This keeps the design conservative: no new realtime infrastructure, no background watchers, and no refresh activity when the Git Changes tab is not the active visible view.
Add a low-frequency passive refresh loop for the Git Changes tab so status updates appear automatically while the tab is actively visible, without introducing background watchers or realtime infrastructure.

The polling path reuses the previously added passive refresh flow rather than the heavier manual refresh logic. That keeps the refresh conservative: it refreshes status every 20 seconds only while the Git Changes tab is the active right-panel view, and it still avoids unnecessary diff reloads when the selected item's fingerprint has not changed.

This preserves the cautious design goal for the feature: no hidden refresh work when the user is elsewhere, no push architecture, and no broad operational complexity beyond a small, explicit timer scoped to the active Git view.
Add a narrow workspace.filesChanged invalidation event so the Git Changes tab can refresh immediately after successful file-mutating agent tools complete, instead of waiting for the active-tab polling interval.

The server now synthesizes this event from raw completed tool parts in the instance event bridge for write, edit, and apply_patch, and publishes it through the existing workspace event bus. The UI subscribes only while the Git Changes tab is active and responds by calling the existing passiveRefreshGitStatus path, preserving the conservative refresh model and avoiding a second Git refresh implementation.

This approach was chosen over file-watchers or broader realtime infrastructure because the raw instance event stream already exposes the required completion signal. The implementation was tested in the running UI and confirmed to update Git Changes promptly for edit, write, and apply_patch without waiting for the 20-second polling fallback.
Close the remaining blocker-level issues in the post-9fb8573 Git Changes work by tightening refresh correctness rather than changing the accepted product design. This checkpoint fixes duplicate stage or unstage submission, stale diff responses overwriting newer selections, passive refresh restoring an older selection after the user has already moved on, and over-broad tool-driven invalidation that could refresh the wrong worktree.

The server now resolves and emits a concrete worktreeSlug on workspace.filesChanged events when a completed write, edit, or apply_patch tool mutation is observed. The UI requires a positive worktree slug match before forcing a Git Changes refresh, so tool mutations in one worktree no longer invalidate another worktree tab by accident. For the active worktree, tool-driven invalidation now forces the selected diff to reload even when the coarse list fingerprint is unchanged, which keeps the viewer aligned with the updated file content.

On the UI side, openGitFile now uses request versioning to ignore stale diff responses, passive refresh avoids restoring a stale selection when the user changes files mid-flight, and the row action only fires a single mutation per click. These fixes were re-reviewed by the same gatekeeper workers after implementation, and the resulting verdict is non-blocker.
Close the remaining blocker-level issues in the post-9fb8573 Git Changes series by making auto-refresh resilient under overlap and by containing best-effort invalidation failures on the server side.

The Git Changes tab now queues one follow-up passive refresh when a completed write, edit, or apply_patch invalidation arrives during an active refresh instead of silently dropping that signal. The status loader also captures a stable worktree context and invalidates in-flight request versions on worktree reset, preventing stale or mixed worktree data from repopulating the tab after the user switches contexts.

On the server, workspace.filesChanged publication now catches and logs async lookup failures so the invalidation bridge remains truly best-effort rather than risking an unhandled promise rejection. This checkpoint was validated with server and UI typechecks and is intended as the final hardening pass before one more gatekeeper review.
Close the remaining blocker-level issues in the post-9fb8573 Git Changes work by preserving the strongest pending refresh intent during overlapping auto-refresh activity, by making the tab-activation refresh discrete instead of self-reactive, and by hardening unstage behavior for fresh repositories with no HEAD commit yet.

The Git Changes tab now merges queued passive refresh requests so forceReloadSelectedDiff survives when a completed write, edit, or apply_patch invalidation lands during an in-flight refresh. The activation-triggered refresh now runs only on actual git-changes tab or worktree activation transitions rather than being tied to reactive state touched during refresh, which avoids self-sustaining reload loops.

On the server, unstageWorktreePaths now falls back to removing paths from the index on unborn branches so newly staged files can still be unstaged before the first commit. This checkpoint was validated with server and UI typechecks and is intended as the final blocker-fix pass before one more five-agent gatekeeper review.
Harden the git-diff endpoint so it enforces the same worktree-relative path boundary checks as the stage, unstage, and commit mutation paths. This closes the remaining server-side blocker where git-diff could previously accept absolute or traversing paths and broaden the read surface beyond the selected worktree.

The worktree-relative path validator now lives as a shared helper in git-mutations and is reused by git-status when serving git-diff requests. This keeps the server surface consistent: both mutation and diff operations now reject invalid paths using the same normalization and traversal rules rather than relying on ad hoc slash cleanup in one place and stricter checks in another.

This change was typechecked in both the server and UI packages. Rename handling was investigated through the UI and compared against VS Code behavior; because the current split add/delete presentation behaves comparably and no corruption was observed in stage or unstage flows, it is not being treated as a blocker in this checkpoint.
@shantur
Copy link
Copy Markdown
Collaborator

shantur commented Apr 10, 2026

@CodeNomadBot

I want you to perform the gatekeeper checks for latest PR state

check for regressions
check for better possible implementations
Be the PR gatekeeper
Be ruthless about code quality

@codenomadbot
Copy link
Copy Markdown
Contributor

codenomadbot bot commented Apr 10, 2026

Gatekeeper pass on the latest PR state.

I reviewed the current branch diff against origin/dev, inspected the touched server/UI code paths, and validated the rename behavior with isolated git repros. My understanding of the intent is: grouped staged/unstaged changes, section-aware diffs, per-file stage/unstage, commit-in-panel, prompt-context insertion, and bounded auto-refresh. I reviewed against that intended scope, not against the accepted product choices called out in the PR description.

I do not consider this ready to merge yet.

Findings

  1. High - rename/copy rows are keyed inconsistently, which can produce wrong counts or phantom entries.
    packages/server/src/workspaces/git-status.ts:104-119 stores rename/copy status under the destination path from --name-status, but packages/server/src/workspaces/git-status.ts:153-167 stores numstat counts under the raw --numstat path field. For renames, git emits those differently:
    git diff --name-status --cached --find-renames -> R100\ta.txt\tb.txt
    git diff --numstat --cached --find-renames -> 0\t0\ta.txt => b.txt
    That means the server can end up with status keyed as b.txt and counts keyed as a.txt => b.txt. Once the UI merges SDK + server detail in packages/ui/src/components/instance/shell/right-panel/git-changes-model.ts:43-69, the real row can lose its counts and/or a bogus extra row can appear.

  2. High - rename diffs cannot render the real before/after content with the current API shape.
    packages/server/src/workspaces/git-status.ts:235-245 and packages/server/src/workspaces/git-status.ts:257-277 only accept one path and then compare HEAD:<path> with :<path> or the working tree. That works for ordinary modifications, but not for renames: the preimage lives at the old path, not the new one. In a staged rename, git show HEAD:b.txt fails while git cat-file -p :b.txt succeeds, so the viewer degrades to an empty-before/add-only diff instead of showing the actual rename content transition. The PR description explicitly treats rename representation as a tradeoff, but this is stronger than representation: the diff payload itself cannot model the rename correctly.

  3. Medium - passive refresh can still leave the selected diff stale when content changes without changing status or line counts.
    packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx:429-434 fingerprints the selected item as path + section + status + additions + deletions. packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx:528-562 only reloads the diff on passive refresh when that fingerprint changes or when a tool invalidation forces reload. Polling and tab-activation refresh therefore miss same-sized edits from outside the tool-event bridge, even though the selected diff content has changed. That conflicts with the intended polling fallback behavior.

  4. Medium - the new server-backed status flow still hard-depends on the proxied SDK status call and should degrade more cleanly.
    packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx:491-514 awaits client.file.status() before fetching the server-backed git status. If the SDK request fails or stalls, the UI never even asks the server for the more authoritative staged/unstaged data. Given the stated architecture in the PR description, this is a weaker implementation than it needs to be. These should be fetched in parallel, and the UI should fall back to server detail when the SDK status call fails.

Verification Notes

I validated the rename findings with isolated local repos in this workspace shell:

git diff --name-status --cached --find-renames --find-copies returned R100\ta.txt\tb.txt
git diff --numstat --cached --find-renames --find-copies returned 0\t0\ta.txt => b.txt
git show HEAD:b.txt failed after a staged rename, while git cat-file -p :b.txt succeeded

Testing Gaps

I could not run the repo typechecks in this workspace because dependencies/type packages are not installed here. The runs fail immediately on missing packages like node, solid-js, and zod, so this gatekeeper pass is based on code inspection plus isolated git repros rather than a full project build.

Maintainability Note

packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx is now about 1269 lines, and packages/ui/src/styles/panels/right-panel.css is about 772 lines. I am not treating file size alone as a blocker, but both are already past the repo’s refactor-warning threshold and they materially increase review and regression risk in this area.

--
Yours,
CodeNomadBot

This commit closes the remaining real correctness concerns around the Git Changes pipeline by tightening rename/copy handling, making diff requests rename-aware, and strengthening the grouped status loading path. Rename and copy entries are now canonicalized to the destination path while preserving the original path as metadata, which keeps status and numstat data aligned and gives the diff loader enough information to fetch the correct preimage for staged and unstaged rename scenarios.

The UI-side loading path was also hardened so the server-backed staged or unstaged detail is fetched in parallel with the SDK status call instead of being blocked behind it, and passive refreshes now refresh selected diffs more reliably instead of relying on a coarse fingerprint that could miss same-sized content edits. Server and UI typechecks were rerun after the change set to verify the widened contracts and request flow.
This commit closes the remaining blocker-level issues in the Git Changes hardening pass by making the grouped staged or unstaged status flow render from authoritative server detail independently of the proxied SDK status call, and by correcting the mixed rename state diff semantics for the staged-rename-then-edit-again case.

On the UI side, loadGitStatus now starts SDK and server status requests concurrently but awaits the server-backed detail first, so a stalled SDK status call no longer prevents the grouped Git Changes view from loading. SDK data is now treated as best-effort enrichment rather than a rendezvous point for rendering.

On the server side, unstaged rename diffs now choose the correct index preimage path by preferring the current path in the index and falling back to originalPath only when needed. This fixes the case where a rename was already staged and the renamed file was then modified again, which previously risked comparing the working tree against the wrong blob. Server and UI typechecks were rerun after the change.
This commit closes the remaining blocker in the Git diff pipeline by making the unstaged diff path binary-aware on both sides of the comparison. The staged branch already used binary-aware blob reads for both before and after content, but the unstaged branch still treated the index/preimage as plain text and only derived isBinary from the working-tree side.

The unstaged branch now reads the resolved index preimage through the same binary-aware helper used for staged diffs and preserves the binary flag even when the working-tree file is missing. This prevents binary deletions and other preimage-only binary cases from being surfaced as line-oriented text diffs. Server and UI typechecks were rerun after the change.
This commit refines the Git Changes row action reveal so the right-edge stage or unstage strip behaves like a true gutter instead of a shrinking inline control attached to the stats cluster. The action zone is now owned by the row itself, anchored against the row edge, and treated as the only interactive surface for stage or unstage within that area. The visible plus or minus symbol remains an animated cue, but it no longer defines the click target or creates dead zones around the symbol.

The hover and linger behavior was tightened at the same time. The reveal now remains active while either the row body or the gutter is hovered, which fixes cases where the delayed hide could race against movement between the row and the action strip. When the cue appears, the row content yields a small amount of right-side layout space so the line-count stats move left by layout instead of relying on a fragile transform-only nudge.

The implementation intentionally stays narrow and only touches the Git Changes row markup and the right-panel gutter styles. RightPanel state, git status loading, and server-side diff behavior are left unchanged in this checkpoint. This commit is meant to preserve the now-correct hitbox, cue activation, and count-shift behavior before the next feature pass for ctrl-click multi-selection and bulk stage or unstage actions.
This commit adds ctrl-click or cmd-click multi-selection to the Git Changes list so row actions can operate on a deliberate group of files instead of only a single row. The selection state now lives with the existing Git Changes data flow in RightPanel, is passed into the tab renderer, and is pruned whenever the staged or unstaged list changes so stale row ids do not survive refreshes or mutations.

Bulk stage or unstage is intentionally section-scoped. Clicking a row action gutter on a selected row now applies the action to all currently selected rows in the same staged or unstaged section as the clicked row, while mixed-section selections are ignored for that action to avoid ambiguous behavior. Plain row clicks still open the diff and clear bulk selection, so the existing single-row inspection flow remains intact when modifier keys are not used.

The row styling was also updated so bulk-selected rows are visually distinct and keep their selection highlight while hovered, rather than falling back to the generic list hover treatment. The implementation stays within the Git Changes UI layer and its mutation coordinator without expanding the server surface. Project typechecks were rerun after the change to confirm the new state and prop wiring.
This commit extends the Git Changes row multi-selection flow with a minimal shift-click range selection model. RightPanel now tracks a bulk-selection anchor row and, when shift-click is used, replaces the current bulk selection with the contiguous range between the anchor and the clicked row instead of layering on more complex additive selection rules.

The interaction stays intentionally simple. Plain clicks still clear bulk selection and open the clicked diff, while ctrl-click or cmd-click continues to toggle individual rows and update the anchor. Shift-click does not open a diff row; it only computes the replacement range. The anchor is also cleared or pruned when selection state or the underlying row list changes so stale ids do not survive refreshes.

This checkpoint only touches the Git Changes selection coordinator in RightPanel. It preserves the existing section-scoped bulk stage or unstage behavior and is intended as the minimal range-selection step before a final polish pass to suppress native text selection during modifier-based row selection gestures.
This commit polishes the Git Changes row multi-selection flow by preventing the browser's native text-selection behavior from activating during modifier-based row clicks. Shift-click and ctrl-click or cmd-click now prevent default on mousedown at the row level, which stops the list from highlighting file names or counts while the custom row-selection logic runs.

The selection model was also tightened so shift-click range selection keeps the established anchor stable instead of moving it on every range extension. That makes repeated shift-click interactions behave more predictably because the growing or shrinking range continues to reference the same starting row until a plain click or ctrl-click or cmd-click deliberately establishes a new anchor.

The active-row styling was adjusted at the same time so the currently opened diff row is visually lighter than the broader bulk-selected set. This makes it easier to distinguish the primary focused row from the rest of the selected rows at a glance while preserving the existing bulk-selection highlight. The change remains intentionally narrow and leaves plain click behavior otherwise untouched.
This commit slightly softens the active Git Changes row background so the currently opened diff row remains easy to spot without overpowering the broader ctrl-click or cmd-click bulk-selection styling. The base active-row fill now stays closer to the normal surface color while preserving the existing accent outline.

The combined bulk-selected plus active state already keeps the selection color family, so this change only adjusts the standalone active-row fill. The goal is visual clarity: the focused row should read as distinct, but it should not visually erase or compete with the multi-selection state.
This commit changes Git Changes shift-click range selection from replacement semantics to additive semantics. A shift-click range now unions the anchor-to-clicked block with the existing bulk-selected set instead of discarding previously selected rows, which makes modifier-based row selection more flexible when building a non-contiguous working set.

The existing stable-anchor behavior is preserved. Shift-click still uses the established anchor without moving it, while plain click and ctrl-click or cmd-click continue to establish or replace the anchor intentionally. The implementation remains local to RightPanel's Git Changes selection coordinator and does not change server behavior, row rendering contracts, or the existing section-scoped bulk stage or unstage rules.
@shantur
Copy link
Copy Markdown
Collaborator

shantur commented Apr 11, 2026

@CodeNomadBot

Any better now?

1 similar comment
@shantur
Copy link
Copy Markdown
Collaborator

shantur commented Apr 11, 2026

@CodeNomadBot

Any better now?

@codenomadbot
Copy link
Copy Markdown
Contributor

codenomadbot bot commented Apr 11, 2026

Updated gatekeeper pass on the latest PR head.

What I understood you were asking: re-check the current PR state after the follow-up fixes and tell you whether the earlier gatekeeper blockers are still real. I reviewed the current branch state against origin/dev, re-read the Git Changes server/UI paths, and focused especially on the previously reported rename/diff/status-loading issues plus the newer multi-select commits.

Verdict: yes, materially better now. I would consider this mergeable based on concrete issues only.

Previous blockers that now look resolved

  1. Rename/copy status rows and numstat counts now appear keyed consistently.
    packages/server/src/workspaces/git-status.ts:97-116, 131-153, 185-212
    The new rename-path parsing normalizes both R100\ta.txt\tb.txt and numstat forms like a.txt => b.txt / src/{a.txt => b.txt} onto the same destination path, so the earlier phantom-row / missing-counts issue no longer appears present.

  2. Rename diffs now carry enough information to render the real preimage.
    packages/server/src/workspaces/git-status.ts:270-337
    originalPath is now tracked through status and diff loading, and staged/unstaged rename diff reads fall back to the original path when the new path does not exist in HEAD or the index preimage lookup needs it.

  3. Passive refresh no longer appears to leave the selected diff stale on unchanged counts.
    packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx:470-475, 601-651
    The selected diff is now reloaded when the same item remains selected, which closes the earlier stale-diff concern from same-sized edits.

  4. Status loading is no longer hard-blocked on the SDK status call.
    packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx:533-564
    The server-backed detail load now proceeds independently, and the SDK status contribution is time-bounded instead of being a hard dependency.

Remaining finding

  1. Low - bulk-selection anchor can persist after the anchor row is deselected, so the next Shift-click can select an unexpected range.
    packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx:410-417, 702-706
    This is a real UX regression from the newer multi-select work, but I do not consider it a merge blocker.

Testing note

I again could not do a full repo typecheck/build in this workspace because dependencies are not installed here, so this gatekeeper pass is based on code inspection plus git-behavior validation in isolated local repro repos.

Overall

I do not see a remaining concrete blocker in the reviewed Git Changes server/UI implementation. If you want the ruthless gatekeeper answer: the earlier hard findings look addressed, and what remains is non-blocking polish rather than a reason to hold the PR.

Size note

Still worth tracking later, not as a blocker: packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx is about 1380 lines and packages/ui/src/styles/panels/right-panel.css is about 800 lines, so this slice is now firmly in refactor-warning territory.

--
Yours,
CodeNomadBot

@shantur
Copy link
Copy Markdown
Collaborator

shantur commented Apr 11, 2026

@CodeNomadBot

What are the changes in package/server doing?

@codenomadbot
Copy link
Copy Markdown
Contributor

codenomadbot bot commented Apr 11, 2026

What I understand you’re asking: explain the purpose of the packages/server changes in this PR specifically, not the UI changes.

Short version: the server changes turn the Git Changes tab from a UI that mostly depended on the proxied OpenCode file APIs into a server-backed worktree-aware Git surface that can answer the questions the UI actually needs: staged vs unstaged state, section-aware diffs, stage/unstage/commit mutations, and bounded invalidation when files change.

Here is what each server area is doing.

1. packages/server/src/api-types.ts
Adds the shared request/response contracts for the new Git endpoints and events:

  • worktree git status entries
  • worktree git diff request/response
  • stage/unstage request payloads
  • commit request/response
  • workspace.filesChanged event payload

This is the typed contract between server and UI.

2. packages/server/src/server/routes/workspaces.ts
Adds the actual HTTP routes under the worktree namespace:

  • GET /git-status
  • GET /git-diff
  • POST /git-stage
  • POST /git-unstage
  • POST /git-commit

This file is mostly request validation, worktree resolution, and wiring those routes to the underlying git helpers.

3. packages/server/src/workspaces/git-status.ts
This is the core read-side git logic.

It does two main jobs:

  • Compute a worktree-scoped status model that separates staged and unstaged changes correctly.
  • Build diff payloads for either staged or unstaged view, including deletes, binaries, untracked files, and rename handling.

More concretely, it:

  • runs git commands like diff --name-status, diff --numstat, ls-files --others
  • merges those results into a richer status entry than the SDK gives
  • tracks originalPath for renames/copies so rename diffs can be reconstructed correctly
  • detects likely binary blobs/files and suppresses line-oriented diff behavior
  • reads from HEAD, the index, or the filesystem depending on whether the UI asked for staged or unstaged diff content

This file is the reason the UI can show staged vs unstaged sections and load the correct diff for each section.

4. packages/server/src/workspaces/git-mutations.ts
This is the write-side git logic.

It wraps git commands for:

  • staging paths
  • unstaging paths
  • committing staged changes

It also does safety/normalization work:

  • rejects empty paths
  • rejects absolute paths
  • rejects traversal like ../...
  • dedupes paths
  • handles unborn-repo unstage behavior
  • maps git failures into typed application errors the routes can return cleanly

So this file is the server authority for per-file stage, per-file unstage, and commit.

5. packages/server/src/workspaces/worktree-directory.ts
Resolves between CodeNomad worktree slugs and real directories.

It provides:

  • slug -> directory resolution for the new routes
  • directory -> slug resolution for invalidation events
  • a short-lived cache so repeated worktree lookups do not keep re-listing worktrees on every request/event

This is what keeps the new git endpoints worktree-scoped instead of accidentally operating on the wrong checkout.

6. packages/server/src/workspaces/instance-events.ts
Extends the existing instance SSE bridge so that when completed file-writing tools run, the server emits a targeted workspace.filesChanged event.

Specifically it watches completed tool events for:

  • write
  • edit
  • apply_patch

Then it resolves the affected directory back to a worktree slug and publishes a worktree-scoped invalidation event.

This is what lets the Git Changes tab refresh itself after agent-driven file mutations without adding a separate bespoke invalidation channel.

7. packages/server/src/events/bus.ts
Just wires the new workspace.filesChanged event type into the existing event bus subscription flow so clients can receive it.

8. packages/server/src/server/http-server.ts
This is only touched to thread the updated route/event setup through the server bootstrap path so the new route/event behavior is actually registered.

Why the server changes exist at all
The key reason is that the old proxied file APIs are not enough for this feature set.

The UI needs server help to answer questions like:

  • is this change staged, unstaged, or both?
  • what is the staged diff for this path vs the unstaged diff?
  • how should deletes/untracked/binary files be represented?
  • how do we stage/unstage/commit safely inside the correct worktree?
  • how do we know when to invalidate the current Git Changes view after agent file edits?

That is the real job of the packages/server changes in this PR: provide a narrow, worktree-aware Git backend for the Git Changes tab rather than forcing the UI to infer SCM semantics from weaker generic file APIs.

If useful, I can also break this down one level deeper into “read path”, “mutation path”, and “event invalidation path” as an execution flow.

--
Yours,
CodeNomadBot

@shantur
Copy link
Copy Markdown
Collaborator

shantur commented Apr 11, 2026

@CodeNomadBot

When is workspace.fileChanged event sent?
Any other events does server or plugin sends for UI from our codebase?
Be ruthless about the packages/server changes and git handling. Where can it fail, what can lead to issues, does it increase any dependency, does Git client need to be present, what happens if Git is absent? and forgotten edge cases?

@codenomadbot
Copy link
Copy Markdown
Contributor

codenomadbot bot commented Apr 11, 2026

What I understand you’re asking: explain exactly when workspace.filesChanged is emitted, what other server/plugin events the UI can receive from our codebase, and do a ruthless server-side review of the new Git handling: failure modes, dependencies, Git binary assumptions, and forgotten edge cases.

Short answer first:

  • The event name is workspace.filesChanged.
  • It is sent only from the server SSE bridge when an OpenCode instance SSE event reports a completed write, edit, or apply_patch tool call.
  • It is not a general filesystem watcher event.
  • The new Git functionality does add a runtime dependency on a git executable being available in PATH, and behavior without Git is only partially graceful right now.

1. When workspace.filesChanged is sent

Source:

  • packages/server/src/workspaces/instance-events.ts:207-239
  • Event type definition: packages/server/src/api-types.ts:369-374

It is emitted only when all of these are true:

  1. The server is consuming the OpenCode instance SSE stream for a workspace.
  2. The incoming SSE event type is message.part.updated.
  3. event.properties.part.type === "tool".
  4. event.properties.part.state.status === "completed".
  5. event.properties.part.tool is one of:
    • write
    • edit
    • apply_patch

Then the bridge publishes:

  • type: "workspace.filesChanged"
  • instanceId
  • directory if present on the raw instance event
  • worktreeSlug if the server can map that directory back to a known worktree
  • reason equal to the tool name

Important constraints:

  • If the SSE payload has no directory, the event is still published, but worktreeSlug may be missing.
  • The current Git Changes UI path ignores workspace.filesChanged when worktreeSlug is absent, so in practice those events may be non-actionable for that tab.
  • This is not sent for arbitrary file changes on disk.
  • This is not sent for bash commands that mutate files.
  • This is not sent for manual editor changes outside the tracked tool events.
  • This is not sent for git stage/unstage/commit actions themselves.
  • That is why polling still exists as a fallback.

So the right mental model is: tool-completion invalidation hint, not filesystem change stream.

2. Other UI-facing events from our codebase

Server SSE channel: /api/events

Defined in:

  • packages/server/src/api-types.ts:340-374
    Dispatched in:
  • packages/server/src/server/routes/events.ts:26-79

The UI can receive these top-level events from our backend:

  1. workspace.created
  2. workspace.started
  3. workspace.error
  4. workspace.stopped
  5. workspace.log
  6. sidecar.updated
  7. sidecar.removed
  8. storage.configChanged
  9. storage.stateChanged
  10. instance.dataChanged
  11. instance.event
  12. instance.eventStatus
  13. workspace.filesChanged

Where they come from in our code:

  • Workspace lifecycle: packages/server/src/workspaces/manager.ts
  • Workspace logs: packages/server/src/workspaces/runtime.ts
  • Sidecars: packages/server/src/sidecars/manager.ts
  • Settings/state: packages/server/src/settings/service.ts:117-127
  • Instance data storage: packages/server/src/server/routes/storage.ts
  • OpenCode instance SSE bridge: packages/server/src/workspaces/instance-events.ts
  • Background-process updates piggyback through instance.event: packages/server/src/background-processes/manager.ts:563-609

Important nuance:

  • instance.event is a mixed bag. It includes raw OpenCode instance events forwarded by the server, plus some server-originated synthetic events like:
    • background.process.updated
    • background.process.removed

Plugin SSE channel: /workspaces/:id/plugin/events

Defined in:

  • packages/server/src/server/routes/plugin.ts:30-59

From our code, the plugin channel currently sends:

  • codenomad.ping (packages/server/src/plugins/handlers.ts:28-35)
  • codenomad.voiceMode (packages/server/src/plugins/voice-mode.ts:84-92)

Inbound plugin event handling from our code is minimal right now:

  • codenomad.pong is recognized
  • anything else is logged as unhandled
  • packages/server/src/plugins/handlers.ts:17-25

So if you’re asking “what do we ourselves send for UI consumption?”, the main channels are:

  • /api/events for backend state and instance events
  • /workspaces/:id/plugin/events for plugin-facing SSE such as voice mode and ping

3. Ruthless review of the packages/server Git handling

High-confidence concerns

1. Git binary is now a real runtime dependency

The new Git paths call spawn("git", ...) in multiple places:

  • packages/server/src/workspaces/git-status.ts
  • packages/server/src/workspaces/git-mutations.ts
  • packages/server/src/workspaces/git-worktrees.ts

That means this feature set assumes:

  • git is installed
  • git is in PATH for the server process
  • the version supports the commands being used

If Git is absent:

  • worktree discovery degrades partially
  • git status/diff/mutation endpoints fail when called
  • users get raw-ish error messages from child-process failure paths

There is no explicit Git capability probe or clear “Git unavailable” product state in these new routes.

2. Non-git workspaces are only partially handled

resolveRepoRoot() returns { repoRoot: folder, isGitRepo: false } when the folder is not a git repo:

  • packages/server/src/workspaces/git-worktrees.ts:39-50

Then listWorktrees() falls back to returning only a synthetic root descriptor if git worktree list fails:

  • packages/server/src/workspaces/git-worktrees.ts:95-99

That is fine for generic worktree listing, but the new Git routes still try to operate afterward. In a non-git workspace, calling:

  • GET /git-status
  • GET /git-diff
  • POST /git-stage
  • POST /git-unstage
  • POST /git-commit

will fall through into git subprocess failures and come back as 400/409-style errors rather than a clean feature-unavailable state.

So the feature is not truly capability-gated. It mostly fails reactively.

3. workspace.filesChanged is narrower than it sounds

This is the biggest conceptual caveat.

Despite the name, it is not sent for “workspace files changed” in general. It is only sent for completed write / edit / apply_patch tool events coming through the OpenCode SSE bridge:

  • packages/server/src/workspaces/instance-events.ts:207-239

That means it misses:

  • file changes from bash
  • file changes from background processes
  • file changes from external editors
  • file changes from git checkout/rebase/merge/etc.
  • file changes made directly by plugin endpoints
  • file changes that do not surface with one of those exact tool names

The current design relies on polling to cover the rest. That is acceptable if deliberate, but it should not be mistaken for comprehensive invalidation.

4. Worktree directory matching is exact-string based

resolveWorktreeSlugForDirectory() matches:

  • cached worktree directory string
  • against the raw directory string carried by the SSE event
  • packages/server/src/workspaces/worktree-directory.ts:56-82

There is no normalization beyond .trim() there:

  • no realpath
  • no case normalization
  • no slash normalization
  • no parent-directory containment logic

So this can miss valid worktree matches when paths differ by representation, especially on Windows or nested-directory tool execution paths. Your PR notes already call this out as a follow-up; I agree it is real.

Medium-confidence edge/risk areas

5. git restore --staged requires relatively modern Git

Unstage uses:

  • git restore --staged -- ...paths when HEAD exists
  • packages/server/src/workspaces/git-mutations.ts:89-104

That command is not available in older Git versions. So there is a version dependency here beyond just “Git installed”.

If a system has an older Git:

  • status/diff may work
  • stage may work
  • unstage may fail unexpectedly

There is no version check or fallback to git reset HEAD -- <paths>.

6. Error classification is still pretty generic

Route error handling is broad:

  • packages/server/src/server/routes/workspaces.ts:293-304

Most non-GitMutationError failures become 400 with the raw error message.

Implications:

  • “git not installed” becomes a generic request failure, not a clearly classified capability error
  • “not a git repo” becomes a generic request failure
  • server-side execution/environment issues are mixed with bad client input

That is survivable, but not ideal for diagnosability or UX.

7. Rename handling is much better now, but still representation-dependent

The rename fixes are solidly better now.

What still remains true:

  • rename/copy interpretation depends on parsing the output format of git porcelain/numstat strings
  • packages/server/src/workspaces/git-status.ts:97-116, 185-212

That is reasonable, but it means this feature is coupled to exact git output shapes more than a libgit-backed design would be. It is acceptable for scope, but it is a dependency on CLI behavior.

8. No explicit debounce/rate limiting on tool-driven invalidation publishing

Every qualifying completed tool event can publish workspace.filesChanged immediately.

That is probably fine given the narrow tool list, but a burst of small write/edit/apply_patch completions can still trigger repeated refresh pressure on the UI. The current protection is mostly on the UI side via in-flight refresh queuing, not on the server side.

Lower-severity notes / forgotten edge cases

9. If directory is missing on the SSE event, invalidation becomes lossy

The bridge publishes workspace.filesChanged even if directory is absent, but then cannot reliably resolve worktreeSlug:

  • packages/server/src/workspaces/instance-events.ts:220-238

The Git Changes UI path currently wants the slug. So these events can exist but fail to cause the intended refresh.

10. Git commit path assumes repo-local config/user setup is valid

git commit -m ... will fail if author identity is not configured or other repo hooks/config constraints block commit:

  • packages/server/src/workspaces/git-mutations.ts:106-120

That is expected, but worth stating plainly: the server is not abstracting Git’s commit prerequisites; it is forwarding them.

11. Binary detection is heuristic

Binary detection is based on null-byte scan in sampled buffers:

  • packages/server/src/workspaces/git-status.ts:12-18

That is a practical heuristic, not a guaranteed binary classifier. False negatives/positives are possible for unusual file types.

Does this increase dependency surface?

Yes.

Compared with the earlier UI-only/proxied-file approach, the server now depends on:

  1. a working git CLI on the host
  2. Git command semantics for status/diff/mutation/worktree operations
  3. OpenCode SSE payload shapes for tool-event invalidation
  4. worktree-directory path matching staying compatible with reported tool directories

That is not inherently wrong, but it is a real expansion in operational dependency surface.

Bottom line

When workspace.filesChanged is sent

Only on completed OpenCode tool events for write, edit, or apply_patch, via the server’s instance SSE bridge.

Other UI-facing events from our codebase

Top-level server SSE events:

  • workspace.created
  • workspace.started
  • workspace.error
  • workspace.stopped
  • workspace.log
  • sidecar.updated
  • sidecar.removed
  • storage.configChanged
  • storage.stateChanged
  • instance.dataChanged
  • instance.event
  • instance.eventStatus
  • workspace.filesChanged

Plugin SSE events from our code:

  • codenomad.ping
  • codenomad.voiceMode

Ruthless verdict on server-side git handling

The implementation is reasonable and much stronger than the earlier version, but it is absolutely not “free”:

  • it requires Git to be present
  • it relies on modern Git for at least some paths
  • it does not fully capability-gate non-git / no-git environments
  • workspace.filesChanged is intentionally partial, not comprehensive
  • path/worktree matching still has normalization blind spots

If you want the most important practical risk to keep in mind: absence of Git, old Git, or path-shape mismatches will degrade this feature in ways that are currently handled more as generic request failures than as first-class product states.

--
Yours,
CodeNomadBot

@shantur
Copy link
Copy Markdown
Collaborator

shantur commented Apr 11, 2026

@VooDisss

event.properties.part.type === "tool".
event.properties.part.state.status === "completed".
event.properties.part.tool is one of:
write
edit
apply_patch

Depending on the hard coded tool calls might not be the best approach here. Any new tool call or a custom tool call editing the files will fire the event. Instead we should rely on the earlier event. If I'm not wrong it was session.updated. That brings in the changes.

  1. Missing gig binary needs to be handled better way. The new roads should return a proper clean error message rather than failing blindly.

  2. Work tree path matching should be improved. It's actually called out by the PR itself. We need to fix it.

  3. If the workspace is not Git repository, that case needs to be handled correctly.

  4. More to come

@shantur
Copy link
Copy Markdown
Collaborator

shantur commented Apr 11, 2026

@CodeNomadBot

From your message above

  1. Git commit path assumes repo-local config/user setup is valid

What happens in case use isn't setup? What happens to the UI / UX

  1. Binary detection is heuristic

Is the git binary run from the user environment as the node / opencode binary are run in packages/server?

@codenomadbot
Copy link
Copy Markdown
Contributor

codenomadbot bot commented Apr 11, 2026

What I understand you’re asking: answer two very specific follow-ups.

  1. If Git author/user identity is not configured, what actually happens in this implementation, and what does the user see in the UI?
  2. When we run git, is that binary resolved from the same environment that packages/server uses to run subprocesses like OpenCode/Node, or something else?

1. If Git user identity is not configured, what happens?

Server behavior

The commit path is very thin right now:

  • packages/server/src/workspaces/git-mutations.ts:106-120

It just does:

  • git commit -m <message>
  • if that fails, it throws a GitMutationError with Git’s stderr/error text

So if author identity is missing, Git itself is what fails, typically with a message along the lines of:

  • Author identity unknown
  • Please tell me who you are
  • missing user.name / user.email

The server does not intercept that case and turn it into a friendlier domain-specific error. It just returns the Git failure text.

Route handling:

  • packages/server/src/server/routes/workspaces.ts:264-297

That means the HTTP response becomes an error with that message body.

UI / UX behavior

The UI commit flow is here:

  • packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx:714-743

The request layer does this on any non-OK response:

  • reads the response text
  • throws new Error(message)
  • packages/ui/src/lib/api-client.ts:119-125

Then the Git Changes tab catches that and shows an error toast:

  • showToastNotification({ message: error.message, variant: "error" })
  • packages/ui/src/components/instance/shell/right-panel/RightPanel.tsx:736-740

So the actual UX today is:

  1. User clicks commit.
  2. Server runs git commit -m ....
  3. Git fails because identity is not configured.
  4. The request rejects with Git’s raw stderr text.
  5. UI shows an error toast with that raw message.
  6. The commit message stays in the textarea because it is only cleared on success.
  7. The staged changes remain staged.
  8. No success refresh path runs.

Practical verdict on UX quality

This is functional but not polished.

Good:

  • no silent failure
  • user keeps typed commit message
  • no destructive side effect

Weak:

  • the message is whatever Git prints, not a tailored app message
  • there is no inline guidance like “Configure user.name and user.email to enable commits”
  • there is no dedicated “Git not configured” UI state

So if user identity is not set up, the feature does not corrupt state, but the UX is basically: raw Git error in a toast.

2. Is git run from the same environment as server/OpenCode subprocesses?

Short answer: yes, effectively from the server process environment, because these git calls use spawn("git", ...) without an explicit env override.

Relevant code:

  • packages/server/src/workspaces/git-status.ts:50-73
  • packages/server/src/workspaces/git-mutations.ts:16-39
  • packages/server/src/workspaces/git-worktrees.ts:13-36

All of those call spawn("git", args, { cwd, stdio: ... }) and do not pass env.

In Node, that means the child process inherits the parent process environment by default, i.e. the environment of the running CodeNomad server process.

Comparison with OpenCode subprocess launching

OpenCode launch is here:

  • packages/server/src/workspaces/runtime.ts:145-205

That path explicitly builds:

  • const env = { ...process.env, ...(options.environment ?? {}) }
  • and passes that into spawn(...)

So:

  • OpenCode subprocesses inherit the server environment plus some explicit extra vars.
  • git subprocesses inherit the server environment as-is, with no extra override layer.

Important nuance

That means git is not necessarily resolved from the user’s interactive shell environment.
It is resolved from whatever environment the CodeNomad server process was started with.

So if a user has Git available in their terminal because of shell init files, but CodeNomad was launched from a GUI/session that does not carry the same PATH, spawn("git", ...) can still fail with ENOENT even though “git works in my shell”.

That is the operational risk.

On the binary-detection question

The heuristic binary detection I mentioned is not about locating the git executable.
It is about deciding whether file/blob contents should be treated as binary by scanning bytes for nulls:

  • packages/server/src/workspaces/git-status.ts:12-18
  • packages/server/src/workspaces/git-status.ts:20-25
  • packages/server/src/workspaces/git-status.ts:38-47

So there are two separate things:

  1. Git executable resolution: inherited from the server process environment.
  2. Binary file detection: heuristic inspection of content bytes after Git/file reads.

Bottom line

If Git user identity is not configured

  • commit fails on the server
  • UI shows an error toast with Git’s raw message
  • typed commit message remains
  • staged files remain staged
  • no data loss, but UX is rough

Where git comes from

  • git is spawned from the CodeNomad server process environment
  • not from a special custom env layer
  • not necessarily from the user’s interactive shell PATH
  • so GUI/server PATH mismatches can absolutely matter

If you want the ruthless summary: the failure mode is safe but crude, and the environment assumption is “whatever PATH/env the server inherited,” which is good enough for many setups but definitely not bulletproof UX.

--
Yours,
CodeNomadBot

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.

Improve Git Changes diff workflow and source-control ergonomics

2 participants