Skip to content

IDE-features batch: FILES/SEARCH/GIT tab row, git MCP tools (1.6.0) - #21

Merged
shivanshu-risa merged 8 commits into
mainfrom
feat/ide-features
Sep 3, 2026
Merged

shivanshu-risa merged 8 commits into
mainfrom
feat/ide-features

Conversation

@shivanshu-risa

@shivanshu-risa shivanshu-risa commented Sep 1, 2026 •

Copy link
Copy Markdown
Collaborator

Part of the IDE-features batch. Grows the codebase panel from a file tree into a FILES / SEARCH / GIT tab row, and is the release that supersedes the standalone git-status and git-log plugins.

What's in this PR

SEARCH tab - global content search + replace over the host's ContentSearchProvider:

  • project_search / project_replace MCP tools; new project.replace permission
  • include and exclude globs, both applied inside the engine so the result cap is honest
  • open-file buffer overlay so unsaved edits are searched too (host-side)

GIT tab - replaces the git-status + git-log panels:

  • changes accordion with stage / unstage / discard / checkout row actions
  • commit graph with branch model, commit-message prompt, and an agent-review prompt
  • checkout + "show diff" row actions (the git-log row actions land here, not in git-log)

MCP tools - 16, all on the boss server, parent-first, in three RBAC tiers:

Tier Tools
open (read-only) git_status, git_log, git_diff, git_diff_all, git_diff_ref, git_diff_between, project_search
git.write git_stage, git_unstage, git_stage_all, git_unstage_all, git_discard, git_checkout, git_cherry_pick, git_revert
project.replace project_replace

An empty requiredPermissions is not a neutral default: the host's MCP registry exposes such a tool to every local session, including one where nobody is signed in. Reads are open on that basis; anything that mutates git state or writes file contents sits behind a grant. Admins bypass both.

New permissions (both declared in plugin.json definedPermissions): project.replace and git.write.
Ops note: org admins must grant git.write to agent roles, or those eight tools are unreachable for non-admins.

Manifest:

  • version 1.6.0 (set in build.gradle.kts; processResources syncs it into plugin.json)
  • boss-plugin-api 1.0.72 -> 1.0.87
  • minBossVersion 9.2.20 -> 9.5.7

Release ordering (important)

BossConsole's RetiredPlugins floor for git-status and git-log is codebase >= 1.6.0, and a RetiredPluginIds filter hides those two from this host build on. So this 1.6.0 must ship in the same wave as (or before) the host release that carries the retirement floor. If it lands late the failure is silent: those two panels are hidden and nothing replaces them.

Review rounds

Three rounds of claude-review are addressed. Highlights from the last two:

  • Agent Review diff budget. The collector truncates to fit and then appends its own header and marker, so a truncated result lands slightly over the budget - and the prompt builder's length test then replaced the whole diff with a tool pointer, dropping it in exactly the case truncation exists to serve. Both sides are pinned separately, which is why neither test saw it; GitViewModelOperationsTest now pins the seam, and the case was mutation-checked.
  • Modal sheets were drawn modal but did not behave modally. No pointer modifier on the scrim, so clicks fell through to the rows underneath - with Discard and Push back there. They swallow every pointer event now, and take focus so Escape works.
  • Stale MCP reads. git_status / git_log sampled .value right after refresh*(); they await the post-refresh emission with a timeout now.
  • Storage effects. Three preferences ran in sequence in one LaunchedEffect, but a StateFlow collect never completes - so the splitter position was neither loaded nor saved.
  • Argument clamping (git_log limit, project_search maxResults), isSafeRef on the MCP ref path, JSON-array unescaping, Dispatchers.IO for blocking work, per-match LazyColumn virtualization, project-switch reset, dispose(), and the processResources version filter (which produced the right answer only by accident).

Tests: 178 -> 196, all green. README and AGENTS.md updated - the README documented only the FILES tree and the pre-1.6.0 minimums.

