feat(source-control): show all sub-repos when the workspace root is a container#922
feat(source-control): show all sub-repos when the workspace root is a container#922wang-mingxue wants to merge 2 commits into
Conversation
… container When the active directory is not itself a git repo but contains child repositories (e.g. a git-worktree workspace holding several repos), the Source Control panel showed "No repository". Now it discovers each immediate child repo and renders one collapsible SourceControlPanel per repo, so every sub-repo's changes appear on one screen. When the directory is inside a single repo, behaviour is unchanged. - backend: git::operations::discover_repos + git_discover_repos command - frontend: SourceControlMultiPanel wraps the single-repo panel per discovered repo; discovery re-runs on every scanPath change so tab switches don't leave the panel stuck on a closed tab's single repo Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughAdds a Rust ChangesMulti-repo git discovery
Estimated code review effort: 3 (Moderate) | ~25 minutes Related Issues: No linked issues provided. Related PRs: No related PRs provided. Suggested labels: feature, source-control, tauri, needs-review Suggested reviewers: No reviewer information available. 🐇 A repo hops here, a repo hops there, 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment Warning |
CONTRIBUTING requires a test for git command-layer changes. Locks the discover_repos contract: a non-repo container returns its child repos, a cwd inside a repo returns just that repo (incl. from a nested subdir), and an unauthorized path is rejected rather than scanned.
|
Marked ready. Added the required git command-layer test (a257986): Still happy to close/fold into #639 if that's the preferred direction — the open question remains "show all sub-repos at once" (this PR) vs. the repo-switcher dropdown (#639). |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
src/modules/source-control/SourceControlMultiPanel.tsx (1)
21-26: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winPer-repo poller cost and re-render fan-out both scale unbounded with repo count
Two compounding concerns here as the number of discovered repos grows:
RepoSectioncallsuseSourceControl(repoRoot, true)with the enabled flag hard-codedtrueeven when the section is collapsed (comment on line 77 says this is deliberate, to keep the changed-count badge live). That means every discovered repo keeps its own active status polling/watching alive indefinitely, not just the expanded one — N repos means N times the background IPC/git-status cost, all the time, even for repos nobody is looking at.{...shared}(line 26) destructures a fresh object every render ofSourceControlMultiPanel. SinceRepoSectionis wrapped inmemo, butsharedis a new reference on every parent render, any unrelated re-render of the parent (e.g. the ambientsourceControlprop changing identity on a routine status poll, even though it isn't used in the multi-root branch) invalidates memoization for everyRepoSectionat once.Neither breaks functionality, but both work against the "ultra-lightweight" bar for workspaces with more than a couple of sub-repos. Wrapping
sharedinuseMemois a cheap partial fix for (2); (1) would need either a smaller poll interval when collapsed or lazy-mount for the badge count.As per coding guidelines, "for every change ask: how much RAM it costs... whether it triggers extra re-renders or wasted work."
Also applies to: 69-119
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/modules/source-control/SourceControlMultiPanel.tsx` around lines 21 - 26, The multi-repo branch in SourceControlMultiPanel is causing unnecessary re-renders because the spread props object from {...shared} is recreated on every render. Memoize the shared props before passing them into RepoSection so its memoization can hold across parent updates. Also review RepoSection’s useSourceControl(repoRoot, true) behavior so collapsed repos do not keep full polling/watching alive for every discovered repo; consider a lighter background strategy or lazy activation for the badge count.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src-tauri/src/modules/git/operations.rs`:
- Around line 111-137: The child repository discovery flow in the git operations
logic can authorize a symlink-resolved root outside the current workspace. In
the loop that builds `roots` and calls `registry.authorize` after
`git_stdout_line_opt` and `canonical_dir`, add a containment/prefix check that
verifies `canonical_root.local_path` is still inside `cwd.local_path` before
authorizing or pushing `canonical_root.git_path`; if it escapes, skip that
entry.
In `@src/modules/source-control/SourceControlMultiPanel.tsx`:
- Around line 39-67: The render logic in SourceControlMultiPanel incorrectly
treats a single discovered repo the same as “no repo” because it only switches
to RepoSection when roots.length > 1. Update the branch condition so a container
path with one child repo still renders the discovered repo, using roots[0]
versus scanPath (or equivalent) to distinguish a repo root from a container
directory. Keep SourceControlPanel only for the true single-repo/current-path
case, and route discovered repos through RepoSection whenever discovery returns
at least one repo.
---
Nitpick comments:
In `@src/modules/source-control/SourceControlMultiPanel.tsx`:
- Around line 21-26: The multi-repo branch in SourceControlMultiPanel is causing
unnecessary re-renders because the spread props object from {...shared} is
recreated on every render. Memoize the shared props before passing them into
RepoSection so its memoization can hold across parent updates. Also review
RepoSection’s useSourceControl(repoRoot, true) behavior so collapsed repos do
not keep full polling/watching alive for every discovered repo; consider a
lighter background strategy or lazy activation for the badge count.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 2dee7437-3c97-4cf7-a3c8-305ae515da95
📒 Files selected for processing (8)
src-tauri/src/lib.rssrc-tauri/src/modules/git/commands.rssrc-tauri/src/modules/git/operations.rssrc/app/App.tsxsrc/modules/ai/lib/native.tssrc/modules/source-control/SourceControlMultiPanel.tsxsrc/modules/source-control/SourceControlPanelLazy.tsxsrc/modules/source-control/useSourceControlContext.ts
| // Otherwise scan immediate children; each holding a `.git` (dir or worktree file) | ||
| // is its own repository. | ||
| // ponytail: one level deep, local FS only — matches the worktree workspace layout; | ||
| // recurse or honor a config depth if a deeper container ever shows up. | ||
| let mut roots: Vec<String> = Vec::new(); | ||
| for entry in std::fs::read_dir(&cwd.local_path) | ||
| .map_err(GitError::Io)? | ||
| .flatten() | ||
| { | ||
| let path = entry.path(); | ||
| if !path.is_dir() || !path.join(".git").exists() { | ||
| continue; | ||
| } | ||
| let Some(child_str) = path.to_str() else { | ||
| continue; | ||
| }; | ||
| let child = canonical_dir(registry, child_str, &cwd.workspace)?; | ||
| if let Some(root_line) = git_stdout_line_opt( | ||
| &child.workspace, | ||
| &child.git_path, | ||
| ["rev-parse", "--show-toplevel"], | ||
| )? { | ||
| let canonical_root = canonical_dir(registry, &root_line, &child.workspace)?; | ||
| let _ = registry.authorize(&canonical_root.local_path); | ||
| roots.push(canonical_root.git_path); | ||
| } | ||
| } |
There was a problem hiding this comment.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -n -A15 'fn canonical_dir' src-tauri/src/modules/git
rg -n -A15 'fn authorize' src-tauri/src/modules/workspace
rg -n -A10 'fn is_authorized' src-tauri/src/modules/workspaceRepository: crynta/terax-ai
Length of output: 1419
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo '--- outline: git utils ---'
ast-grep outline src-tauri/src/modules/git/utils.rs --view expanded || true
echo '--- locate workspace registry files ---'
fd -a 'workspace*.rs' src-tauri/src/modules src-tauri/src || true
fd -a 'registry*.rs' src-tauri/src/modules src-tauri/src || true
fd -a '*workspace*' src-tauri/src/modules src-tauri/src || true
echo '--- search for canonicalize_cached / authorize / is_authorized ---'
rg -n -A12 -B6 'canonicalize_cached|fn authorize|is_authorized|authorize\(' src-tauri/src/modules src-tauri/src --glob '!**/target/**'
echo '--- read git utils ---'
sed -n '1,220p' src-tauri/src/modules/git/utils.rsRepository: crynta/terax-ai
Length of output: 50371
Guard child repo authorization against symlink escapes src-tauri/src/modules/git/operations.rs:111-137
registry.authorize(&canonical_root.local_path) is reached without a containment check, so a symlinked child repo can resolve outside the authorized workspace and get added as a session-wide git root. Add a prefix check against cwd.local_path before authorizing.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src-tauri/src/modules/git/operations.rs` around lines 111 - 137, The child
repository discovery flow in the git operations logic can authorize a
symlink-resolved root outside the current workspace. In the loop that builds
`roots` and calls `registry.authorize` after `git_stdout_line_opt` and
`canonical_dir`, add a containment/prefix check that verifies
`canonical_root.local_path` is still inside `cwd.local_path` before authorizing
or pushing `canonical_root.git_path`; if it escapes, skip that entry.
| native | ||
| .gitDiscoverRepos(scanPath) | ||
| .then((r) => { | ||
| if (!cancelled) setRoots(r); | ||
| }) | ||
| .catch(() => { | ||
| if (!cancelled) setRoots([]); | ||
| }); | ||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, [scanPath]); | ||
|
|
||
| // Multiple sibling repos under the directory → one collapsible section each. | ||
| if (roots && roots.length > 1) { | ||
| return ( | ||
| <div className="flex h-full min-w-0 flex-col overflow-y-auto bg-card/80 backdrop-blur"> | ||
| {roots.map((root) => ( | ||
| <RepoSection key={root} repoRoot={root} shared={shared} /> | ||
| ))} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| // 0 or 1 repo, or discovery still loading → original single-repo panel. | ||
| return ( | ||
| <SourceControlPanel open={open} sourceControl={sourceControl} {...shared} /> | ||
| ); | ||
| }); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Single-child-repo containers silently render "no repository" instead of the found repo
roots.length > 1 is used as a proxy for "cwd is itself a repo, so the ambient sourceControl prop already applies." That equivalence breaks when scanPath is a container with exactly one child repo: discover_repos returns a 1-element array in that case too, but sourceControl here is the ambient state computed for the container path (not a repo), so it will report hasRepo: false. The code falls into the same branch as the true single-repo case (line 65) and shows the "no repository" UI instead of the one repo that was actually discovered.
Comparing roots[0] against scanPath (or just always rendering via RepoSection when roots.length >= 1) would distinguish "cwd is a repo" from "container with one child" and let the latter render correctly.
🐛 Sketch of a fix
- // Multiple sibling repos under the directory → one collapsible section each.
- if (roots && roots.length > 1) {
+ // roots[0] !== scanPath means scanPath is a container whose single child repo
+ // needs its own SourceControl instance, not the ambient (container-scoped) one.
+ if (roots && roots.length > 1) {
return (
<div className="flex h-full min-w-0 flex-col overflow-y-auto bg-card/80 backdrop-blur">
{roots.map((root) => (
<RepoSection key={root} repoRoot={root} shared={shared} />
))}
</div>
);
}
+ if (roots && roots.length === 1 && roots[0] !== scanPath) {
+ return (
+ <div className="flex h-full min-w-0 flex-col overflow-y-auto bg-card/80 backdrop-blur">
+ <RepoSection repoRoot={roots[0]} shared={shared} />
+ </div>
+ );
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| native | |
| .gitDiscoverRepos(scanPath) | |
| .then((r) => { | |
| if (!cancelled) setRoots(r); | |
| }) | |
| .catch(() => { | |
| if (!cancelled) setRoots([]); | |
| }); | |
| return () => { | |
| cancelled = true; | |
| }; | |
| }, [scanPath]); | |
| // Multiple sibling repos under the directory → one collapsible section each. | |
| if (roots && roots.length > 1) { | |
| return ( | |
| <div className="flex h-full min-w-0 flex-col overflow-y-auto bg-card/80 backdrop-blur"> | |
| {roots.map((root) => ( | |
| <RepoSection key={root} repoRoot={root} shared={shared} /> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| // 0 or 1 repo, or discovery still loading → original single-repo panel. | |
| return ( | |
| <SourceControlPanel open={open} sourceControl={sourceControl} {...shared} /> | |
| ); | |
| }); | |
| native | |
| .gitDiscoverRepos(scanPath) | |
| .then((r) => { | |
| if (!cancelled) setRoots(r); | |
| }) | |
| .catch(() => { | |
| if (!cancelled) setRoots([]); | |
| }); | |
| return () => { | |
| cancelled = true; | |
| }; | |
| }, [scanPath]); | |
| // roots[0] !== scanPath means scanPath is a container whose single child repo | |
| // needs its own SourceControl instance, not the ambient (container-scoped) one. | |
| if (roots && roots.length > 1) { | |
| return ( | |
| <div className="flex h-full min-w-0 flex-col overflow-y-auto bg-card/80 backdrop-blur"> | |
| {roots.map((root) => ( | |
| <RepoSection key={root} repoRoot={root} shared={shared} /> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| if (roots && roots.length === 1 && roots[0] !== scanPath) { | |
| return ( | |
| <div className="flex h-full min-w-0 flex-col overflow-y-auto bg-card/80 backdrop-blur"> | |
| <RepoSection repoRoot={roots[0]} shared={shared} /> | |
| </div> | |
| ); | |
| } | |
| // 0 or 1 repo, or discovery still loading → original single-repo panel. | |
| return ( | |
| <SourceControlPanel open={open} sourceControl={sourceControl} {...shared} /> | |
| ); | |
| }); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/modules/source-control/SourceControlMultiPanel.tsx` around lines 39 - 67,
The render logic in SourceControlMultiPanel incorrectly treats a single
discovered repo the same as “no repo” because it only switches to RepoSection
when roots.length > 1. Update the branch condition so a container path with one
child repo still renders the discovered repo, using roots[0] versus scanPath (or
equivalent) to distinguish a repo root from a container directory. Keep
SourceControlPanel only for the true single-repo/current-path case, and route
discovered repos through RepoSection whenever discovery returns at least one
repo.
What
When the active workspace directory is not itself a git repo but contains child repositories (e.g. a git-worktree workspace holding several repos), render one collapsible Source Control section per child repo, all on one screen — instead of showing "No repository".
Why
Multi-repo / worktree workspaces open at a container directory that has no
.gitof its own, so the panel shows "No repository" even though every subfolder is a repo. This surfaces all of them at once.Relationship to #639
#639 also adds multi-repo support, with a repo-switcher dropdown (one repo visible at a time, auto-switching on editor focus) and a depth-3 recursive scan. This PR takes a different UX: all sub-repos shown simultaneously as stacked collapsible sections (VS Code multi-root style), with a one-level scan of the container. Different enough that I wanted to show it; if #639 is the chosen direction I'll close this.
How
git::operations::discover_repos+git_discover_reposcommand: if cwd is inside a repo → that single repo; else scan immediate children for.git(handles worktree.gitfiles) → sorted repo roots. Authorization reuses the existing prefix-basedis_authorized.SourceControlMultiPanelwraps the existing single-repoSourceControlPanel, rendering one per discovered repo. Discovery re-runs on everyscanPathchange so tab switches don't leave the panel stuck on a closed tab's single repo. Single-repo behavior is unchanged.Testing
pnpm exec tsc --noEmitcleancargo testfor the git command layer — not yet added. CONTRIBUTING requires a test for changes to the git command layer (repo-root resolution / discovery). I'd add one fordiscover_repos(container-with-children, cwd-inside-repo, deny path) before this leaves draft.Screenshots / GIFs
Container workspace: Source Control shows one collapsible section per sub-repo (account / community / …), each with its own file list and actions.
Notes for reviewer
Main open questions: (1) is the "show all at once" UX wanted at all vs. #639's switcher; (2) one-level scan vs. #639's depth-3. Will add the required Rust test once there's a signal this direction is worth pursuing — didn't want to invest the test before confirming it's not just duplicating #639.
Summary by CodeRabbit
New Features
Bug Fixes