feat(desktop): add nested git repository support for monorepos#1139
feat(desktop): add nested git repository support for monorepos#1139gbourne1 wants to merge 1 commit into
Conversation
Add support for nested git repositories in the Source Control panel, enabling monorepo workflows where subdirectories contain separate .git directories. Backend: - Auto-detect nested .git directories (depth limit: 5, excludes node_modules) - Add getMultiRepoStatus procedure returning status for all detected repos - Add assertValidNestedRepo security validation - Add secureFs nested-repo-aware methods (statInNestedRepo, readFileInNestedRepo, etc.) - All git operations (stage, unstage, commit, push, pull) accept optional repoPath Frontend: - Multi-repo view with collapsible sections per repository - Per-repo commit input with independent push/pull controls - Thread repoPath through file viewer for correct diff display - Add RepoSection component for nested repo UI Security: - Validate nested repo paths are within worktree bounds - Prevent symlink escape attacks via nested repos - Path normalization for reliable comparison
📝 WalkthroughWalkthroughThis pull request extends Superset with nested Git repository support for monorepos by introducing optional Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant UI as FileViewerPane UI
participant Hook as useFileContent Hook
participant Store as Changes Store
participant Router as Backend Router<br/>(file-contents)
participant Validate as Path Validation
participant SecureFS as SecureFS
participant Git as Git Ops
User->>UI: Opens file in nested repo
UI->>Store: Extract repoPath from context
UI->>Hook: Call useFileContent({worktreePath,<br/>filePath, repoPath})
Hook->>Router: Query getFileContents<br/>({worktreePath, filePath, repoPath})
Router->>Validate: Validate nested repo path<br/>(assertValidNestedRepo)
Validate-->>Router: Path valid ✓
Router->>SecureFS: Read file using<br/>readFileInNestedRepo<br/>(worktreePath, repoPath, filePath)
SecureFS->>SecureFS: Verify boundaries &<br/>security checks
SecureFS->>Git: Fetch file versions<br/>from nested repo
Git-->>SecureFS: File versions
SecureFS-->>Router: File content
Router-->>Hook: Return content
Hook-->>UI: Render file content
sequenceDiagram
participant UI as ChangesView UI
participant Query as Backend Query
participant Detector as Nested Repo<br/>Detector
participant Validator as Path Validator
participant Git as Git Status
UI->>Query: Request getMultiRepoStatus<br/>({worktreePath})
Query->>Detector: Detect nested repos<br/>(detectNestedRepos)
Detector->>Detector: BFS traverse with<br/>depth limit (5)
Detector-->>Query: Array of repo paths
Query->>Validator: For each repo:<br/>assertValidNestedRepo()
Validator-->>Query: Validation results
Query->>Git: Fetch status for each repo<br/>(in parallel)
Git-->>Query: Per-repo git status
Query->>Query: Aggregate results:<br/>- Per-repo counts<br/>- Total counts<br/>- Root indicator
Query-->>UI: MultiRepoGitChangesStatus
UI->>UI: Render per-repo<br/>RepoSection components
Estimated Code Review Effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly Related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
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 |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/desktop/src/lib/trpc/routers/changes/status.ts (1)
264-295:⚠️ Potential issue | 🟡 MinorDon’t swallow nested repo read errors silently.
The catch currently drops failures; please log with context so we can trace validation/read issues in nested repos. As per coding guidelines, Never swallow errors silently; at minimum log them with context, and use prefixed console logging with pattern[domain/operation] messagefor all logging.🪵 Suggested logging
- } catch { - // Skip files that fail validation or reading - } + } catch (error) { + console.error("[changes/applyUntrackedLineCount] Failed to count lines", { + filePath: file.path, + error, + }); + }
🤖 Fix all issues with AI agents
In `@apps/desktop/src/lib/trpc/routers/changes/utils/nested-repos.ts`:
- Around line 36-96: The current scan functions (isGitRepo and
findNestedReposRecursive) swallow filesystem errors silently; update their catch
blocks to log unexpected errors with context (include symbols like isGitRepo,
findNestedReposRecursive, currentPath, entryPath, and depth) while still
ignoring expected ENOENT/ENOENT-like cases. Specifically, in isGitRepo's catch,
detect and ignore ENOENT but log other errors with a clear prefix and the
gitPath; in the lstat catch inside findNestedReposRecursive, ignore ENOENT but
log other errors including entryPath; and in the outer try/catch around readdir,
log unexpected errors with a prefix and basePath/currentPath/depth info (use the
existing MAX_SCAN_DEPTH and EXCLUDED_DIRS names for context). Use the project
logger (or console.warn/error if no logger is in scope) — do not rethrow, just
log.
In
`@apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx`:
- Around line 335-450: hasChanges only looks at multiRepoStatus totals and
rootRepo commits, so nested repos with no working-tree changes but with commits
or push/pull activity get hidden; update the hasChanges computation to iterate
over repos (the repos array) and return true if any repo has
staged/untracked/unstaged files, commits (repo.commits), againstBase entries
(repo.againstBase), or push/pull counts (repo.pushCount or repo.pullCount), or
alternatively always render RepoSection for each repo; adjust the logic near the
hasChanges declaration to reference repos and ensure RepoSection/CommitInput
still render for nested repos with remote/commit activity.
🧹 Nitpick comments (6)
apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts (1)
225-231: Minor: Redundant check for${sep}..prefix.The check
relativePath.startsWith(${sep}..)on line 226 appears redundant. Node'spath.relative()returns paths without leading separators - it would return..or../foo, not/../foo. The first condition (relativePath.startsWith("..")) already covers the traversal case.This doesn't cause bugs (it's just dead code), but could be simplified for clarity.
♻️ Suggested simplification
// Reject if relative path starts with .. (escapes worktree) - if (relativePath.startsWith("..") || relativePath.startsWith(`${sep}..`)) { + if (relativePath.startsWith("..")) { throw new PathValidationError( "Nested repo path escapes worktree boundary", "PATH_TRAVERSAL",apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts (1)
550-582: Consider extracting shared delete logic to reduce duplication.The
deleteInNestedRepomethod largely duplicates the logic fromdelete(lines 373-410). While the current implementation is correct and clear, consider extracting shared logic if this pattern continues to grow.♻️ Optional: Extract shared delete implementation
// Internal helper for delete logic after validation async function deleteValidatedPath( worktreePath: string, fullPath: string, ): Promise<void> { let stats: Stats; try { stats = await lstat(fullPath); } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") { return; } throw error; } if (stats.isSymbolicLink()) { await rm(fullPath); return; } await assertRealpathInWorktree(worktreePath, fullPath); await rm(fullPath, { recursive: true, force: true }); }Then both
deleteanddeleteInNestedRepocould call this helper after their respective validation.apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts (1)
122-131: Consider using named parameters for functions with 2+ arguments.Per coding guidelines, functions with 2+ parameters should accept a single params object. While
gitStageAll(worktreePath, repoPath?)is simple, consistency with the guideline would improve maintainability as the API grows.♻️ Optional: Named parameters pattern
-export async function gitStageAll( - worktreePath: string, - repoPath?: string, -): Promise<void> { +interface GitStageAllParams { + worktreePath: string; + repoPath?: string; +} + +export async function gitStageAll({ + worktreePath, + repoPath, +}: GitStageAllParams): Promise<void> {This would apply similarly to
gitUnstageAll,gitDiscardAllUnstaged,gitDiscardAllStaged,gitStash,gitStashIncludeUntracked, andgitStashPop.apps/desktop/src/lib/trpc/routers/changes/git-operations.ts (1)
11-20: Refactor resolveTargetPath to use a params object.
This aligns with the project’s parameter-style guideline and keeps call sites self-documenting.As per coding guidelines, Functions with 2+ parameters should accept a single params object with named properties instead of positional arguments.♻️ Proposed refactor
-/** - * Resolves the target path for git operations, validating nested repo if provided. - */ -function resolveTargetPath(worktreePath: string, repoPath?: string): string { - if (repoPath) { - assertValidNestedRepo(worktreePath, repoPath); - return repoPath; - } - return worktreePath; -} +/** + * Resolves the target path for git operations, validating nested repo if provided. + */ +function resolveTargetPath({ + worktreePath, + repoPath, +}: { + worktreePath: string; + repoPath?: string; +}): string { + if (repoPath) { + assertValidNestedRepo(worktreePath, repoPath); + return repoPath; + } + return worktreePath; +}Update call sites (example pattern):
-const targetPath = resolveTargetPath(input.worktreePath, input.repoPath); +const targetPath = resolveTargetPath({ + worktreePath: input.worktreePath, + repoPath: input.repoPath, +});apps/desktop/src/renderer/stores/changes/store.ts (1)
28-86: Consider refactoring selectFile to a params object.
With five optional positional args, call sites are easy to mis-order.♻️ Proposed refactor
interface ChangesState { @@ - selectFile: ( - worktreePath: string, - file: ChangedFile | null, - category?: ChangeCategory, - commitHash?: string | null, - repoPath?: string, - ) => void; + selectFile: (params: { + worktreePath: string; + file: ChangedFile | null; + category?: ChangeCategory; + commitHash?: string | null; + repoPath?: string; + }) => void; @@ - selectFile: (worktreePath, file, category, commitHash, repoPath) => { + selectFile: ({ + worktreePath, + file, + category, + commitHash, + repoPath, + }) => { const { selectedFiles } = get(); set({ selectedFiles: {Call sites will need to pass an object instead of positional args.
As per coding guidelines, Functions with 2+ parameters should accept a single params object with named properties instead of positional arguments.apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx (1)
198-297: Use a params object for the new multi-argument handlers.
handleDiscard,handleFileSelect, andhandleCommitFileSelectnow take 2–3 positional arguments; please convert to a single params object and update call sites (including RepoSection props) to reduce ambiguity. As per coding guidelines, Functions with 2+ parameters should accept a single params object with named properties instead of positional arguments.
| async function isGitRepo(dirPath: string): Promise<boolean> { | ||
| try { | ||
| const gitPath = join(dirPath, ".git"); | ||
| await access(gitPath); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Recursively find all nested git repositories within a directory. | ||
| * Uses breadth-first search with depth limiting. | ||
| */ | ||
| async function findNestedReposRecursive({ | ||
| basePath, | ||
| currentPath, | ||
| depth, | ||
| results, | ||
| }: { | ||
| basePath: string; | ||
| currentPath: string; | ||
| depth: number; | ||
| results: string[]; | ||
| }): Promise<void> { | ||
| if (depth > MAX_SCAN_DEPTH) return; | ||
|
|
||
| try { | ||
| const entries = await readdir(currentPath, { withFileTypes: true }); | ||
|
|
||
| for (const entry of entries) { | ||
| if (!entry.isDirectory()) continue; | ||
| if (EXCLUDED_DIRS.has(entry.name)) continue; | ||
|
|
||
| const entryPath = join(currentPath, entry.name); | ||
|
|
||
| // Skip if it's a symlink (security: prevent escaping worktree) | ||
| try { | ||
| const stats = await lstat(entryPath); | ||
| if (stats.isSymbolicLink()) continue; | ||
| } catch { | ||
| continue; | ||
| } | ||
|
|
||
| // Check if this directory is a git repo | ||
| if (await isGitRepo(entryPath)) { | ||
| // Don't descend into nested repos, just record them | ||
| results.push(entryPath); | ||
| } else { | ||
| // Recurse into subdirectories | ||
| await findNestedReposRecursive({ | ||
| basePath, | ||
| currentPath: entryPath, | ||
| depth: depth + 1, | ||
| results, | ||
| }); | ||
| } | ||
| } | ||
| } catch { | ||
| // Silently skip directories we can't read | ||
| } |
There was a problem hiding this comment.
Don’t swallow filesystem errors silently during scanning.
At minimum log unexpected errors with a prefix; keep expected ENOENT quiet to avoid noise.
🛠️ Suggested fix
+const LOG_PREFIX = "[git/nested-repos]";
+
+function getErrorCode(error: unknown): string | undefined {
+ if (typeof error === "object" && error && "code" in error) {
+ return String((error as { code?: string }).code);
+ }
+ return undefined;
+}
+
async function isGitRepo(dirPath: string): Promise<boolean> {
try {
const gitPath = join(dirPath, ".git");
await access(gitPath);
return true;
- } catch {
+ } catch (error) {
+ if (getErrorCode(error) !== "ENOENT") {
+ console.warn(`${LOG_PREFIX} access failed for ${dirPath}/.git`, error);
+ }
return false;
}
}
@@
- try {
+ try {
const stats = await lstat(entryPath);
if (stats.isSymbolicLink()) continue;
- } catch {
+ } catch (error) {
+ console.warn(`${LOG_PREFIX} lstat failed for ${entryPath}`, error);
continue;
}
@@
- } catch {
- // Silently skip directories we can't read
+ } catch (error) {
+ console.warn(`${LOG_PREFIX} readdir failed for ${currentPath}`, error);
}
}📝 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.
| async function isGitRepo(dirPath: string): Promise<boolean> { | |
| try { | |
| const gitPath = join(dirPath, ".git"); | |
| await access(gitPath); | |
| return true; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| /** | |
| * Recursively find all nested git repositories within a directory. | |
| * Uses breadth-first search with depth limiting. | |
| */ | |
| async function findNestedReposRecursive({ | |
| basePath, | |
| currentPath, | |
| depth, | |
| results, | |
| }: { | |
| basePath: string; | |
| currentPath: string; | |
| depth: number; | |
| results: string[]; | |
| }): Promise<void> { | |
| if (depth > MAX_SCAN_DEPTH) return; | |
| try { | |
| const entries = await readdir(currentPath, { withFileTypes: true }); | |
| for (const entry of entries) { | |
| if (!entry.isDirectory()) continue; | |
| if (EXCLUDED_DIRS.has(entry.name)) continue; | |
| const entryPath = join(currentPath, entry.name); | |
| // Skip if it's a symlink (security: prevent escaping worktree) | |
| try { | |
| const stats = await lstat(entryPath); | |
| if (stats.isSymbolicLink()) continue; | |
| } catch { | |
| continue; | |
| } | |
| // Check if this directory is a git repo | |
| if (await isGitRepo(entryPath)) { | |
| // Don't descend into nested repos, just record them | |
| results.push(entryPath); | |
| } else { | |
| // Recurse into subdirectories | |
| await findNestedReposRecursive({ | |
| basePath, | |
| currentPath: entryPath, | |
| depth: depth + 1, | |
| results, | |
| }); | |
| } | |
| } | |
| } catch { | |
| // Silently skip directories we can't read | |
| } | |
| const LOG_PREFIX = "[git/nested-repos]"; | |
| function getErrorCode(error: unknown): string | undefined { | |
| if (typeof error === "object" && error && "code" in error) { | |
| return String((error as { code?: string }).code); | |
| } | |
| return undefined; | |
| } | |
| async function isGitRepo(dirPath: string): Promise<boolean> { | |
| try { | |
| const gitPath = join(dirPath, ".git"); | |
| await access(gitPath); | |
| return true; | |
| } catch (error) { | |
| if (getErrorCode(error) !== "ENOENT") { | |
| console.warn(`${LOG_PREFIX} access failed for ${dirPath}/.git`, error); | |
| } | |
| return false; | |
| } | |
| } | |
| /** | |
| * Recursively find all nested git repositories within a directory. | |
| * Uses breadth-first search with depth limiting. | |
| */ | |
| async function findNestedReposRecursive({ | |
| basePath, | |
| currentPath, | |
| depth, | |
| results, | |
| }: { | |
| basePath: string; | |
| currentPath: string; | |
| depth: number; | |
| results: string[]; | |
| }): Promise<void> { | |
| if (depth > MAX_SCAN_DEPTH) return; | |
| try { | |
| const entries = await readdir(currentPath, { withFileTypes: true }); | |
| for (const entry of entries) { | |
| if (!entry.isDirectory()) continue; | |
| if (EXCLUDED_DIRS.has(entry.name)) continue; | |
| const entryPath = join(currentPath, entry.name); | |
| // Skip if it's a symlink (security: prevent escaping worktree) | |
| try { | |
| const stats = await lstat(entryPath); | |
| if (stats.isSymbolicLink()) continue; | |
| } catch (error) { | |
| console.warn(`${LOG_PREFIX} lstat failed for ${entryPath}`, error); | |
| continue; | |
| } | |
| // Check if this directory is a git repo | |
| if (await isGitRepo(entryPath)) { | |
| // Don't descend into nested repos, just record them | |
| results.push(entryPath); | |
| } else { | |
| // Recurse into subdirectories | |
| await findNestedReposRecursive({ | |
| basePath, | |
| currentPath: entryPath, | |
| depth: depth + 1, | |
| results, | |
| }); | |
| } | |
| } | |
| } catch (error) { | |
| console.warn(`${LOG_PREFIX} readdir failed for ${currentPath}`, error); | |
| } | |
| } |
🤖 Prompt for AI Agents
In `@apps/desktop/src/lib/trpc/routers/changes/utils/nested-repos.ts` around lines
36 - 96, The current scan functions (isGitRepo and findNestedReposRecursive)
swallow filesystem errors silently; update their catch blocks to log unexpected
errors with context (include symbols like isGitRepo, findNestedReposRecursive,
currentPath, entryPath, and depth) while still ignoring expected
ENOENT/ENOENT-like cases. Specifically, in isGitRepo's catch, detect and ignore
ENOENT but log other errors with a clear prefix and the gitPath; in the lstat
catch inside findNestedReposRecursive, ignore ENOENT but log other errors
including entryPath; and in the outer try/catch around readdir, log unexpected
errors with a prefix and basePath/currentPath/depth info (use the existing
MAX_SCAN_DEPTH and EXCLUDED_DIRS names for context). Use the project logger (or
console.warn/error if no logger is in scope) — do not rethrow, just log.
| const hasChanges = | ||
| status.againstBase.length > 0 || | ||
| status.commits.length > 0 || | ||
| status.staged.length > 0 || | ||
| status.unstaged.length > 0 || | ||
| status.untracked.length > 0; | ||
| multiRepoStatus.totalStaged > 0 || | ||
| multiRepoStatus.totalUnstaged > 0 || | ||
| multiRepoStatus.totalUntracked > 0 || | ||
| (rootRepo?.againstBase?.length ?? 0) > 0 || | ||
| (rootRepo?.commits?.length ?? 0) > 0; | ||
|
|
||
| const commitsWithFiles = status.commits.map((commit) => ({ | ||
| const commitsWithFiles = (rootRepo?.commits ?? []).map((commit) => ({ | ||
| ...commit, | ||
| files: commitFilesMap.get(commit.hash) || [], | ||
| })); | ||
|
|
||
| const hasStagedChanges = status.staged.length > 0; | ||
| const hasStagedChanges = rootRepo ? rootRepo.staged.length > 0 : false; | ||
| const hasExistingPR = !!githubStatus?.pr; | ||
| const prUrl = githubStatus?.pr?.url; | ||
|
|
||
| // Multi-repo view | ||
| if (isMultiRepo) { | ||
| return ( | ||
| <div className="flex flex-col h-full"> | ||
| <ChangesHeader | ||
| onRefresh={handleRefresh} | ||
| viewMode={fileListViewMode} | ||
| onViewModeChange={setFileListViewMode} | ||
| worktreePath={worktreePath} | ||
| workspaceId={workspaceId} | ||
| onStash={() => stashMutation.mutate({ worktreePath })} | ||
| onStashIncludeUntracked={() => | ||
| stashIncludeUntrackedMutation.mutate({ worktreePath }) | ||
| } | ||
| onStashPop={() => stashPopMutation.mutate({ worktreePath })} | ||
| isStashPending={ | ||
| stashMutation.isPending || | ||
| stashIncludeUntrackedMutation.isPending || | ||
| stashPopMutation.isPending | ||
| } | ||
| /> | ||
|
|
||
| {!hasChanges ? ( | ||
| <div className="flex-1 flex items-center justify-center text-muted-foreground text-sm px-4 text-center"> | ||
| No changes detected | ||
| </div> | ||
| ) : ( | ||
| <div className="flex-1 overflow-y-auto"> | ||
| {repos.map((repo) => ( | ||
| <RepoSection | ||
| key={repo.repoPath} | ||
| repo={repo} | ||
| worktreePath={worktreePath} | ||
| isExpanded={isRepoExpanded(repo.repoPath)} | ||
| onToggle={() => toggleRepoExpanded(repo.repoPath)} | ||
| selectedFile={selectedFile} | ||
| selectedCommitHash={selectedCommitHash} | ||
| fileListViewMode={fileListViewMode} | ||
| expandedSections={expandedSections} | ||
| onToggleSection={toggleSection} | ||
| onFileSelect={handleFileSelect} | ||
| onStageFile={(file, repoPath) => | ||
| stageFileMutation.mutate({ | ||
| worktreePath, | ||
| filePath: file.path, | ||
| repoPath, | ||
| }) | ||
| } | ||
| onUnstageFile={(file, repoPath) => | ||
| unstageFileMutation.mutate({ | ||
| worktreePath, | ||
| filePath: file.path, | ||
| repoPath, | ||
| }) | ||
| } | ||
| onDiscard={handleDiscard} | ||
| onStageAll={(repoPath) => | ||
| stageAllMutation.mutate({ worktreePath, repoPath }) | ||
| } | ||
| onUnstageAll={(repoPath) => | ||
| unstageAllMutation.mutate({ worktreePath, repoPath }) | ||
| } | ||
| onDiscardAllUnstaged={(repoPath) => { | ||
| setDiscardDialogRepoPath(repoPath); | ||
| setShowDiscardUnstagedDialog(true); | ||
| }} | ||
| onDiscardAllStaged={(repoPath) => { | ||
| setDiscardDialogRepoPath(repoPath); | ||
| setShowDiscardStagedDialog(true); | ||
| }} | ||
| isStaging={ | ||
| stageFileMutation.isPending || stageAllMutation.isPending | ||
| } | ||
| isUnstaging={ | ||
| unstageFileMutation.isPending || unstageAllMutation.isPending | ||
| } | ||
| isDiscarding={ | ||
| discardChangesMutation.isPending || | ||
| deleteUntrackedMutation.isPending || | ||
| discardAllUnstagedMutation.isPending || | ||
| discardAllStagedMutation.isPending | ||
| } | ||
| isExpandedView={isExpandedView} | ||
| commitInput={ | ||
| <CommitInput | ||
| worktreePath={worktreePath} | ||
| hasStagedChanges={repo.staged.length > 0} | ||
| pushCount={repo.pushCount} | ||
| pullCount={repo.pullCount} | ||
| hasUpstream={repo.hasUpstream} | ||
| hasExistingPR={repo.isRoot && hasExistingPR} | ||
| prUrl={repo.isRoot ? prUrl : undefined} | ||
| onRefresh={handleRefresh} | ||
| repoPath={repo.repoPath} | ||
| /> | ||
| } | ||
| /> | ||
| ))} | ||
| </div> | ||
| )} |
There was a problem hiding this comment.
Multi-repo empty state hides push/pull for nested repos.
hasChanges only considers staged/unstaged/untracked totals and root repo commits, so a nested repo that’s ahead/behind (or has commits) but no working tree changes renders “No changes detected” and omits RepoSection/CommitInput—blocking push/pull. Consider basing hasChanges on per-repo activity (including commits/against-base/push/pull) or always rendering RepoSections.
💡 Suggested fix
- const hasChanges =
- multiRepoStatus.totalStaged > 0 ||
- multiRepoStatus.totalUnstaged > 0 ||
- multiRepoStatus.totalUntracked > 0 ||
- (rootRepo?.againstBase?.length ?? 0) > 0 ||
- (rootRepo?.commits?.length ?? 0) > 0;
+ const hasChanges = repos.some(
+ (repo) =>
+ repo.staged.length > 0 ||
+ repo.unstaged.length > 0 ||
+ repo.untracked.length > 0 ||
+ repo.againstBase.length > 0 ||
+ repo.commits.length > 0 ||
+ repo.pushCount > 0 ||
+ repo.pullCount > 0,
+ );🤖 Prompt for AI Agents
In
`@apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx`
around lines 335 - 450, hasChanges only looks at multiRepoStatus totals and
rootRepo commits, so nested repos with no working-tree changes but with commits
or push/pull activity get hidden; update the hasChanges computation to iterate
over repos (the repos array) and return true if any repo has
staged/untracked/unstaged files, commits (repo.commits), againstBase entries
(repo.againstBase), or push/pull counts (repo.pushCount or repo.pullCount), or
alternatively always render RepoSection for each repo; adjust the logic near the
hasChanges declaration to reference repos and ensure RepoSection/CommitInput
still render for nested repos with remote/commit activity.
Summary
Adds support for nested git repositories in the Source Control panel, enabling monorepo workflows where subdirectories contain separate
.gitdirectories.Closes #1003
Screenshot
What Changed
Backend
.gitdirectories (depth limit: 5, excludesnode_modules,vendor,dist, etc.)getMultiRepoStatusprocedure returns status for all detected reposassertValidNestedRepovalidation andsecureFsnested-repo-aware methodsrepoPathFrontend
repoPaththreadingNew Files
src/lib/trpc/routers/changes/utils/nested-repos.ts- Auto-detection logic with cachingsrc/renderer/.../RepoSection/RepoSection.tsx- Per-repo UI section componentHow to Test
Create a test monorepo:
Open
/tmp/test-monorepoin SupersetVerify: Both repos appear in Source Control panel with collapsible headers
Test: Stage/unstage files in each repo independently
Test: Click a file in nested repo → diff should display correctly
Test: Commit to nested repo → commit goes to correct repo
Test: Push/pull works independently per repo
Technical Notes
Limitations (Future Work)
Summary by CodeRabbit