Skip to content

feat(source-control): show all sub-repos when the workspace root is a container#922

Open
wang-mingxue wants to merge 2 commits into
crynta:mainfrom
wang-mingxue:feat/source-control-multi-repo
Open

feat(source-control): show all sub-repos when the workspace root is a container#922
wang-mingxue wants to merge 2 commits into
crynta:mainfrom
wang-mingxue:feat/source-control-multi-repo

Conversation

@wang-mingxue

@wang-mingxue wang-mingxue commented Jul 2, 2026

Copy link
Copy Markdown

Draft — overlaps with #639. Opening for discussion, not asking for a merge over that PR. See "Relationship to #639" below; happy to close this and fold the idea into #639 if you prefer.

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 .git of 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

  • Backend git::operations::discover_repos + git_discover_repos command: if cwd is inside a repo → that single repo; else scan immediate children for .git (handles worktree .git files) → sorted repo roots. Authorization reuses the existing prefix-based is_authorized.
  • Frontend SourceControlMultiPanel wraps the existing single-repo SourceControlPanel, rendering one 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. Single-repo behavior is unchanged.

Testing

  • pnpm exec tsc --noEmit clean
  • Manual smoke-test: opened a worktree workspace containing 4 repos → 4 collapsible sections, each with its own changes/stage/commit/push; opening inside a single repo → unchanged single panel.
  • cargo test for 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 for discover_repos (container-with-children, cwd-inside-repo, deny path) before this leaves draft.
  • Platforms tested: macOS

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

    • Source control can now detect multiple Git repositories inside a selected folder and show them as separate sections.
    • The source control view now uses the current scan location to surface repository roots more accurately.
  • Bug Fixes

    • Improved repository detection for nested folders and repo worktrees.
    • Unauthorized locations are now rejected more reliably when scanning for repositories.

… 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>
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a Rust discover_repos operation and git_discover_repos Tauri command to find git repo roots under a directory, exposes it via a new native.gitDiscoverRepos binding, and introduces SourceControlMultiPanel/RepoSection to render per-repo panels, wired through App.tsx and context via a new sourceControlPath.

Changes

Multi-repo git discovery

Layer / File(s) Summary
Backend discover_repos operation and command
src-tauri/src/modules/git/operations.rs, src-tauri/src/modules/git/commands.rs, src-tauri/src/lib.rs
Adds discover_repos with workspace authorization and git rev-parse checks to resolve single-repo or multi-child scanning, a git_discover_repos command wrapper, handler registration, and new tests.
Frontend binding and multi-panel UI
src/modules/ai/lib/native.ts, src/modules/source-control/SourceControlMultiPanel.tsx, src/modules/source-control/SourceControlPanelLazy.tsx
Adds native.gitDiscoverRepos, new SourceControlMultiPanel/RepoSection components that discover and render per-repo panels, and switches the lazy import to load the multi-panel.
App and context wiring
src/modules/source-control/useSourceControlContext.ts, src/app/App.tsx
Context now returns sourceControlPath, which App.tsx passes as scanPath to SourceControlPanel.

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,
Discovery scans the terminal air,
One panel splits to many panes,
Git roots found through winding lanes,
Hop on, reviewer — the burrow's laid bare.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title follows Conventional Commits and accurately summarizes the new multi-repo source-control behavior.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Warning

⚠️ This pull request shows signs of AI-generated slop (defensive_cruft, description_diff_mismatch). It has been flagged by CodeRabbit slop detection and should be reviewed carefully.

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.
@wang-mingxue wang-mingxue marked this pull request as ready for review July 2, 2026 09:50
@wang-mingxue wang-mingxue requested a review from crynta as a code owner July 2, 2026 09:50
@wang-mingxue

Copy link
Copy Markdown
Author

Marked ready. Added the required git command-layer test (a257986): discover_repos now has unit coverage for a non-repo container returning its child repos, a cwd inside a repo returning just that repo (including from a nested subdir), and an unauthorized path being rejected. cargo test --lib discover_repos and cargo clippy --lib -- -D warnings are clean.

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).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/modules/source-control/SourceControlMultiPanel.tsx (1)

21-26: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Per-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:

  1. RepoSection calls useSourceControl(repoRoot, true) with the enabled flag hard-coded true even 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.
  2. {...shared} (line 26) destructures a fresh object every render of SourceControlMultiPanel. Since RepoSection is wrapped in memo, but shared is a new reference on every parent render, any unrelated re-render of the parent (e.g. the ambient sourceControl prop changing identity on a routine status poll, even though it isn't used in the multi-root branch) invalidates memoization for every RepoSection at once.

Neither breaks functionality, but both work against the "ultra-lightweight" bar for workspaces with more than a couple of sub-repos. Wrapping shared in useMemo is 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

📥 Commits

Reviewing files that changed from the base of the PR and between 1c7f3e4 and a257986.

📒 Files selected for processing (8)
  • src-tauri/src/lib.rs
  • src-tauri/src/modules/git/commands.rs
  • src-tauri/src/modules/git/operations.rs
  • src/app/App.tsx
  • src/modules/ai/lib/native.ts
  • src/modules/source-control/SourceControlMultiPanel.tsx
  • src/modules/source-control/SourceControlPanelLazy.tsx
  • src/modules/source-control/useSourceControlContext.ts

Comment on lines +111 to +137
// 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);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 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/workspace

Repository: 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.rs

Repository: 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.

Comment on lines +39 to +67
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} />
);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Suggested change
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.

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