Not in this PR

  • The host-side diff/search provider implementation (BossConsole PR #289)
  • editor-tab's diff/composer tabs and buffer model (separate PR)

…1.0.87

- SEARCH tab: global content search + replace over the host's
  ContentSearchProvider (project_search / project_replace MCP tools,
  new project.replace permission)
- GIT tab: changes accordion (stage/unstage/discard/checkout row
  actions), commit graph with branch model, agent-review and
  commit-message prompts
- MCP tools: git_status, git_stage, git_unstage, git_stage_all,
  git_unstage_all, git_discard, git_checkout, git_diff, git_diff_all,
  git_diff_ref, git_diff_between, project_search, project_replace
- Version 1.6.0 (build.gradle.kts, synced into plugin.json by
  processResources): the release that supersedes git-status and
  git-log - the host's retirement floor is >= 1.6.0
- boss-plugin-api 1.0.72 -> 1.0.87 (IDE-features surface)
@claude

claude Bot commented Sep 1, 2026

Copy link
Copy Markdown

Review: IDE-features batch (FILES/SEARCH/GIT, 1.6.0)

Read the whole diff (28 files, ~7.1k lines). Well-organised work: the pure logic (GitBranchModel, GitChangeTree, GitGraphEdges, GitGraphLayout, the two prompt builders) is properly separated from the Compose layer and pinned by tests, null providers degrade to an empty state everywhere instead of crashing, destructive row actions all go through GitConfirmDialog, and the inputs.property("pluginVersion", version) fix in processResources is a genuinely good catch.

Findings roughly by severity.


1. startAgentReview still has the exact bug collectDiff was fixed for

GitTab.kt:592-607 uses diff.rawUnified and then if (sb.length + block.length > INLINE_DIFF_BUDGET) break.

collectDiff (line 554) was fixed for precisely this: it uses compactDiff because "the host generates diffs with whole-file context (-U100000), so rawUnified is the entire file", and it truncates the oversized block (slice) instead of breaking. startAgentReview still uses rawUnified and still breaks unconditionally, so when the first changed file exceeds 15,000 chars (a ~400-line source file), sb stays empty, diffText is null, and the prompt degrades to "The diff could not be fetched inline". That is the common case, not an edge case.

Reuse compactDiff plus the remaining/slice logic; the two loops are near-identical and could be one function parameterised by budget.

2. git_status and git_log MCP tools sample .value right after refresh*()

CodebaseGitMcpTools.kt:252-256 and :144-150 do provider.refreshStatus() then provider.fileStatus.value.

GitTab.kt:33-37 documents why that is wrong: "Status is collected, never snapshotted. The provider refreshes asynchronously (and over IPC when this plugin runs out-of-process), so reading fileStatus.value right after an operation returns the state from before it." The view model was fixed; the MCP tools carried the old pattern over. An agent calling git_status right after git_stage can get the pre-stage tree — worse than a UI glitch, because it feeds a wrong premise into whatever the agent does next.

If the provider contract does not guarantee the flow is updated before refreshStatus() returns, these should await the next emission with a timeout rather than sample.

3. Destructive git tools have no RBAC gate, but project_replace does

project_replace is correctly behind requiredPermissions = listOf("project.replace") with a definedPermissions entry (CodebaseGitMcpTools.kt:183-193). But git_discard (:67, "irreversible"), git_checkout (:77), git_revert, git_cherry_pick and git_stage_all are plain definitions with readOnly = false and no permission. An agent that cannot run a dry-run-defaulted text replace can silently git_discard your working tree.

The "carried over verbatim so the tool tiers stay stable" intent is clear from the header comment — but if the tiers gate these host-side, say so there; if they don't, git.write / git.discard permissions belong here.

4. "Replace all" after a capped search does not replace all

SearchTab.kt:246 sets _capped at MAX_RESULTS (500), and matchedFiles() (:361) derives the replace target set from _results alone. On a capped search, Replace All acts only on the files behind the first 500 matches. The summary row appends "(capped)", but ConfirmReplaceSheet (:915-928) says "Replace N occurrence(s) across M file(s)?" with no hint the set is truncated — the user confirms what looks complete and gets a partial write, with no error.

At minimum surface the capped state in the sheet; better, refuse replace-all (or re-scan uncapped for the file list) while capped.

5. pluginContext is retained with no dispose()

CodebaseDynamicPlugin.kt:48,51 newly holds the host PluginContext in a field for the plugin's lifetime, and the class has no dispose() override — so on disable/unload the plugin keeps a strong reference to the context and every provider hanging off it. AGENTS.md names dispose() as half the entry-point contract. Add one that nulls pluginContext and the cached providers.

6. IPC-crossing provider calls are mostly unguarded

refreshBranchOptions (GitTab.kt:300) wraps git.branches() in try/catch, but loadGraph (:266), op (:648) and the diffFile calls are not. A throwing provider (IPC drop, repo mid-rebase) leaves _message null and the exception unhandled in a SupervisorJob scope — the user sees a spinner stop and nothing else. report() already exists to surface failures.

Related: generateCommitMessage (:483) does val gateway = aiGateway() ?: return — a silent no-op if aiUnavailable() returned null but the gateway is still absent. That branch should set _message.


Performance

  • Three independent poll loops. Project path every 1s (CodebaseComponent.kt:155-161), git status every 5s (GitTab.kt:669), search re-scan every 2.5s (SearchTab.kt:396). Out-of-process that is a sustained ~1.4 IPC round-trips/sec per open panel, and the git one shells out git status. The 1s project poll watches a getter that changes maybe once an hour; 5-10s would be indistinguishable.
  • searchFileGroup defeats LazyColumn virtualization (SearchTab.kt:731-739): the file row and every match row are emitted inside one item {}. A file with 400 matches composes 400 rows whether or not they are on screen. Split into item (header) + items (matches); the expand state would need to hoist out of SearchFileGroupBody.
  • Storage I/O on the composition dispatcher. CodebaseComponent.kt:138-149 calls getString/putString directly in a LaunchedEffect, while the tab-switch path four lines later (:167-169) deliberately uses Dispatchers.Default. If storage blocks, the first stalls the UI thread; if it doesn't, the second is unnecessary. Make them consistent.

Correctness / polish

  • baseRef changes the prompt but not the payload. The prompt says "Review this branch's changes against main" (AgentReviewPrompt.kt:33), but startAgentReview only ever collects index + working-tree diffs — nothing computes git diff main. Either wire diffBetween(base, "HEAD") in, or make the copy honest about what is attached.
  • Broken sentence in the empty case (AgentReviewPrompt.kt:56-60): three appendLines render as There are no uncommitted changes in / path / . on three lines.
  • Files missing a trailing newline (AGENTS.md requires them): AgentReviewPrompt.kt, CodebaseGitMcpTools.kt, GitGraphLayout.kt, AgentReviewPromptTest.kt, GitGraphLayoutTest.kt.
  • Dead state (GitTabUi.kt:583-584): approachOpen and branchOpen are declared and never read; GitOptionDropdown owns its own open.
  • Version is a third source of truth. CodebaseDynamicPlugin.kt:30 hardcodes "1.6.0" alongside build.gradle.kts. It has already drifted once — the field said 1.0.9 while gradle said 1.5.8. Deriving it from the manifest would remove the class of bug.
  • Stale KDoc in GitTab.kt: :511 describes collectDiff but sits above compactDiff; :678-683 (the %D branch-name paragraph) is orphaned above describeMissingRepo's own KDoc.
  • GitDataProvider.discard (GitTab.kt:363) is a one-line alias for discardChanges used once — inline it.
  • project_replace's files splits on , (CodebaseGitMcpTools.kt:224-229), so a path containing a comma cannot be addressed. A JSON array in the schema sidesteps it.
  • git_diff_all is uncapped while every other diff tool goes through capDiff.
  • Leftover run { } in CodebaseContent.kt around the new EXPLORER header.

Tests

Good coverage of the pure layer — GitGraphBranchViewModelTest's legacy-vs-modern host fakes are the right shape for the additive-defaults contract, and it disposes its view models.

Two gaps:

  • No tests for CodebaseGitMcpToolProvider — 16 new tools, and the interesting parts are pure: capDiff truncation, diffTexts multi-file rendering (a regression the header comment says has already bitten once), statusChar totality, files parsing, missing-arg handling. The fake GitDataProvider in GitGraphBranchViewModelTest could be lifted into TestProviders.kt.
  • SearchPathTest.vm() constructs CodebaseSearchViewModel (which spins up a CoroutineScope in its constructor) without ever calling dispose() — 8 leaked scopes per run. The @AfterTest pattern from GitGraphBranchViewModelTest fixes it.

Conventions

Compose Multiplatform only, no Android APIs; java.awt.Cursor and TooltipArea are the right Desktop choices. Null-provider handling is consistently graceful across both new tabs and all MCP handlers. Version bumped only in build.gradle.kts as required (plugin.json's literal 1.0.10 is dead text that processResources overwrites — correct, if confusing to read).

Merge gate

The test check is currently failing, which matches your note that it stays red until boss-plugin-api 1.0.87 is published. Stating it explicitly: none of these 7.1k lines has been compiled or exercised by CI yet, so everything above is a static read. Please don't merge until that check actually passes.

Items 1-4 are the ones I'd want fixed before this ships.

Before-merge items:
- startAgentReview now reuses collectDiff (compactDiff + slice) instead of
  rawUnified with a break loop: the host diffs with -U100000, so rawUnified
  is the whole file and the first large file emptied the prompt
- git_status / git_log sample the provider BEFORE the refresh returns when
  out-of-process; awaitFresh waits for the post-refresh emission (2s cap,
  falls back to the latest value on a conflated no-op refresh)
- destructive git tools now sit behind a git.write permission (declared in
  plugin.json): empty requiredPermissions is open to every local session,
  including unsigned-in, so git_discard (irreversible) could not stay bare.
  Read-only tools stay open; project_replace keeps project.replace
- Replace All over a capped search: the confirm sheet now states the
  truncation and the Replace button stays disabled while capped, instead of
  silently covering only the listed files

Item 5/6 and polish:
- CodebaseDynamicPlugin gains dispose() (nulls context + all providers)
- unguarded provider calls (loadGraph, op, status timer, collectDiff
  diffFile, generateCommitMessage gateway) surface failures via _message
  instead of dying in a SupervisorJob scope with the spinner just stopping
- collectDiff no longer dies on one file that fails to diff
- version now read from the bundled manifest (processResources-synced from
  build.gradle.kts) instead of a third hand-maintained copy
- project_replace files arg accepts a JSON array (paths may legally contain
  commas) with a strict scanner and flat-form fallback; schema documents it
- storage reads/writes off the composition dispatcher; project-path poll
  1s -> 5s; orphaned/incorrect KDoc relocated; one-line discard alias
  inlined; stray run{} removed

Tests:
- new CodebaseGitMcpToolsTest: RBAC tier per tool, missing-arg errors,
  blank files list, oversized diff truncation, multi-file diff header,
  files-arg spellings, statusChar totality
- SearchPathTest now disposes its view models (was leaking a scope per test)
@shivanshu-risa

Copy link
Copy Markdown
Collaborator Author

All 6 findings addressed in 7e4080c:

  1. startAgentReview rawUnified bug - now reuses collectDiff (compactDiff + slice); a large first file no longer empties the prompt. The per-file diffFile calls are guarded so one failing file is skipped instead of killing the collection, and the launch has a catch that surfaces provider failures in the message row.
  2. git_status / git_log stale sampling - both now use awaitFresh: snapshot before, refresh, await the post-refresh emission (2s cap, falls back to the latest value on a conflated no-op refresh).
  3. RBAC on destructive git tools - the host does NOT tier-gate them: empty requiredPermissions is open to every local session, including unsigned-in (the api KDoc says so explicitly). All 8 state-mutating tools (stage, unstage, stage_all, unstage_all, discard, checkout, cherry_pick, revert) now require a git.write permission, declared in plugin.json definedPermissions. Read-only tools stay open; project_replace keeps project.replace. The header comment now documents the three tiers. Ops note: org admins must grant git.write to agent roles; admins bypass.
  4. Replace All over a capped search - the confirm sheet now states the truncation ("search was capped at 500 matches ... only the listed files") and the Replace button stays disabled while capped, so a partial write can no longer be confirmed silently.
  5. pluginContext retained with no dispose() - added; nulls the context and all 11 retained refs.
  6. Unguarded IPC-crossing provider calls - loadGraph, op, the 5s status timer, and generateCommitMessage's gateway-null branch all surface failures via _message now, instead of dying in a SupervisorJob scope with the spinner just stopping.

Polish from the same round: git_diff_all now goes through capDiff; the project_replace files arg accepts a JSON array (paths may legally contain commas) and the schema documents it; dead approachOpen/branchOpen state removed; stray run{} dropped from the EXPLORER header; orphaned/stale KDoc relocated; one-line discard alias inlined; missing trailing newlines fixed; version now read from the bundled manifest instead of a third hand-maintained copy; project-path poll 1s to 5s and storage I/O off the composition dispatcher.

Deferred to a follow-up (noted here rather than hidden): per-match-row LazyColumn virtualization in search results, and tightening the 5s git / 2.5s search poll intervals further.

Tests: new CodebaseGitMcpToolsTest pins the RBAC tier per tool, missing-arg errors, files-arg spellings, and diff truncation; full suite green locally against api 1.0.87.

@claude

claude Bot commented Sep 1, 2026

Copy link
Copy Markdown

Review — FILES/SEARCH/GIT tab row + git MCP tools (1.6.0) — part 1 of 2

Big, well-organised PR, and the repo conventions are mostly respected: no Android-only APIs (the one AWT touch, PointerIcon(Cursor(...)) at CodebaseUi.kt:616, is Compose Desktop); every provider is nullable with a real fallback UI (CodebaseGitContent:115, CodebaseSearchContent:429, unavailableGit()); the version moved only in build.gradle.kts; and the new pure logic (GitBranchModel, GitChangeTree, GitGraphEdges, GitGraphLayout) is genuinely well tested. Adding inputs.property("pluginVersion", version) and tiering RBAC on the git tools are both good calls.

I could not compile or run Gradle in this sandbox (no boss-plugin-api jar available), so everything below is from reading — please sanity-check items 1 and 2 locally.


1. processResources writes a literal dollar-version into the manifest, and 1.6.0 now reads that back at runtime

build.gradle.kts:125-129. The replacement argument is a Kotlin raw string, so the backslash-dollar sequence is a literal backslash followed by the text version — raw strings do no interpolation. That string then goes to Regex.replace, where backslash-dollar is the escape for a literal dollar. So the processed manifest should end up containing "version": "$version" verbatim.

This was invisible while the manifest version was not load-bearing. It is now: CodebaseDynamicPlugin.kt:186-200 reads the field back via readManifestVersion() and exposes it as override val version, so a broken filter surfaces in the Toolbox and in whatever the store reads. Please confirm with ./gradlew processResources and inspect build/resources/main/META-INF/boss-plugin/plugin.json. If it reproduces: capture version.toString() into a local and use the lambda overload of replace, so nothing in the replacement is reinterpreted.

2. Agent Review drops the diff exactly when there is a diff worth reading

GitTab.kt:578-604 plus AgentReviewPrompt.kt:80. collectDiff is called with budget = INLINE_DIFF_BUDGET and deliberately fills it: for the first file it takes block.take(remaining), then appends a truncation marker plus a --- path header line, so the builder ends up at or just over the budget. build() then hits else if (diffText.length >= INLINE_DIFF_BUDGET) and discards the whole collected diff.

Concretely: one changed file whose compact diff exceeds 15000 chars, and the agent gets the file listing plus a "use git_diff" note and never sees the diff. Multi-file sets fall in the same hole once the per-file --- path headers push the total past the budget — the sb.length + block.length > budget check does not account for them. Same class of failure the comment at GitTab.kt:591-596 says was fixed. Tests pass either side of the seam (AgentReviewPromptTest pins build alone, GitTabCompactDiffTest pins compactDiff), which is why it is not caught. Fix: collect against a slightly smaller budget, or have build include whatever it was handed and merely append the "there is more, use the tools" note. A test running collectDiff into build would pin it.

3. Both confirmation sheets are drawn modal but do not behave modally

GitTabUi.kt:1443-1461 (GitConfirmDialog) and SearchTab.kt:896-913 (ConfirmReplaceSheet). The scrim is a full-size Box carrying only a background modifier — no pointer input. Compose hit-tests against pointer modifiers, not draw order, so clicks pass straight through the scrim into the panel underneath: while "Discard changes to X?" is on screen the user can still hit Commit, another row Discard, Push, or a tab. For a sheet whose whole purpose is gating irreversible actions that is the wrong default; add a swallow (clickable with a remembered interaction source, indication = null, empty body).

Related, same block: the onPreviewKeyEvent at GitTabUi.kt:1452 sits on a non-focusable Box and nothing requests focus, so Escape almost certainly never reaches it — the event goes to whatever still holds focus, typically the commit-message field. Needs a FocusRequester, .focusable(), and a LaunchedEffect that requests focus.

4. SEARCH results lose virtualization and forget their collapse state

SearchTab.kt:725-818. searchFileGroup emits one lazy item per file, and SearchFileGroupBody renders the file row plus every match row inside a plain Column — a file with 300 hits is 300 composables in a single item. That is the opposite of the flattening changeGroup (GitTabUi.kt:833-893) does correctly, and of the fix issue 8 landed for the file tree. The same root cause bites the state: expanded is a remember inside a lazy item, so scrolling a group off screen disposes it and it returns at expandedInitially — collapsing one file group does not survive a scroll. Hoisting expansion into a Set of paths (as GIT already does with collapsedDirs) fixes both: one item for the file row plus items(entry.matches) for the hits.

5. busy is not a mutex, so the enabled = !busy guards are racy

GitTab.kt:667 (op), :435 (commit), :504 (generateCommitMessage), SearchTab.kt:298 and :321. The flag is set inside scope.launch, so two clicks dispatched before the coroutine body starts both pass the guard. Usually that is a duplicate git status; on discard, commit, push and applyReplacement it is a duplicate mutation. generateCommitMessage has the same shape despite its early return, because the flag is set inside the launch rather than at the guard. Set it synchronously before launch, or guard with compareAndSet(false, true).

6. readManifestVersion may read another plugin manifest

CodebaseDynamicPlugin.kt:188-190 resolves META-INF/boss-plugin/plugin.json — a path not namespaced per plugin — through classLoader.getResourceAsStream. With parent-first delegation (which this PR notes is how the MCP registry is reached) or a shared plugin classloader in the in-process fallback mode the manifest declares, the first match on the classpath wins and may belong to a different plugin. Safer: walk getResources(...) and accept the first whose pluginId matches this one, else FALLBACK_VERSION.

7. Switching project leaves the GIT tab on the previous repository

CodebaseComponent.kt polls getProjectPath() for the header, but nothing reloads the git view model: _graph, _branchOptions, _currentBranch and graphRef keep the old data until the user hits Refresh. fileStatus recovers on its own via the 5 s poll, which makes the mismatch worse — fresh changes over a stale graph. A LaunchedEffect keyed on the polled project path, calling loadGraph(reset = true) and clearing graphRef, would cover it.

@claude

claude Bot commented Sep 1, 2026

Copy link
Copy Markdown

Review — part 2 of 2: remaining findings, nits, security

8. Load more appears on repositories that have nothing more to load

GitTab.kt:369 — hasMoreGraph() is _graph.value.size < GRAPH_MAX. A 12-commit repo shows the action, and clicking it refetches the same 12. Track "the last load returned fewer rows than requested" instead.

9. git_status emits a three-column status field for untracked and ignored files

CodebaseGitMcpTools.kt:368-379 returns two-char values for UNTRACKED and IGNORED from a function whose other arms return one char, and :362-364 concatenates two of them — so an untracked file renders with a leading space rather than porcelain ?? path, while the tool description promises XY path. Agents that slice fixed columns will mis-parse. The assertion at CodebaseGitMcpToolsTest.kt:175 uses contains, which passes on the misaligned string; worth tightening to line equality once fixed. Simplest fix: return single chars and special-case untracked/ignored to render the whole XY cell.

10. Blocking I/O on Dispatchers.Default

CodebaseComponent.kt (two getString and two putString calls) comments that the reads are blocking I/O, then dispatches them to Default — whose parallelism is bounded by core count and which is also carrying Compose work. GitTab.kt:714-735 (describeMissingRepo: listFiles plus up to 200 dot-git existence probes) runs on the same dispatcher, on every explicit refresh, even when the project is a repo. Use Dispatchers.IO for all of these, and gate describeMissingRepo on the repository flag being false.

11. Cumulative polling

SEARCH re-runs the full project scan every 2.5 s (REFRESH_MS) while the tab is open, GIT re-runs git status every 5 s, and the panel re-samples the project path every 5 s. On a large monorepo the search timer alone is a directory walk plus a stat per file, twice a minute, indefinitely. Separately, the git view model init fires refreshStatus() and loadGraph(reset = true) (50 commits, git log with graph) on every panel open even if the user never leaves FILES — intentional per the comment, but it is a git log per panel. If the host offers any change-notification hook, event-driven invalidation with the timer as a long fallback (15-30 s) would be much cheaper.

12. The MCP provider captures providers at register time

CodebaseDynamicPlugin.kt:143-149 passes the values of gitDataProvider and searchProvider into CodebaseGitMcpToolProvider, while the AI gateway immediately above is deliberately resolved per call, with the comment that plugin load order is not guaranteed so a null now may be a gateway that has simply not registered yet. If that reasoning holds for the gateway it holds here: a host that registers GitDataProvider after this plugin leaves all 13 git tools permanently answering "Git is unavailable". A supplier lambda would make the two consistent.


Minor

  • GitTabUi.kt:507-517 — GitToolbar takes settingsOpen, onToggleSettings and onReview and uses none of them, while the call site at :172-185 still wires all three. Kotlin does not warn on unused parameters, so these will rot.
  • CodebaseGitMcpTools.kt:302-331 — parseJsonStringArray tracks escapes but never decodes them, so an escaped quote or a Windows doubled backslash survives into the path handed to replaceInProject. Append the unescaped char in the escaped arm, and drop the backslash in the backslash arm.
  • GitTabUi.kt:1147 — viewModel.checkout(node.shortHash); node.hash is right there and is unambiguous.
  • src/test/.../CodebaseGitMcpToolsTest.kt has no trailing newline; AGENTS.md requires one on every Kotlin file.
  • collapseHome in CodebaseComponent.kt and splitPathForDisplay at CodebaseUi.kt:673 split on forward slash only. The repo already has a PathUtils that handles both separators (PanelUiLogicTest: "name returns last segment for both separators") — reuse it rather than growing a second convention.
  • CodebaseGitMcpTools.kt:169-178 — the git_log limit is unbounded and its output does not pass through capDiff; a limit of 100000 is one tool call away from a very large result. Clamp it.
  • Dead or unreachable: CodebaseUi.kt imports MaterialTheme, BoxScope, fillMaxSize and fillMaxHeight and uses none of them; CodebaseSearchViewModel.cancelSearch() has no caller; formatCommitDate is referenced only from tests; GitGraphLayout.ancestorCache is keyed per commit and each key is written exactly once, so it never hits; the prefixLabel parameter of GitChangeTree.emit is always null.
  • GitGraphEdges.kt:59-61 — the comment says a parent outside the window makes the line leave the bottom of the last row, but the code skips it and draws nothing. Fix one or the other.
  • CodebaseDynamicPlugin.kt:63 — PanelId("atlas", 16): the 16 deserves a named constant or a note on where it comes from.
  • The PR description lists only project.replace as a new permission, but the manifest also defines git.write. Worth syncing so the release notes match what the host will prompt for.

Security notes (flagging shape, not asking for changes)

  • The three-tier RBAC split reads right, and gating irreversible git_discard behind git.write rather than leaving requiredPermissions empty is the correct instinct; the comment at CodebaseGitMcpTools.kt:19-29 explains why well.
  • project_search is ungated and returns file contents for any project-relative glob, so an agent session with no grants can read a dotenv file, keys, or credentials that happen to live in the tree. That may well be the intended tier (it mirrors plain file reads), but it is worth confirming the host engine skip rules cover dotfiles and ignored paths, since exclusion is now entirely host-side (SearchTab.kt:225-240).
  • generateCommitMessage ships staged diff content to whatever provider the AI Gateway is configured with. Click-gated and host-governed, so fine — just noting the egress path now exists.
  • GitBranchModel.isSafeRef rejects blanks, a leading dash, whitespace and control characters, which covers the argument-injection case; good that the plugin refuses locally and leans on the host boundary.

Happy to re-review once the version filter and the Agent Review budget seam are settled.

@claude

claude Bot commented Sep 1, 2026

Copy link
Copy Markdown

Review: IDE-features batch (FILES/SEARCH/GIT, git MCP tools, 1.6.0)

Read all the new Kotlin (GitTab, GitTabUi, SearchTab, CodebaseGitMcpTools, GitBranchModel, GitChangeTree, GitGraph*, AgentReviewPrompt, CommitMessagePrompt, CodebaseComponent) plus the manifest/build changes. Pulling the pure logic (lane assignment, edge building, ref-pill parsing, tree flattening, prompt building) out of the composables is the right shape and it pays off in the tests — 13 new/updated test files with real cases, not smoke tests. The comments explaining why ("status is collected, never snapshotted"; "compactDiff not rawUnified because the host diffs with -U100000") are unusually good.

Conventions: all clean. Compose Multiplatform only (the lone AWT reference, Cursor(N_RESIZE_CURSOR) in CodebaseSplitter, is Compose Desktop — correct here). Version bumped only in build.gradle.kts, plugin.json's version left for processResources. Every .kt ends with a newline. Null providers degrade to hints, never crash.

Findings roughly by priority. (1) and (2) are the ones I'd want before merge; (4)-(6) are cheap.

1. Neither in-panel modal blocks input to the content behind it

GitConfirmDialog (GitTabUi.kt:1448) and ConfirmReplaceSheet (SearchTab.kt:908) are each a Box with .fillMaxSize().background(CodebaseScrim) and no pointer-input modifier. background() paints but doesn't make the node a hit-test target, so pointer events fall through to the list underneath.

Open "Discard changes" on a file, then without dismissing click another row's discard or stage button — it lands. You can also stack a second GitConfirmation over the first (confirm is a single var, so the pending one is silently replaced and its action lost). Same for the replace sheet: toggles and result rows stay live behind it, and flipping a toggle calls setSearchOption → _dryRun.value = null, which yanks the sheet away mid-decision.

Drawing these in-panel because the host's browser surface renders above plugin dialogs is sound reasoning, but then the scrim has to swallow input itself:

Modifier.fillMaxSize()
    .background(CodebaseScrim)
    .pointerInput(Unit) { detectTapGestures { /* swallow, or onDismiss() */ } }

Same two composables: GitConfirmDialog's onPreviewKeyEvent (:1452) sits on a node that is never focusable and never requests focus, so Escape-to-dismiss almost certainly never fires. Needs focusRequester(fr).focusable() + LaunchedEffect { fr.requestFocus() }.

2. refreshStatus() is the one provider call in CodebaseGitViewModel with no catch

GitTab.kt:214 has try { git?.refreshStatus(); _noRepoHint.value = ... } finally { _loaded.value = true }. Meanwhile op(), loadGraph(), startStatusTimer(), refreshBranchOptions(), collectDiff() and startAgentReview() all guard against a throwing provider and say why in a comment ("IPC drop, repo mid-rebase"). Here the exception escapes the launch to the thread's uncaught handler and _noRepoHint is left stale. It's on both the panel-open path (init) and the tab-open path (DisposableEffect, GitTabUi.kt:156) — the most likely one to hit a flaky IPC channel. A catch setting _message would match everything around it.

3. describeMissingRepo does ~200 blocking stats on every refresh, including when there is a repo

GitTab.kt:218 calls it unconditionally; GitTab.kt:714 does root.listFiles() plus a File(it, ".git").exists() for up to CHILD_SCAN_LIMIT = 200 children. Two things: gate it on !isGitRepository.value so the normal case doesn't pay for a hint that never renders, and wrap the scan in withContext(Dispatchers.IO) — the scope is SupervisorJob() + Dispatchers.Default, so this is blocking filesystem I/O on a CPU worker.

4. git_log with a negative limit throws instead of returning an error result

CodebaseGitMcpTools.kt:173 — args.int("limit") ?: 30, then .take(limit). List.take(-1) throws IllegalArgumentException. Every other bad-argument case here returns a clean McpToolResult(isError = true), and the test "missing required arguments are clean errors, not exceptions" states that contract, so a model guessing limit: -1 shouldn't be the one path that escapes. .coerceIn(1, 500) fixes it and bounds the top end too. project_search's maxResults (:237) is likewise unbounded upward: 1_000_000 makes the host scan everything to return at most MAX_RESULT_LINES = 100 lines.

5. git_status untracked/ignored rows break the XY alignment the tool advertises

The tool description promises "git-porcelain-style format (XY path)", but statusChar (:368) returns two characters for UNTRACKED ("??") and IGNORED ("!!"), concatenated after the index cell. An untracked file emits a 3-column status (space + ??) where porcelain emits ??. Worth fixing since an agent may column-slice this. Returning "?" / "!" per cell is the fix — git emits ??/!! as index+worktree, not as one worktree token.

Note CodebaseGitMcpToolsTest.kt:175 asserts with result.text.contains("?? u.kt"), which passes on the leading-space output too — so the test doesn't currently pin the format it looks like it pins. Asserting whole lines would catch this.

6. isSafeRef guards the graph picker but not the MCP ref tools

GitBranchModel.isSafeRef (GitBranchModel.kt:70) rejects blank refs, refs starting with -, over-long refs and control characters, and selectGraphBranch applies it with a good comment about the plugin-API boundary. But git_checkout, git_diff_ref and git_diff_between (CodebaseGitMcpTools.kt:102, 145, 157) pass agent-supplied refs straight through — and those are reachable by a less-trusted caller, unlike a name the graph picker got from git itself. Something shaped like --upload-pack=... is exactly what the startsWith("-") rule exists for. The host is the real boundary, agreed, but the helper already exists and is already applied to the safer path; using it here too is a couple of lines and makes the posture consistent.

7. Agent Review's caption promises a diff it doesn't send

GitTabUi.kt:713 renders "Review diffs vs. $base." and the tooltip says "Runs the agent over this branch's diff". AgentReviewPrompt.build's own KDoc says otherwise — "the attached diff is the uncommitted change, never a diff against this ref" — and startAgentReview (GitTab.kt:625) confirms: the payload is collectDiff(staged + unstaged, ...). Pick main as the base and you'd reasonably expect the whole branch reviewed; you get only what's uncommitted. Either reword ("Review uncommitted changes, targeting $base") or wire the base through to diffBetween.

Nearby: the "Diff against…" options come from _branches (GitTab.kt:135), parsed from the graph's %D decorations, while the graph's own picker uses the real git.branches() list via _branchOptions — now that refreshBranchOptions() exists the review picker could use the authoritative source rather than the strictly-smaller fallback. And branchesOf (:747) hardcodes origin/ for remote folding even though _remoteNames is right there, so upstream/x won't fold.

@claude

claude Bot commented Sep 1, 2026

Copy link
Copy Markdown

(review continued)

8. Performance: three independent polls, and one un-lazy list

None are blockers, but they compound on a large repo:

  • CodebaseSearchViewModel.startAutoRefresh re-runs the whole project search every 2.5 s (SearchTab.kt:196) whenever SEARCH is open with a non-blank query. The comment is right that the provider caches per file against mtime, but the directory walk plus a stat per file isn't free, and this is the shortest of the three intervals. Since the point is mainly to keep results honest after a replace — which applyReplacement already handles explicitly at :342 — a much longer safety tick would cost far less.
  • startStatusTimer at 5 s (GitTab.kt:231) and the project-path sampler at 5 s (CodebaseComponent.kt:167) both run for as long as the panel is open.
  • searchFileGroup (SearchTab.kt:732) emits an entire file group — header and every match row — as a single LazyColumn item. Lazy skipping therefore works per file, not per match: with MAX_RESULTS = 500 landing in one or two files, ~500 rows compose and measure at once. Splitting into item { header } + items(entry.matches) { … } restores it; the per-group expanded state would need hoisting into the caller, where collapseGeneration already lives.
  • Minor: changeGroup (GitTabUi.kt:878) calls GitChangeTree.filesUnder(files, row.path) — an O(n) filter — inside the directoryActions composable lambda, so it re-runs on every recomposition of every directory row instead of being remembered.

9. hasMoreGraph() keeps offering "Load more" after history runs out

GitTab.kt:369 is _graph.value.size < GRAPH_MAX, so a 60-commit repo shows "Load more" forever and each click re-fetches the same 60 with no visible change. Comparing the returned count against the requested limit in loadGraph and latching an _exhausted flag would fix it.

Smaller notes

  • GitToolbar has three unused parameters. onReview, settingsOpen, onToggleSettings (GitTabUi.kt:511-514) aren't referenced in the body — review moved to GitAgentReview — but the call site still passes all three (:176-182). Same for GitChangeTree.emit's prefixLabel (GitChangeTree.kt:99), null at both call sites.
  • checkout uses shortHash where revert/cherryPick use hash. GitTabUi.kt:1147 vs :1118/:1132. Abbreviated hashes can collide in a big repo and there's no reason for the difference.
  • parseJsonStringArray doesn't decode escapes, and [] becomes a bogus path. CodebaseGitMcpTools.kt:302 appends the backslash and the escaped character, so an element with an escaped quote keeps the backslash in the path. And parseFiles("[]") → scanner returns null (empty element) → flat-split fallback → listOf("[]"), which sails past the isEmpty() guard at :256. An explicit empty-array check returning emptyList() would let the existing "must not be empty" error fire.
  • GraphRowCanvas silently collapses lanes past 6. cx() coerces to drawnLanes - 1 (GitTabUi.kt:1578), so on a repo with more than GRAPH_MAX_LANES active lanes everything from lane 5 up draws on top of itself with no indication. Worth a comment at least, or a "+N" marker.
  • op() isn't re-entrancy-guarded. _busy is set inside the launched coroutine, so two fast clicks can both pass the check and interleave two index writes — the exact failure batch() was introduced to avoid for the multi-file case. Most buttons are enabled = !busy, but the ones that aren't (the Icons.Outlined.Difference rows, the graph's onRefresh) plus the raced window make it reachable.
  • PR description lists 13 MCP tools; the provider registers 16. git_log, git_cherry_pick and git_revert are missing from the list — the plugin's own KDoc (CodebaseDynamicPlugin.kt:25) and plugin.json's git.write description both correctly say 16 / mention cherry-pick and revert.
  • build.gradle.kts:127, pre-existing rather than introduced here, but while you're in the file: the replacement is a raw string containing \$version. Kotlin raw strings don't process \ as an escape, so this interpolates to "version": "\1.6.0" and only produces the right output because Java's Matcher then treats \1 as a literal 1. It works for every semver — by accident. Dropping the backslash is clearer. The inputs.property("pluginVersion", version) addition is a good catch, incidentally; that stale-UP-TO-DATE failure is a nasty one.
  • storage?.getString/putString run on Dispatchers.Default (CodebaseComponent.kt:143, 150, 155, 178). The comment says these are blocking I/O, which argues for Dispatchers.IO.

Test coverage

Strong on the pure layer. Gaps, roughly by priority: git_log's limit handling (finding 4); awaitFresh's timeout-and-fall-back path; parseJsonStringArray's escape and empty-array cases; CodebaseGitViewModel.batch's first-error-wins rule, a nice invariant that's currently unpinned; and describeMissingRepo's three message branches. Plus tightening CodebaseGitMcpToolsTest:175 to whole-line assertions per finding 5.

Release ordering

Noted that CI stays red until boss-plugin-api 1.0.87 publishes, and that this has to ship in the same wave as (or before) the host carrying the RetiredPlugins floor at codebase >= 1.6.0. Worth keeping on the merge checklist — the failure mode (users on the new host with git-status/git-log hidden but no 1.6.0 codebase) is a silent loss of functionality rather than a visible error.

Nothing here is architectural. Nice work on the whole.

… on the reloaded graph in the return-to-HEAD test
@claude

claude Bot commented Sep 1, 2026

Copy link
Copy Markdown

Review: IDE-features batch — FILES/SEARCH/GIT tab row (1.6.0)

Read through the whole diff (29 files, ~7.6k lines). This is a big, well-organized change, and the "why" comments on the non-obvious decisions (status collected rather than snapshotted, logGraphFor over logGraph, in-panel sheets instead of AlertDialog, the explicit ref-pill height) are genuinely useful — they document the failure they prevent rather than the code. Provider nullability is handled everywhere I checked (providerOrNull == null → hint, git == null → "Git is unavailable on this host", MCP unavailableGit()/unavailable()), the version moved only in build.gradle.kts, every file in the merged tree ends with a newline, and the pure logic (branch model, change tree, lane graph, edges, prompts, MCP arg parsing) is covered by ~100 unit tests. The test check is green.

Below are the things I would want addressed, roughly in priority order.

Correctness

1. The Agent Review diff is dropped exactly when collectDiff truncated it to fit. GitTab.kt:582-608 truncates a block to budget and then appends a --- path header plus a \n… [truncated]\n marker, so for a single oversized file the returned string is budget + ~26 chars. AgentReviewPrompt.kt:80 then tests diffText.length >= INLINE_DIFF_BUDGET and replaces the whole diff with "use the git_diff tools". Net effect: any change whose compact diff exceeds 15k chars sends the agent a file listing and no diff at all — precisely the case the truncation logic exists to serve. Either reserve header/marker room in collectDiff (budget - header.length - MARKER.length), or have build() inline whatever it was handed and only note that it was truncated.

2. The modal sheets do not block the content behind them. GitConfirmDialog (GitTabUi.kt:1448) and ConfirmReplaceSheet (SearchTab.kt:908) are Box(fillMaxSize + background(scrim)) with no pointer-input modifier, so Compose never consumes the clicks — the rows underneath stay live while the confirmation is up. With Discard and Push behind these, a user can click "Discard changes" on another row (or Commit) with the confirmation still on screen. A swallowing pointerInput / clickable(indication = null) on the scrim fixes it. Relatedly, onPreviewKeyEvent at GitTabUi.kt:1452 sits on a Box that never takes focus, so Escape-to-dismiss cannot fire — it needs focusRequester + focusable() and a LaunchedEffect { requestFocus() }.

3. Search auto-refresh can resurrect cleared results. startAutoRefresh calls execute() directly (SearchTab.kt:200) instead of going through searchJob, so clear() / cancelSearch() (SearchTab.kt:346, :257) cancel nothing. Press Escape while a refresh is in flight and the previous query's results — plus _searched = true — land back in the UI a moment later. Track the refresh in searchJob too, or have execute() re-check _query.value before publishing.

4. A project switch leaves GIT and SEARCH describing the previous project. CodebaseComponent.kt:165-171 polls getProjectPath() and updates only the header's local state. Nothing asks CodebaseGitViewModel to loadGraph(reset = true) or to clear _currentBranch / branchOptions, and nothing drops the search results — so after switching projects the commit graph, branch chip and result tree are stale until someone hits refresh. That same poll is already there; it could reset both view models.

5. parseJsonStringArray does not unescape what its doc comment promises. CodebaseGitMcpTools.kt:302-331 appends the backslash and then the escaped character, so ["C:\\Users\\x.kt"] comes back with the backslashes doubled and \" stays \". The array form is the one you recommend for awkward paths, so it is the most likely carrier of a Windows path. removeSurrounding("\"") at :320 / :329 is also dead code — quotes are never appended to current.

6. project_search (MCP) cannot exclude. The schema at :484 and the handler at :227 omit excludePattern, although the provider takes it and SearchTab.kt:225-240 explains at length why exclusion has to happen inside the engine for the maxResults cap to mean anything. Agents get exactly the pre-exclude behaviour the UI moved away from; adding the parameter is a two-line change.

Security

  • git_checkout (CodebaseGitMcpTools.kt:96), git_diff_ref and git_diff_between pass an LLM-supplied ref straight to the provider, while the UI path runs it through GitBranchModel.isSafeRef first (GitTab.kt:342-347) precisely because "it crosses the plugin API". The host is the real boundary, but the helper is right there and rejects a leading - and control characters — worth applying on the MCP path too.
  • The RBAC split (reads open, mutations behind git.write, project_replace behind project.replace, both declared in definedPermissions) is the right shape, and the comment explaining why an empty requirement list is not a neutral default is worth having written down.

Performance

  • SEARCH results are only half-virtualized. searchFileGroup emits one lazy item per file and renders every match row inside it (SearchTab.kt:796). One file holding 400 of the 500 capped matches composes 400 rows off-screen. Emitting matches as their own items (keyed by path+line+column) would fix it; the generation re-key trick also forces every group to rebuild on collapse-all.
  • Two polls run against the same repository. A 5 s git status while GIT is open, plus a full project content re-scan every 2.5 s while SEARCH is open (SearchTab.kt:396). The mtime cache keeps each scan cheap-ish, but it is still a directory walk and a stat per file, ~24 times a minute per open panel, on a monorepo. Consider a longer search interval, or backing off when the window is unfocused.
  • Every provider call runs on Dispatchers.Default (GitTab.kt:49, SearchTab.kt:85), including blocking git/IPC work and describeMissingRepo's listFiles() plus up to 200 .git stats (GitTab.kt:718-739). Dispatchers.IO is the right pool; Default is sized to cores and these calls block it.

Nits

  • plugin.json:5 still reads "version": "1.0.10". Correct per the convention (build-time sync, and readManifestVersion() reads the processed copy), but a plausible-looking stale number invites confusion — something like "0.0.0-dev" would be self-documenting.
  • CodebaseUi.kt:30 imports MaterialTheme unused.
  • formatCommitDate (GitTabUi.kt:1527) is referenced only from a test; the tooltip it was written for uses the raw fields.
  • Leftover run { … } wrapper around the EXPLORER header text in CodebaseContent.kt.
  • splitFraction is a bare remember in CodebaseGitContent (GitTabUi.kt:147), so the splitter position resets on every tab hop — the same problem that moved the change-layout preference into the view model.
  • CodebaseGitViewModel.branchesOf (GitTab.kt:751) hardcodes the origin/ prefix while remoteNames already carries the real remote set (GitBranchModel does this properly).
  • collapsedDirs is a single set shared by all three change groups, so collapsing src/main in CHANGES also collapses it in STAGED. Possibly intended — worth a comment either way.
  • splitPathForDisplay splits on / only; harmless while provider paths are project-relative POSIX, but a Windows trap if that ever changes.

Conventions

Compose Multiplatform only (the single java.awt.Cursor use is the desktop-appropriate way to do pointerHoverIcon), no Android APIs, null providers degrade to hints rather than crashes, version bumped only in build.gradle.kts — plus the inputs.property("pluginVersion") fix, which is a real bug caught — and trailing newlines everywhere. One aside: the processResources replacement string round-trips correctly only because Matcher re-escapes the stray backslash in "\$version"; it works, but ${'$'}{version} or plain concatenation would be much less surprising to the next reader.

@shivanshu-risa

Copy link
Copy Markdown
Collaborator Author

Addressed in 5b2cf47:

Correctness

  • Agent Review diff dropped on truncation - both halves fixed: collectDiff now reserves the per-file header and marker room in the budget (so the framing can't push the result over it), and AgentReviewPrompt.build no longer re-tests the length at all - it inlines whatever the caller budgeted and adds a truncation note when the marker is present. A single oversized file now sends the agent a truncated diff instead of a file listing. AgentReviewPromptTest pins the new contract ("a truncated diff is inlined with a note, not dropped").
  • Modal sheets - GitConfirmDialog and ConfirmReplaceSheet scrims now consume press events (pointerInput + consume() on the press, matching the pattern already used in CodebaseContent), so the rows underneath are inert while a confirmation is up. The confirm dialog additionally takes focus (focusRequester + focusTarget + LaunchedEffect) so Escape-to-dismiss actually fires.
  • Auto-refresh resurrecting cleared results - the refresh now runs through searchJob like every other scan, so clear()/cancelSearch() reach it, and execute() re-checks the query before publishing so a scan that raced a clear can't publish the old result set. CancellationException is re-thrown instead of being reported as "Search failed".
  • Project switch leaving GIT/SEARCH stale - the existing getProjectPath() poll in CodebaseComponent now resets both view models on a change: gitViewModel.onProjectChanged() clears the graph, branch chip, review base and re-reads status + history for the new project; searchViewModel.clear() drops the old query and results.
  • parseJsonStringArray unescaping - the scanner now decodes real JSON escapes (\\, \", \/, \n, \t, \r, \b, \f, \uXXXX) instead of appending the backslash and the escaped character; the dead removeSurrounding("\"") is gone. ["C:\\Users\\me\\x.kt"] round-trips to C:\Users\me\x.kt (new test).
  • project_search exclusion - excludePattern is in the schema and the handler; the engine excludes during the walk, so excluded matches no longer consume the maxResults cap.

Security

  • git_checkout, git_diff_ref and git_diff_between now run LLM-supplied refs through GitBranchModel.isSafeRef before they cross the plugin API, same as the graph-picker path (new test: leading-dash refs are refused).
  • On the RBAC split: git.write is operationally new - it is not a pre-existing permission. Non-admin org members will need the org admin to create and grant it (admins bypass checks); the PR body notes this.

Performance

  • All provider calls now run on Dispatchers.IO: both view-model scopes and the component's storage reads moved off Default (which is core-sized and was being blocked by git/IPC and listFiles() work).

Nits

  • plugin.json version is 0.0.0-dev - self-documenting that the build is the source of truth.
  • Unused MaterialTheme import removed from CodebaseUi.
  • formatCommitDate now backs the commit-row tooltip (relative date on the row, absolute date in the tooltip).
  • The splitter position moved to the view model with debounced storage persistence (same pattern as the change-group layout), so it survives tab hops and restarts; CodebaseSplitter is now stateless (State<Float> + onChanged).
  • branchesOf folds remote refs using the repository's real remote set instead of a hardcoded origin/ (upstream/x folds when the repo has an upstream remote, a local someone/something never does; test extended).
  • collapsedDirs shared across the three change groups is now a deliberate, commented choice.
  • splitPathForDisplay takes the last separator of either family, so a Windows path can't paint the whole path as the "file name".
  • No leftover run { } wrapper around the EXPLORER header was found in the current tree.

Deferred (noted in the PR body): half-virtualizing the search match rows, and the double-poll cadence (5 s git status + 2.5 s content re-scan) / unfocused backoff.

Local verification: ./gradlew test green (180 tests including the new ones) and buildPluginJar clean.

@claude

claude Bot commented Sep 1, 2026

Copy link
Copy Markdown

Review: IDE-features batch (FILES/SEARCH/GIT, 1.6.0)

Reviewed the full diff (29 files, ~7.9k lines). Overall this is high-quality work — the pure logic (GitBranchModel, GitChangeTree, GitGraphLayout, GitGraphEdges, AgentReviewPrompt, CommitMessagePrompt) is well factored and well tested, provider nullability is handled everywhere I checked (every tab renders an "unavailable on this host" state rather than crashing), the RBAC tiering on the MCP tools is deliberate and documented, and dispose() now actually drops host references. The inline comments explaining why each decision was made are unusually good.

I could not compile or run the tests here — no boss-plugin-api jar is available in this environment (consistent with the PR note that CI is red until 1.0.87 is published). Everything below is from reading.


1. Split-fraction persistence is dead code — CodebaseComponent.kt:140-173

gitViewModel.changeLayout.collect { layout -> … }    // StateFlow — never completes
gitViewModel.setSplitFraction(…)                     // unreachable
gitViewModel.splitFraction.debounce(300).collect {…} // unreachable

changeLayout is a StateFlow, so collect suspends forever. Lines 160-172 never run: the graph/changes splitter position is never restored from codebase.gitSplit and never written back, so it silently resets to 0.55 on every panel open — exactly the behaviour the KDoc on CodebaseGitViewModel.splitFraction says this code exists to prevent.

Each collector needs its own child coroutine:

LaunchedEffect(Unit) {
    selectedTab = CodebaseTab.fromStorage(withContext(Dispatchers.IO) { storage?.getString("codebase.tab") })
    gitViewModel.setChangeLayout(…)
    gitViewModel.setSplitFraction(…)
    launch { gitViewModel.changeLayout.collect { … } }
    launch { gitViewModel.splitFraction.debounce(300).collect { … } }
}

Worth a small test around the persistence wiring — the existing suite covers the pure helpers thoroughly but nothing exercises this path, which is why an unreachable-code bug got through.

2. "Load more" never goes away on a small repo — GitTab.kt:405

fun hasMoreGraph(): Boolean = _graph.value.size < GRAPH_MAX

For any repo with fewer than 200 commits this is permanently true, so GitGraphList (GitTabUi.kt:1042) always renders a Load more action that fetches size + 50, gets the same commits back, and changes nothing. Track exhaustion instead — e.g. set a _graphExhausted flag in loadGraph when nodes.size < limit.

3. project_replace treats an empty JSON array as a filename — CodebaseGitMcpTools.kt:290-299, 355

parseJsonStringArray("[]") returns null (the items.all { it.isNotEmpty() } guard rejects the single empty element), so parseFiles falls through to the flat splitter and returns listOf("[]"). The files.isEmpty() guard at line 261 then passes and a file literally named [] is handed to replaceInProject. Same for "[ ]". Suggest returning emptyList() for an empty body before the all-non-empty check, so the "must not be empty" error fires as intended.

Two smaller things in the same scanner: ["a" "b"] (missing comma) silently concatenates to ab instead of returning null, and items += current.toString().trim() strips legitimate leading/trailing whitespace inside a quoted path.

4. The Agent Review caption over-promises — GitTabUi.kt:725

text = if (base.isBlank()) "Review uncommitted changes." else "Review diffs vs. $base."

The KDoc on AgentReviewPrompt.build is explicit that baseRef is "context for the agent only - the attached diff is the uncommitted change, never a diff against this ref." The caption tells the user otherwise. Either reword ("Review uncommitted changes, targeting $base") or actually attach the diff against that ref.

5. Search results defeat virtualization — SearchTab.kt:742-757

searchFileGroup emits one item {} per file, and SearchFileGroupBody composes every match row inside it. With MAX_RESULTS = 500 concentrated in a few files, one lazy item can hold hundreds of rows, all composed and measured regardless of scroll position. The FILES tab went out of its way to fully flatten its tree into one item per row (issue 8); this tab should do the same — hoist expansion state out of the item and emit header + match rows as separate item/items calls.

6. git_status XY column is misaligned for untracked/ignored — CodebaseGitMcpTools.kt:388, 401-402

statusChar returns two characters ("??", "!!") for UNTRACKED/IGNORED, but the caller concatenates two status chars, so an untracked file renders as " ?? path" (3 wide) against "M path" (2 wide) for everything else. Real porcelain emits ?? path. CodebaseGitMcpToolsTest.kt:206 asserts with result.text.contains("?? u.kt"), which matches the substring and hides the extra column. Special-case untracked/ignored to emit the full XY pair, and tighten the assertion to a line-exact match.


Smaller points

  • Cancellation is swallowed in several places. runCatching { git?.logGraphFor(…) } (GitTab.kt:308) catches Throwable, and the catch (e: Exception) blocks at GitTab.kt:261, GitTab.kt:721 and the previewReplacement/applyReplacement pair in SearchTab catch CancellationException too. After dispose() an in-flight loadGraph will still write _message and blank _graph. SearchTab.execute gets this right (catch (e: CancellationException) { throw e }) — worth applying the same shape to the others.
  • describeMissingRepo runs even when the project is a repo (GitTab.kt:235). It stats up to 200 child directories for .git on every refreshStatus() — i.e. on every GIT-tab entry, every toolbar refresh, and every project switch — just to compute a hint that is only shown when !isRepo. Guard it.
  • buildPluginJar / shadowJar include the raw resources dir (build.gradle.kts:116, 147) alongside sourceSets.main.output. It works today only because DuplicatesStrategy.EXCLUDE keeps the first spec and the processed output is added first. Now that the checked-in plugin.json says 0.0.0-dev rather than a plausible version, any reordering ships a plugin whose manifest version is 0.0.0-dev — and CodebaseDynamicPlugin.readManifestVersion() reads that same field. Prefer from(tasks.processResources) and drop the raw from("src/main/resources"). (The inputs.property("pluginVersion", version) addition is a good catch, though.)
  • project_search / git_diff* carry no permission and can return arbitrary project file content (.env, key material) to any local agent session. That looks like a deliberate read/write split, but given the module KDoc notes the registry exposes unpermissioned tools "to every session, including one where no user is signed in", it may be worth confirming that read-of-anything-in-the-project is in scope for an unauthenticated session.
  • GitGraphLayout.assignLanes builds a full ancestor HashSet per commit (GitGraphLayout.kt:38-47). Fine at GRAPH_MAX = 200, but it is quadratic in time and memory — worth a comment tying the cache to that cap if the window ever grows.
  • GitChangeTree.rows(files, collapsedDirs) is recomputed inside changeGroup (GitTabUi.kt:876) on every recomposition of CodebaseGitContent (i.e. on every busy/message change). Cheap at typical change-set sizes, but hoisting it into a remember(files, collapsedDirs) above the LazyColumn would be free.
  • Unused imports in CodebaseUi.kt:14, 19, 20 (BoxScope, fillMaxHeight, fillMaxSize). Also several catch (e: Exception) blocks never reference e (GitTab.kt:261, GitTabUi.kt:1569) — catch (_: Exception) reads better and silences the warning.
  • Style: CodebaseGitMcpTools.kt and GitTab.compactDiff use fully-qualified ai.rever.boss.plugin.api.* names inline (GitFileStatusTypeData, GitDiffData, DiffLineKind, GitOperationResultData) while the rest of each file imports them. Worth normalising.
  • Visibility: GitGraphLayout is a public object while its siblings (GitBranchModel, GitChangeTree, GitGraphEdges) are internal. Nothing outside the jar calls it.
  • GitStatusLine classifies errors with message.startsWith("Failed:") (GitTabUi.kt:1418). It matches what report() produces today, but a boolean on the state (or a small sealed result type) would survive someone rewording a message.
  • formatRelativeDate reads System.currentTimeMillis() during composition, so rendered ages do not tick without an unrelated recomposition. Cosmetic.

Conventions checklist

  • Compose Multiplatform only — no Android APIs; java.awt.Cursor in CodebaseUi.kt:617 is Desktop, which is correct here. ✅
  • Null-safe provider access throughout; each tab has a dedicated unavailable state, and the MCP handlers return isError results rather than throwing. ✅
  • Version bumped only in build.gradle.kts; plugin.json carries the 0.0.0-dev placeholder (see the packaging note above). ✅
  • All Kotlin files end with a newline (checked the whole diff). ✅
  • Manifest definedPermissions matches the permission strings used in code (project.replace, git.write). ✅

Nothing here is a blocker except item 1, which I would fix before release since it silently disables a feature the code claims to implement. Items 2, 3 and 6 are quick, and item 4 is a one-line wording change.

Finishes the second review round and covers what it did not reach.

Correctness
- The storage LaunchedEffect ran three preferences in sequence, but a
  StateFlow collect never completes: everything after the first one was
  unreachable, so the splitter position was neither loaded nor saved.
  One effect per preference now.
- loadGraph no longer latches "history exhausted" off a FAILED load. The
  error path yields an empty list too, and hiding "Load more" until the
  next reset is the wrong answer to a dropped IPC call.
- Both modal scrims swallow every pointer event, not only Press, so the
  list behind a confirmation sheet cannot be scrolled either.

Dead code the reviewers flagged
- GitChangeTree.emit's prefixLabel was null at both call sites; the chain
  compaction is the local StringBuilder's job.
- GitGraphLayout's ancestorCache was written once per key and never read:
  assignLanes visits each commit exactly once.
- GitGraphEdges' comment described a stub leaving the last row that the
  code does not draw.
- Unused items / PointerEventType imports, and a comment duplicated onto
  collapsedDirs.

Tests: 178 -> 196
- GitViewModelOperationsTest pins the three things that only break across
  a seam, so neither side's own test saw them: the Agent Review diff
  budget (collector meets prompt builder), "Load more" exhaustion, and
  batch's first-error-wins rule. The diff-budget case was mutation-checked
  - reintroducing the length test fails it.
- PluginManifestTest pins the version chain end to end (gradle -> the
  processResources filter -> plugin.json -> the value read back at
  runtime) and asserts every permission the git tools require is declared.
- git_status is now pinned by whole LINE: contains("?? u.kt") passed just
  as happily on the misaligned " ?? u.kt" it was meant to catch.
- git_log limit and project_search maxResults clamping, the empty JSON
  array, GitBranchModel.reviewBases, and collapseHome on a backslash-
  joined path (home is a parameter now, so it is testable off Windows).
- Two tests leaked a CoroutineScope per run; both dispose now.

Docs
- README covered only the FILES tree while this release adds SEARCH and
  GIT, supersedes git-status and git-log, and ships 16 git_*/project_*
  tools. Adds those, the RBAC rationale, the release-ordering constraint,
  and corrects the stated minimums (9.2.20/1.0.66 -> 9.5.7/1.0.87).
  The tool table is cross-checked against the registrations.
- AGENTS.md carried API version 1.0.20 and a provider list from before
  this panel existed.

Verified: ./gradlew clean build green, 196 tests, jar manifest stamped
1.6.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Sep 1, 2026

Copy link
Copy Markdown

Review: IDE-features batch (FILES/SEARCH/GIT, git MCP tools, 1.6.0)

Reviewed the full diff. This is a large, unusually well-documented PR — the "why" comments on the non-obvious decisions (lane-graph edge model, awaitFresh on the MCP reads, collectLatest for the splitter write, the modal scrims eating pointer events, the processResources lambda-overload fix) are the kind of thing that makes the next change to these files safe. Test coverage is genuinely good for a UI-heavy plugin: the pure logic (GitBranchModel, GitChangeTree, GitGraphLayout, GitGraphEdges, prompt builders, MCP arg handling) is all pinned, and GitViewModelOperationsTest covers the collector-to-prompt-builder seam that neither side's own test could see.

Conventions check: Compose Multiplatform only (no Android APIs; java.awt.Cursor is desktop-correct), every provider access is null-safe with a rendered fallback, version only in build.gradle.kts with plugin.json carrying the 0.0.0-dev placeholder, all Kotlin files end with a newline.

I could not compile or run the suite here (the sibling boss-plugin-api jar is not reachable from this sandbox), so everything below is from reading.


Findings

1. commit() is the one operation with no exception handler — a throwing provider vanishes silently

GitTab.kt:521-538

scope.launch {
    try {
        if (stageFirst) { val staged = git.stageAll(); ... }
        report(git.commit(message)) { ... }
        git.refreshStatus()
    } finally {
        _busy.value = false          // no catch
    }
}

Every other path in this class deliberately catches and reports: op() (:770-778), refreshStatus() (:248-252), generateCommitMessage() (:607-608), startAgentReview() (:735-738), and loadGraph() via runCatching — each with a comment saying why. commit() is the exception, and _message.value = null is set at :520 just before, so an IPC drop or a repo mid-rebase gives the user: spinner stops, nothing else. The exception escapes the SupervisorJob scope (no CoroutineExceptionHandler) to the thread's default handler.

It is also the most consequential one to lose: after stageFirst, a failure between stageAll() and commit() leaves the index staged with nothing on screen saying so.

Suggested: mirror op()'s inner try/catch, setting _message.value = "Failed: ...".

2. "Commit All" stages untracked files — the exact surprise the CHANGES header avoids

GitTab.kt:509-524 plus GitTabUi.kt:174-176, 317-326

The CHANGES group header deliberately does not use stageAll, with an explicit comment:

// Stage exactly this group. The provider's stageAll is 'git add -A', which also stages untracked files - surprising under a button that sits beside a separate UNTRACKED group.

But commitStagesAll = staged.isEmpty() && changed.isNotEmpty() leads to viewModel.commit(stageFirst = true), which calls git.stageAll() — git add -A. So with nothing staged, tracked edits present, and files sitting in the UNTRACKED group, pressing Commit All commits those untracked files too. VS Code's equivalent (git commit -a) does not. The reasoning that made the header button correct applies here with higher stakes, since this one lands a commit rather than just staging.

Suggested: have commit(stageFirst = ...) stage changed explicitly (the paths the label refers to), the way stagePaths(changed.map { it.path }) already does.

3. collectDiff only ever truncates the first file; later files are dropped with no marker

GitTab.kt:662-699

if (sb.isNotEmpty() && sb.length + block.length > budget) break

Once anything has been appended, an oversized block short-circuits the loop instead of reaching the slicing path below it. So the truncation marker — which is what AgentReviewPrompt.build (:97) keys off to emit "(diff truncated to the inline budget; use the git_diff / git_diff_ref tools for the rest)" — is only ever appended when file #1 alone overflows. The same applies to MAX_INLINE_FILES = 15 at :665: files 16 and beyond are dropped with no signal at all.

Net effect for the common shape (a dozen medium files, budget exhausted at file 7): the prompt says "Full diff:" and attaches seven of them, with no note that anything is missing. GitViewModelOperationsTest pins the single-oversized-file case, which is why this does not surface.

Suggested: set a truncated flag on either break and append the marker once at the end so the builder's note fires. (Or pass the flag to build explicitly rather than sniffing the marker out of the text — the string-contains coupling between collector and builder is itself a little fragile.)

4. hasMoreGraph() is not observable, so "Load more" can stick around after history runs out

GitTab.kt:453 / GitTabUi.kt:486

hasMoreGraph() reads _graphExhausted.value and _graph.value.size as plain values, and _graphExhausted is never collected in the composable. It works today only because a changing graph (which is collected at :126) drives the recomposition that re-evaluates it. The case that misses: a "Load more" that comes back with the same list — _graph.value = nodes is conflated away by StateFlow equality, _graphExhausted flips to true at :349, and nothing recomposes, so the action stays on screen. Exposing it as a StateFlow<Boolean> removes the dependency on an incidental emission.

5. refreshStatus() samples isGitRepository.value immediately after refreshStatus() — the staleness awaitFresh exists to fix

GitTab.kt:238-256

git?.refreshStatus()
_noRepoHint.value = if (isGitRepository.value) "" else describeMissingRepo(getProjectPath())

This is the read-right-after-refresh pattern that CodebaseGitMcpToolProvider.awaitFresh (CodebaseGitMcpTools.kt:398-406) was written to avoid, with the same reasoning: out-of-process, the flow has not moved yet. Two consequences on a real repo — describeMissingRepo does a listFiles() plus up to 200 File.exists() stats that the normal case is documented as never needing, and since _loaded flips to true in the same finally, the panel can briefly render "X is not a Git repository." before isRepo catches up. Reusing awaitFresh here would settle both.


Smaller things

  • build.gradle.kts:113-116 and :153-155 — buildPluginJar and shadowJar both do from(sourceSets.main.get().output) and from("src/main/resources"). The second is the unprocessed source dir, so with DuplicatesStrategy.EXCLUDE the correct manifest only wins because the processed copy is declared first. That was harmless when plugin.json carried a real version; now that it is 0.0.0-dev and readManifestVersion() reads it back at runtime, a reorder ships a plugin that reports 0.0.0-dev to the host. from("src/main/resources") is redundant (sourceSets.main.output already includes processed resources) — dropping it removes the landmine.
  • GitTab.kt:297-310 — onProjectChanged() clears the branch, graph, options and review base, but not _commitMessage or _reviewInstructions. A drafted commit message follows you into the next project's commit box.
  • CodebaseGitMcpTools.kt:239 — requiredPermissions = listOf("project.replace") is a bare literal while the git tools use the GIT_WRITE constant. PluginManifestTest reads both, so a PROJECT_REPLACE constant keeps the manifest pin symmetric.
  • CodebaseGitMcpTools.kt:424-448, 456-499 and GitTab.kt:623-651 — roughly fifteen inline ai.rever.boss.plugin.api.GitFileStatusTypeData / GitDiffData / GitOperationResultData / DiffLineKind references in files that import everything else. Imports would make statusCell and compactDiff much easier to scan.
  • GitTab.kt:279, GitTab.kt:826, GitTabUi.kt:1578 — catch (e: Exception) with e unused, three warnings. catch (_: Exception).
  • GitTabUi.kt:1086-1095 — the absoluteDate / tooltip block is de-indented out of the enclosing lambda. Cosmetic, but it reads as a scope break.
  • CodebaseGitMcpTools.kt:369-381 — parseJsonStringArray trims each element after unescaping, so a JSON-quoted path with intentional leading or trailing whitespace loses it. Vanishingly rare; noting it only because the array form exists specifically to carry awkward paths faithfully.
  • SearchTab.kt:305-309 — absolutePathOf does root.trimEnd('/') only; on Windows a C:\proj root joins as C:\proj/src/x.kt. Java tolerates it, but trimming both separators plus PathUtils.platformSeparator would match what collapseHome already does.

RBAC

The three-tier split reads right, and the framing in the class KDoc — an empty requiredPermissions is exposure, not a neutral default — is the correct one. One thing worth an explicit sign-off from whoever owns the security model rather than being settled inside this PR: project_search being open means any local session, signed in or not, can read arbitrary project file contents through an MCP tool. That is a defensible call given the panel shows the same data, but it is a broader read surface than git_status / git_log, and it is the one open tool where "read-only" and "harmless" are not the same thing.

Release ordering

The note about the RetiredPlugins floor (codebase >= 1.6.0) failing silently if this ships after the host release is the right thing to have written down — worth carrying into the release checklist rather than leaving it in the PR body, since the failure mode is two panels quietly disappearing.


Nothing here blocks the direction. #1 and #2 are the two I would want fixed before merge, #3 next.

…, observable hasMoreGraph

Per the 05:09 review:

1. commit() now has the try/catch every other operation has; a throwing
   provider (IPC drop, index.lock) reports "Failed: ..." instead of escaping
   the SupervisorJob scope to the thread's default handler after the spinner
   stopped.

2. commit() takes the paths to stage, not a stageAll flag. The provider's
   stageAll is `git add -A`, which also stages untracked files; under a button
   labelled "Commit All" that sits above a separate UNTRACKED group it quietly
   commits files the user never touched. The UI passes `changed` (the tracked
   edits the label counts), and a stage failure stops short of the commit.

3. collectDiff returns the collected text AND a truncated flag, set on every
   drop path (sliced file, budget exhaustion, file cap, failed fetch) rather
   than inferred from a marker only the sliced file carries. The prompt builder
   takes diffTruncated as a fact and says the diff is INCOMPLETE; a collection
   that simply stopped early no longer reads as complete.

4. hasMoreGraph is a StateFlow (combine of the graph and the exhaustion latch)
   collected by the UI, not a function reading both as plain values; the
   confounded-same-list case that would leave the action on screen is now
   covered by the flow itself.

5. refreshStatus reads isGitRepository through awaitRepositoryFlag() - the
   same wait the MCP tools' awaitFresh does - instead of sampling the flag in
   the same breath as the async refresh.

Smaller: buildPluginJar/shadowJar no longer re-add the raw src/main/resources
alongside the processed output; onProjectChanged clears the commit message,
review instructions and graph latch; project.replace permission uses a
constant; api types imported instead of fully qualified; three unused catch
parameters renamed; the GitCommitRow tooltip block re-indented;
parseJsonStringArray no longer trims element interiors; absolutePathOf trims
both path separators and joins with the platform's.

Tests: commit stages-by-path and never stageAlls; a stage failure and a
throwing commit() both surface; a cut-short multi-file collection and a
file-cap overflow both announce themselves as incomplete; the truncation
warning is driven by the flag, not the marker.
@shivanshu-risa

Copy link
Copy Markdown
Collaborator Author

Round 4 - all five findings addressed

Per-finding against 1a97128:

1. commit() exception handler

Added. commit() now has the same inner try/catch as op(): a throwing provider reports _message.value = "Failed: ${e.message ?: e::class.simpleName}" instead of escaping the SupervisorJob scope to the thread's default handler. Pinned by a new test where the fake's commit throws IllegalStateException("index.lock exists") and asserts the message surfaces and busy clears.

2. "Commit All" stages untracked files

Fixed by design: commit(stageFirst: List<String>) now takes the paths to stage, and the UI passes exactly changed (the tracked edits the label counts). The provider's stageAll (git add -A) is no longer called from this path at all, so the UNTRACKED group can never be swept in. A staging failure stops short of the commit ("Failed to stage $path: ..." and return@launch) - a partial index plus a commit is worse than no commit. New tests: Commit All stages the tracked edits it names, never the untracked group (asserts stageAllCalls == 0) and a failure while staging stops short of the commit.

3. collectDiff only truncates the first file

Fixed both ways suggested. collectDiff now returns CollectedDiff(text, truncated) where truncated is set on every drop path: a sliced file, the remaining < MIN_DIFF_SLICE break, files past MAX_INLINE_FILES, and a failed fetch. The early if (sb.isNotEmpty() && sb.length + block.length > budget) break line is gone - every file gets sliced to fit, not skipped. The prompt builder takes diffTruncated as a fact instead of sniffing the marker out of the text, and says the diff is "INCOMPLETE - it was cut to fit an inline budget... must be read with git_diff before you draw conclusions about them". New tests: a diff cut short across several files still says it is incomplete (12 medium files, budget exhausted mid-list, asserts INCOMPLETE and at least one dropped header) and files dropped past the inline file cap are announced too (20 tiny diffs, nothing sliced, char budget never reached - the case with no marker anywhere).

4. hasMoreGraph() not observable

hasMoreGraph is now a StateFlow<Boolean> - combine(_graph, _graphExhausted) { !exhausted && graph.size < GRAPH_MAX } - and the UI collects it. The confounded-same-list case the review names ("Load more" returns the same list, latch flips, nothing recomposes) is covered by the flow itself rather than by an incidental emission. GitViewModelOperationsTest now asserts vm.hasMoreGraph.value in both the small-repo and full-page cases.

5. refreshStatus() samples isGitRepository immediately

refreshStatus now calls awaitRepositoryFlag() - the same shape as the MCP tools' awaitFresh (snapshot before, refresh, wait up to REPO_FLAG_SETTLE_MS for the flag to move off the snapshot, fall back to the latest value on a no-op refresh) - instead of sampling the flow in the same breath as the async refresh.

Smaller items

All eight:

  • from("src/main/resources") dropped from both buildPluginJar and shadowJar, with a comment on why the raw dir must not be re-added now that plugin.json carries the 0.0.0-dev placeholder and readManifestVersion() reads it back at runtime.
  • onProjectChanged() now also clears _commitMessage, _reviewInstructions, and the _graphExhausted latch (a "Load more" carried into a new project would otherwise look exhausted).
  • PROJECT_REPLACE constant added next to GIT_WRITE; PluginManifestTest's pin still passes.
  • GitFileStatusTypeData / GitDiffData / GitOperationResultData / DiffLineKind imported instead of fully qualified in both files.
  • The three unused catch (e: Exception) parameters renamed to _.
  • The absoluteDate / tooltip block re-indented into the enclosing lambda.
  • parseJsonStringArray no longer trims element interiors; whitespace between elements is still skipped by the scanner, so intentional leading/trailing whitespace inside the quotes survives.
  • absolutePathOf trims both '/' and '\\' and joins with PathUtils.platformSeparator.

RBAC / release ordering

project_search staying open is a deliberate call I'm comfortable owning here: the FILES/SEARCH panel renders the same file contents to the same local user, so the MCP tool adds no new information - only a different interface to it. Flagging it anyway for the security-model owner: it is the one open tool where "read-only" and "harmless" are not the same thing, and if the model tightens, it is the one to gate. The RetiredPlugins floor note (codebase >= 1.6.0 failing silently if this ships after the host release) is carried into the release checklist rather than left in the PR body.

@claude

claude Bot commented Sep 1, 2026

Copy link
Copy Markdown

Review: IDE-features batch (FILES/SEARCH/GIT, 1.6.0)

Reviewed the full diff at 1ce734e (33 files, ~8.9k added). This is unusually well-documented work — nearly every non-obvious decision carries a comment explaining the failure it prevents, and the tests pin the seams that matter (the diff-budget seam, RBAC tiers, porcelain XY alignment, argument clamping, the version chain). The RBAC reasoning in CodebaseGitMcpTools.kt — "an empty requiredPermissions is not a neutral default" — is the right call and worth keeping as a precedent.

I could not build locally (../boss-plugin-api is not present in this checkout and CI is not set), so everything below is from reading. Conventions check passes: no Android-only Compose APIs, every provider access is null-guarded with a hint state, version lives only in build.gradle.kts (plugin.json keeps the 0.0.0-dev placeholder), and every file ends with a newline.


1. applyReplacement re-reads the query/flags instead of using what the dry run measured

SearchTab.kt:342-368. The confirmation sheet is rendered from _dryRun, but the apply re-reads _query.value, _replacement.value, _isRegex, _caseSensitive, _wholeWord at click time. The file list comes from the dry run; the operation comes from current state. Those can disagree.

The window: previewReplacement() starts a full-project dry run and the sheet only appears when it returns. Until then the search field is fully live. Type into it and setQuery fires scheduleSearch() and nulls _dryRun — but the in-flight preview then re-sets _dryRun with the old query’s summary. The sheet now says "Replace 47 occurrences across 9 files?" while applyReplacement will run the new query against the old file list. On a monorepo that dry run takes long enough for this to be reachable.

Fix is small: snapshot (query, replacement, isRegex, caseSensitive, wholeWord, files) alongside the ReplaceSummary and have applyReplacement use the snapshot, so the sheet and the write are guaranteed to describe the same operation. That also makes the stale-preview publish harmless.

2. execute() clears the _busy mutex that previewReplacement / applyReplacement hold

SearchTab.kt:236 and :274 set _busy unconditionally, while previewReplacement (:321) and applyReplacement (:345) use it as a compare-and-set mutex — the same pattern GitTab.kt applies consistently in op(), commit() and startAgentReview(). Here one flag is doing both jobs, and the plain setter wins.

Concretely: startAutoRefresh (:209) reads !_busy.value, then launches — the flag is not set until the coroutine body runs. A Replace click landing in that gap CASes false→true successfully, so both run; the scan’s finally then sets _busy = false while the write is still in flight, re-enabling the Replace and Cancel buttons mid-write. A second click gets a fresh CAS and a duplicate write.

Cleanest fix is a separate _writing flag for the replace path, or having execute() also CAS and skip when busy.

3. Path arguments do not get the isSafeRef treatment that refs do

CodebaseGitMcpTools.kt. git_checkout / git_diff_ref / git_diff_between correctly gate LLM-supplied refs through GitBranchModel.isSafeRef — "a leading dash would become a git flag." That reasoning applies verbatim to the path argument on git_stage, git_unstage, git_discard (irreversible) and git_diff, which are passed to the provider unchecked (:71, :82, :107, :131).

Whether it is exploitable depends on whether the host separates the pathspec with --. If it does, a one-line comment saying so closes the question; if it does not, a leading-dash path reaching git checkout <path> is the same class of problem you already fixed for refs. Worth confirming against the host implementation either way, since git_discard is the one tool here that destroys work.

4. loadGraph has no concurrency guard

GitTab.kt:369-418. Every other operation in this view model CASes _busy before launch — with an explicit comment (:844-847) about two clicks in the same frame both passing the enabled = !busy guard. loadGraph sets _graphBusy inside the coroutine, so the exact scenario that comment describes applies to it: "Load more" clicked twice computes limit from the same _graph.value.size and issues two identical fetches, whichever finishes first clears _graphBusy (spinner vanishes with a load still running), and the two can interleave their writes to _graph / _graphExhausted. Refresh racing Load more is the messier version. A CAS on _graphBusy before launch matches the pattern already established a few methods down.

5. _busy is shared across all git operations, so the review pill mislabels

GitTabUi.kt:637. busy drives "Reviewing…" on the Agent Review pill, but it is the same _busy every stage/unstage/discard/commit/fetch/pull/push sets. Stage a file and the pill reads "Reviewing…" for the duration; conversely, Agent Review is unclickable while any unrelated git op is in flight. A dedicated _reviewing flag (you already have _generating for the commit-message path, for the same reason) fixes both halves.

Smaller things

  • GitGraphList key can crash. GitTabUi.kt:1041 uses key = { _, n -> n.hash }. A duplicate key is a hard IllegalArgumentException in LazyColumn, not a rendering glitch — which is exactly why searchFileGroup (SearchTab.kt:791) keys by index and says so in a comment. GitGraphEdges.build uses putIfAbsent for rowOf, which suggests duplicate hashes were considered possible somewhere. Keying by index plus hash costs nothing.
  • formatCommitDate allocates per row per recomposition. GitTabUi.kt:1095 builds a SimpleDateFormat inside GitCommitRow’s body, un-remembered, so it runs on every hover-driven recomposition of every visible row. Wrap it in remember(node.date), or hoist a shared formatter.
  • previewReplacement runs a dry run that can never be applied while capped. The sheet disables Replace when capped (SearchTab.kt:1045) — good — but the expensive dry run has already run by then. Early-returning with the "narrow your search" message would be cheaper and arrive sooner.
  • Composition-abandonment leak. CodebaseComponent.kt:116-132 constructs both view models (each owning a CoroutineScope and starting work) inside remember { }. If a composition is abandoned before it is applied, the paired DisposableEffect.onDispose never runs and the scope leaks. Rare, but a RememberObserver on the view models would make it airtight — and the comment right above says this class of leak is what the DisposableEffect is there to prevent.
  • Agent Review broadcasts the diff. CodebaseDynamicPlugin.kt:75-86 publishes up to INLINE_DIFF_BUDGET (15 KB) of source diff as a CustomPluginEvent payload on the application event bus, which every loaded plugin can observe. Presumably in-process and trusted, but it is a broadcast rather than a targeted send and it carries source content. Worth a line in the KDoc acknowledging that.

Tests

196 green tests, and the ones covering the tricky seams (GitViewModelOperationsTest, PluginManifestTest, the RBAC tier assertions) are well-aimed. Two gaps:

  • awaitFresh’s happy path is untested, and its timeout path is slow. In CodebaseGitMcpToolsTest the fakes’ refreshStatus/refreshLog are no-ops and the flows never move, so every git_status and git_log invocation burns the full FLOW_SETTLE_MS (2 s) — five or so tests, roughly 10 s of pure wall clock. More to the point, the behaviour awaitFresh exists for (the post-refresh emission arriving) is never exercised. A fake whose refreshStatus() mutates its MutableStateFlow would run instantly and pin the intended semantics; making FLOW_SETTLE_MS injectable would let you pin the fallback cheaply too.
  • CodebaseSearchViewModel has no behavioural tests. absolutePathOf and describeReplacement are covered (SearchPathTest), but execute() is not — the debounce, the mid-scan _query.value != q guard, the capped threshold, and the preview/apply interaction in items 1 and 2 above are all untested. Given that this is the only tab that writes file contents, it is the one that most wants a view-model test.

Docs / release

README and AGENTS.md are accurate against the code, and the release-ordering note about the RetiredPlugins floor is the kind of thing that is easy to lose — good that it is in both the PR body and the README. The git.write ops note (admins must grant it or eight tools are unreachable for non-admins) should probably also land in the release notes, not just here.


Items 1 and 2 are the ones I would want fixed before merge, since they can produce a wrong write to source files. Items 3-5 are worth resolving but are lower-stakes. The rest is polish.

…flags

Per the 05:36 review. Items 1 and 2 could produce a wrong write to source
files, so both are pinned by tests that fail when the bug is reintroduced.

1. The dry run and the write could describe DIFFERENT operations. The
   confirmation sheet was rendered from the summary, but applyReplacement
   re-read the live query, replacement and flags at click time and took the
   file list from the summary. The search field stays live for the whole
   dry run, so the sheet could say "Replace 47 across 9 files" and then run
   a different operation. previewReplacement now snapshots the whole
   operation synchronously (ReplaceOperation) and the write uses nothing
   else. A preview that lands after the user has typed on is dropped rather
   than republished.

2. execute() cleared the mutex the replace path holds. One flag was doing
   two jobs: the replace path CAS'd it while execute() set and cleared it
   unconditionally, so a scan finishing mid-write re-enabled the Replace
   button and a second click ran a duplicate write. Now _scanning and
   _writing are separate, `busy` is their union for the UI, and the
   auto-refresh no longer scans during a write.

3. Path arguments do NOT need the isSafeRef treatment refs get, and now say
   why: the host runs ProcessBuilder("git", *args) - argv, never a shell -
   puts `--` before every pathspec, and sets GIT_LITERAL_PATHSPECS=1.
   Verified against DesktopGitService. Checking paths here would only
   reject the legal ones beginning with a dash.

4. loadGraph had no concurrency guard. A CAS would have fixed the race by
   dropping the second call, which is right for a repeated "Load more" and
   wrong for selectGraphBranch and Refresh - dropping those leaves the graph
   on the previous ref, which the branch tests caught. Loads are serialised
   on a Mutex instead, with a generation counter so only the newest owns the
   spinner and a superseded-while-queued request is skipped.

5. The Agent Review pill read "Reviewing…" for any git operation and was
   unclickable during unrelated ones. It has its own _reviewing flag now,
   like _generating.

Smaller: the graph list keys by index AND hash (a duplicate key throws out
of LazyColumn); formatCommitDate is remembered per row instead of building a
SimpleDateFormat on every recomposition; the view models are held through a
RememberObserver so an ABANDONED composition disposes them too (a
DisposableEffect never runs on that path); and the Agent Review KDoc records
that the prompt is a broadcast carrying source diff.

Concurrency audit while in here: searchJob, refreshTimer and statusTimer are
written from the UI thread and read from coroutines, so they are @volatile;
loadGraph's generation counter is atomic for the same reason.

Tests: 205 -> 212, and the suite drops from 22.1s to 1.8s. The MCP fakes now
PUBLISH on refresh like the real provider, which both pins awaitFresh's happy
path - previously untested - and stops every call paying the full 2s settle;
the settle is injectable so the fallback is pinned cheaply too. New
SearchViewModelTest covers the tab that writes files: the snapshot invariant,
the retracted stale sheet, the write mutex surviving a scan, the capped
early-return, and clear() during a scan.

Verified: ./gradlew clean build green, 212 tests, no source warnings, jar
manifest stamped 1.6.0 with a single plugin.json.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Sep 1, 2026

Copy link
Copy Markdown

Review — IDE-features batch (FILES/SEARCH/GIT, 1.6.0)

Read the full head state of the new/changed sources rather than the commit series. This is unusually well-worked code: the reasoning comments are load-bearing rather than decorative, the concurrency choices (compare-and-set before launch, graphMutex + generation counter, cancelAndJoin in startScan, _writing split from _scanning) are each justified against a specific failure, and DisposedWhenGone covering onAbandoned is a detail most panels get wrong. Test shape is good too — PluginManifestTest pinning the whole version chain and the "every required permission is declared" invariant is the kind of test that pays for itself.

Conventions check: Compose Multiplatform only (java.awt.Cursor behind pointerHoverIcon is desktop-correct, no Android APIs); every provider is null-guarded with a CodebaseEmptyState fallback in all three tabs; version lives only in build.gradle.kts with plugin.json carrying the 0.0.0-dev placeholder; spot-checked every new main source file ends with a newline. The processResources fixes (input property + the lambda overload of replace) are genuine correctness wins, not cosmetics.

I could not run the build or the suite here (no boss-plugin-api jar in this environment), so compilation and the 196 tests are on CI.

1. The four diff tools report "No diff." when git is unavailable

CodebaseGitMcpTools.kt:149, :159, :181, :197 all use the gitProvider?.…orEmpty() shape:

diffTexts(gitProvider?.diffFile(path, args.boolean("staged") ?: false).orEmpty())

With a null provider — the host predating GitDataProvider, or no project open — that collapses to emptyList() and renders "No diff." / "No changes." with isError = false. git_status and git_log handle the same condition correctly via unavailableGit(), so the same session gets an honest error from one tool and a confident false negative from the next. An agent reading "No changes." concludes the tree is clean and skips the work.

Same fix as git_log already uses:

handler = McpToolHandler { args ->
    val provider = gitProvider ?: return@McpToolHandler unavailableGit()
    val path = args.string("path") ?: return@McpToolHandler missing("path")
    diffTexts(provider.diffFile(path, args.boolean("staged") ?: false))
}

CodebaseGitMcpToolsTest covers this for git_log (line 124); worth extending that case over all six read tools.

2. A failed graph load wipes the graph that is already on screen

GitTab.kt:449-474. The failed flag is threaded carefully into the _graphExhausted latch — with a comment explaining exactly why a failed load must not latch — but _graph.value = nodes runs unconditionally, and on the failure path nodes is emptyList(). So an IPC drop on "Load more" replaces 50 rendered commits with the "No commits." empty state plus an error line, and the user has to Refresh to get back what they already had. hasMoreGraph then also reads true against an empty graph.

if (!failed) {
    _graphExhausted.value = nodes.size < limit
    _graph.value = nodes
    refreshReviewBases(nodes)
}

No test covers a throwing logGraphFor — GitGraphBranchViewModelTest and GitViewModelOperationsTest have no failure case at all, which is presumably why this one survived.

3. awaitFresh costs the full 2s on every no-op refresh

CodebaseGitMcpTools.kt:422-430. The wait resolves on a value change, and a StateFlow conflates an identical value, so the common case — an agent calling git_status twice, or git_log re-requesting the same page — always burns FLOW_SETTLE_MS in full. The KDoc calls this out as an accepted fallback, and the test class injects 60ms precisely because 2s per call dominated its runtime; that same cost lands on real agent turns.

GitDataProvider exposes isLoading (the test fake overrides it). Awaiting a true→false edge on that, with the value-change wait as the fallback, would distinguish "refresh finished, nothing moved" from "refresh still in flight" and drop the no-op case to ~zero.

4. project_search can report a capped result as a complete one

CodebaseGitMcpTools.kt:282-289 prints "N match(es)" with no indication the engine stopped at maxResults. Because DEFAULT_SEARCH_RESULTS and MAX_RESULT_LINES are both 100, the "... more matches" tail can never fire on a default call — a search with 5000 hits returns exactly "100 match(es)" and reads as the whole answer. The SEARCH tab gets this right with _capped and its (capped) suffix; the tool should say the same thing when matches.size >= effectiveMax.

5. collectDiff drops a blank-diff file without marking the result truncated

GitTab.kt:832 — val block = compactDiff(diff).ifBlank { continue }. A mode-only change, a pure rename, or a binary file yields no changed lines, so the file is listed under "Staged files:" in the brief but has no diff block, and truncated stays false, so AgentReviewPrompt prints "Full diff:" with no caveat. That is the same failure mode the method's own KDoc describes fixing for the file-cap case. One-liner: .ifBlank { truncated = true; continue }.

Smaller things

  • GitTabUi.kt:1440 — GitStatusLine picks the error styling with message.startsWith("Failed:"), but the view model also emits "Failed to read git status: …", "Failed to load git history: …", "Failed to stage <path>: …" and "Agent review failed: …", all of which render in the neutral style. A boolean alongside the message beats sniffing the prefix.
  • CodebaseGitMcpTools.kt:388 — parseJsonStringArray is less strict than documented: ["a" "b"] (missing comma) toggles inString twice and yields the single path ab instead of falling back to the flat form. Requiring a closing quote to be followed by a comma or end-of-body closes it.
  • CodebaseComponent.kt:150-156 — the persisted-tab load races a click: tapping GIT within the storage read's window gets overwritten back to the stored tab. Only matters on a slow storage impl, but the read is already on Dispatchers.IO so the window is real.
  • SearchTab.kt:223-231 — searchJob is @Volatile but the read-then-write in startScan is not atomic, so two concurrent callers can both capture the same previous and one job escapes ever being joined. Practically UI-thread-driven and the auto-refresh guards on isActive, so low risk; getAndSet on an AtomicReference would make it match the care taken elsewhere in the same class.

One question on posture, not a defect

project_search and the four git_diff* tools carry no requiredPermissions, which by the class KDoc's own reasoning means every local MCP session reaches them — including one with nobody signed in. Those tools return file contents: contextLine per match, and up to 64 KB of whole-file diff (the host generates with -U100000). Gating mutation but not reads is a defensible line for a local IDE, and the PR body states it deliberately — just flagging that project_search for a secret name is a content read the same unauthenticated session is blocked from doing through git_discard, so it is worth being sure that asymmetry is intended rather than inherited.

Nothing here blocks the release-ordering constraint; items 1 and 2 are the two I would fix before shipping, since both turn a transient/absent-provider condition into a confident wrong answer.

@shivanshu-risa
shivanshu-risa marked this pull request as ready for review September 1, 2026 06:01
The round-9 host review (finding 10): a file whose matches were capped in the
search view can have more replaced than were displayed, and the count the
MCP caller reads must not be mistaken for the displayed one. The
project_replace description now says so. The SearchTab confirmation sheet
already shows the DRY-RUN count, which is the applied count, so it stays
as is.
@claude

claude Bot commented Sep 1, 2026

Copy link
Copy Markdown

Review: IDE-features batch (FILES/SEARCH/GIT, 1.6.0)

Big PR, and a genuinely well-worked one — the KDoc-as-rationale style makes the intent of nearly every non-obvious line checkable, DisposedWhenGone/onAbandoned is the right answer to the abandoned-composition leak, per-row LazyColumn virtualization in both new tabs, stagePaths batching to dodge concurrent index writes, snapshot-then-apply in the replace path, and compactDiff instead of -U100000 rawUnified are all good calls. PluginManifestTest guarding the whole version chain (including the Matcher-escaping trap) is a nice touch.

I found two things I would fix before merge and a handful worth a look.


Should fix

1. git_cherry_pick / git_revert take an unvalidated hash — CodebaseGitMcpTools.kt:224-238

Every other LLM-supplied ref on this surface goes through GitBranchModel.isSafeRef (git_checkout L136, git_diff_ref L180, git_diff_between L193/L196), and the class KDoc states the reason: a ref is passed to git ahead of --, so a leading dash is read as a flag. hash is the same kind of argument and skips the gate entirely:

val hash = args.hash()
if (hash == null) missing("hash") else op("Cherry-picked $hash", gitProvider?.cherryPick(hash))

hash = "--abort", "-n", "--strategy-option=theirs" all reach git as flags rather than as a commit, and hash = "" passes the null check too. These sit behind git.write, so it is not reachable by an ungranted session — but the point of isSafeRef is that a granted agent should not be able to turn a commit argument into a flag. Suggest routing args.hash() through the same check (and rejecting blank), plus a CodebaseGitMcpToolsTest case — the existing missing-argument test covers git_discard/git_diff_ref but neither of these two.

Same gap, much lower stakes, in CodebaseGitViewModel.revert/cherryPick/checkout (GitTab.kt:615-619): those arguments come from the graph, so it is consistency rather than exposure, but selectGraphBranch validates and its three siblings do not.

2. A failed graph load blanks the graph — GitTab.kt:450-474

val nodes = runCatching { git?.logGraphFor(ref, limit) }.getOrElse { e ->
    _message.value = "Failed to load git history: ${e.message}"; failed = true; null
} ?: emptyList()
...
if (!failed) _graphExhausted.value = nodes.size < limit
_graph.value = nodes            // <- not guarded

_graphExhausted is correctly protected from the failure path, but the list itself is not. One IPC drop or a repo mid-rebase replaces a loaded 200-commit graph with the "No commits." empty state sitting next to the error line, and the only way back is another Refresh. if (!failed) _graph.value = nodes (or keeping the previous list when failed && !reset) matches the reasoning already applied one line above. Not currently pinned by a test.


Worth a look

3. awaitFresh pays the full 2 s whenever nothing changed — CodebaseGitMcpTools.kt:425-433

StateFlow conflates an identical value, so a no-op refresh emits nothing at all and withTimeoutOrNull(FLOW_SETTLE_MS) runs to completion. The KDoc says this ("the wait times out, falling back to the latest value"), but the cost is not called out: two consecutive git_status calls on an unchanged tree — the normal shape for an agent polling between steps — cost 2 s each. Same in awaitRepositoryFlag (GitTab.kt:341), where isGitRepository essentially never moves, so every Refresh click parks a coroutine for the whole 2 s before _noRepoHint settles. If GitDataProvider exposes anything more precise (a completion signal, a revision counter), that would be strictly better; failing that, the repo flag could take a much shorter settle than the status flow, since a change there is the rare case.

4. startScan swaps searchJob non-atomically — SearchTab.kt:223-231

val previous = searchJob
searchJob = scope.launch { previous?.cancelAndJoin(); if (delayMs > 0) delay(delayMs); execute() }

The coroutine can start on Dispatchers.IO before the assignment lands, and callers come from both the UI thread (typing, Enter, the icon) and scope (the 15 s refresh timer, the post-replace re-scan). Two launches can therefore capture the same previous, both join it, and run execute() concurrently — the overlap the KDoc says joining prevents ("so it needs no mutex of its own"). Consequence is mild (_query.value != q catches the stale publish; _scanning gets cleared by whichever finishes first), but AtomicReference.getAndSet or a small mutex around the swap would make the doc true.

5. GitStatusLine only styles "Failed:" as an error — GitTabUi.kt:1440

val failed = message.startsWith("Failed:"). The view model also emits "Failed to read git status: …" (L319), "Failed to load git history: …" (L454), "Failed to stage $path: …" (L653), "Failed: …" (L667/L930) and "Agent review failed: …" (L891). Four of those render in the neutral colour. Either normalise the prefix at the source or carry an isError flag on the message rather than sniffing the string.

6. The files JSON scanner is not as strict as its KDoc — CodebaseGitMcpTools.kt:361-409

c == '"' -> inString = !inString has no notion of "this element's string is already closed", so ["a" "b"] and ["a""b"] both parse as the single element ab rather than returning null. And ["a", ] produces ["a", ""], fails all { it.isNotEmpty() }, falls through to the flat splitter, and yields the two paths ["a and ]. Both cases end up as bogus paths handed to replaceInProject (reported back as per-file errors, so not dangerous — just not the "strict scan … returns null for anything else" the doc promises).

7. minApiVersion is not enforced by the build

plugin.json declares minApiVersion: 1.0.87, but nothing pins the compile jar to it — local dev resolves the newest jar in the sibling checkout and CI resolves latest. The comment at GitTab.kt:444 says logGraphFor degrades gracefully "on a host that predates 1.0.90", which reads as a member newer than the declared floor; if it truly does not exist at 1.0.87 the call is a NoSuchMethodError on the runtime interface, not a default body. Worth confirming that logGraphFor, branches(), openDiff(fromRef/toRef), searchInProject(excludePattern = …) and McpToolDefinition.withRbac all exist at 1.0.87 — and either raising the floor or resolving the exact floor jar in CI, so the compiler enforces the contract instead of a comment doing it.


Notes (non-blocking)

  • collectDiff reservation is one char short of its own promise — GitTab.kt:835-851. frame = header + marker + 2, but the append is header + slice + marker + 3, so a truncated collection lands at exactly budget + 1. Harmless now that AgentReviewPrompt deliberately does not re-test the length, but the two KDocs contradict each other ("the framing never pushes the result past [budget]" vs. "lands a few characters OVER the budget"). + 3 would make the first one true and the second unnecessary.
  • DisposableEffect(Unit) in CodebaseGitContent (GitTabUi.kt:168) vs. DisposableEffect(viewModel) in CodebaseSearchContent (SearchTab.kt:606). No effect today since the VM is remembered for the panel's life, but the Git one will not re-key if that ever changes.
  • GitGraphLayout.assignLanes rebuilds a full ancestor BFS per unplaced commit — O(n·E). Bounded by GRAPH_MAX = 200 so it is fine in practice, but the KDoc's "the loop below visits each commit exactly once, so a cache keyed by hash would only ever be written to" is not accurate about ancestorsOfCommit itself.
  • Read-tier RBAC: project_search with isRegex=true, query=".", maxResults=2000 returns 2000 lines of arbitrary project content with no grant. That is consistent with the already-open codebase_read, so it is not a regression from this PR — flagging only because the PR reasons carefully about the write tier, and the read tier is a full content-read surface rather than metadata, including in the signed-out case the KDoc calls out.

Conventions

  • Compose Multiplatform only — no Android imports anywhere; java.awt.Cursor in CodebaseSplitter is desktop-appropriate. ✅
  • Null-safe providers — every entry point degrades to a hint (CodebaseEmptyState, unavailableGit(), unavailable()), the MCP provider resolves both providers per call rather than at registration, and dispose() drops every host reference. ✅
  • Version — 1.6.0 only in build.gradle.kts; plugin.json carries the 0.0.0-dev placeholder and processResources stamps it, with the inputs.property declaration so a bump actually re-runs. ✅
  • Permissions — both new ones in definedPermissions, and PluginManifestTest derives the required set from tools() so a future tier cannot ship undeclared. ✅

Test coverage

178 → 196, with the pure logic (branch model, change tree, graph edges/layout, prompt builders, MCP tiers and argument handling) well covered. Three gaps matching the findings above: no case for git_cherry_pick/git_revert argument validation, the failed-graph-load path is not pinned, and startScan's concurrency is not. I could not run ./gradlew test here — the sibling boss-plugin-api checkout is not available in this environment — so the above is a static read only.

@shivanshu-risa
shivanshu-risa merged commit 2a82dee into main Sep 3, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant