Skip to content

feat(desktop): add nested git repository support for monorepos#1139

Closed
gbourne1 wants to merge 1 commit into
superset-sh:mainfrom
gbourne1:feat/nested-git-repos
Closed

feat(desktop): add nested git repository support for monorepos#1139
gbourne1 wants to merge 1 commit into
superset-sh:mainfrom
gbourne1:feat/nested-git-repos

Conversation

@gbourne1
Copy link
Copy Markdown

@gbourne1 gbourne1 commented Feb 2, 2026

Summary

Adds support for nested git repositories in the Source Control panel, enabling monorepo workflows where subdirectories contain separate .git directories.

Closes #1003

Screenshot

Screenshot 2026-02-02 at 11 23 02 AM

What Changed

Backend

  • Auto-detection: Scans for nested .git directories (depth limit: 5, excludes node_modules, vendor, dist, etc.)
  • Multi-repo status: New getMultiRepoStatus procedure returns status for all detected repos
  • Security: Added assertValidNestedRepo validation and secureFs nested-repo-aware methods
  • Git operations: All staging, commit, push, pull operations now accept optional repoPath

Frontend

  • Multi-repo view: Shows collapsible sections per repository with change counts
  • Per-repo commits: Each nested repo has its own commit input and push/pull controls
  • Diff viewer: Files from nested repos now display diffs correctly via repoPath threading

New Files

  • src/lib/trpc/routers/changes/utils/nested-repos.ts - Auto-detection logic with caching
  • src/renderer/.../RepoSection/RepoSection.tsx - Per-repo UI section component

How to Test

  1. Create a test monorepo:

    mkdir -p /tmp/test-monorepo/nested-repo
    cd /tmp/test-monorepo && git init && echo "root" > root.txt && git add . && git commit -m "init"
    cd nested-repo && git init && echo "nested" > nested.txt && git add . && git commit -m "init nested"
    # Make changes in both
    echo "change" >> /tmp/test-monorepo/root.txt
    echo "change" >> /tmp/test-monorepo/nested-repo/nested.txt
  2. Open /tmp/test-monorepo in Superset

  3. Verify: Both repos appear in Source Control panel with collapsible headers

  4. Test: Stage/unstage files in each repo independently

  5. Test: Click a file in nested repo → diff should display correctly

  6. Test: Commit to nested repo → commit goes to correct repo

  7. Test: Push/pull works independently per repo

Technical Notes

  • Detection results are cached for 30 seconds to minimize filesystem scanning
  • Security validation ensures nested repo paths cannot escape the parent worktree
  • Single-repo workspaces render the same UI as before (backward compatible)

Limitations (Future Work)

  • "Against base" and "Commits" sections only shown for root repo currently
  • No submodule-specific handling (treated as regular nested repos)

Summary by CodeRabbit

  • New Features
    • Added support for nested repositories: perform Git operations, view file history, and manage changes across multiple repositories within a single workspace.
    • Multi-repository status view displaying aggregated changes and per-repository breakdowns.
    • Repository-aware file operations enabling targeted edits and interactions within nested repositories.

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
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 2, 2026

📝 Walkthrough

Walkthrough

This pull request extends Superset with nested Git repository support for monorepos by introducing optional repoPath parameters across backend routers, git operations, and file handling. It adds security validation for nested paths, implements multi-repo status aggregation, detects nested repositories within worktrees, and updates the UI to display and manage changes across multiple repositories within a single workspace.

Changes

Cohort / File(s) Summary
Security & Path Validation
apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts, apps/desktop/src/lib/trpc/routers/changes/security/index.ts
Added assertValidNestedRepo() function to validate nested repo paths against worktree boundaries, preventing path traversal, symlink escape, and ensuring .git directory presence. New validation enforces security constraints before operations in nested repos.
Nested-Repo File Operations
apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts
Introduced five new nested-repo-aware file operations: readFileInNestedRepo, readFileBufferInNestedRepo, writeFileInNestedRepo, deleteInNestedRepo, statInNestedRepo. Each validates nested path bounds and operates within target repository context.
Git Command Functions
apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts
Extended 11 git command helpers (branch switching, checkout, stage/unstage, discard, stash operations) with optional repoPath parameter. Each resolves target path and routes git operations accordingly, maintaining existing semantics.
Backend Routers — File & Git Operations
apps/desktop/src/lib/trpc/routers/changes/file-contents.ts, apps/desktop/src/lib/trpc/routers/changes/git-operations.ts
Added optional repoPath parameter to public input schemas for file/git procedures. Refactored internal functions (getFileVersions, getUnstagedVersions) to use parameter objects and propagate repoPath through call chains; routes operations to computed target paths.
Backend Routers — Staging & Status
apps/desktop/src/lib/trpc/routers/changes/staging.ts, apps/desktop/src/lib/trpc/routers/changes/status.ts
Extended staging procedures with repoPath parameter and wired to nested-repo-aware git commands. Added new getMultiRepoStatus endpoint and refactored getStatus to accept optional repoPath, enabling per-repo and aggregate status queries across nested repositories.
Nested Repository Detection
apps/desktop/src/lib/trpc/routers/changes/utils/nested-repos.ts
Added utility module with BFS-based nested repo detection (depth limit 5), caching with 30-second TTL, and display-name generation. Exports detectNestedRepos(), getRepoDisplayName(), and clearNestedReposCache() for discovering and managing nested repos.
Shared Type Definitions
apps/desktop/src/shared/changes-types.ts, apps/desktop/src/shared/tabs-types.ts
Added NestedRepoStatus and MultiRepoGitChangesStatus interfaces to support multi-repo status reporting. Extended FileViewerState with optional repoPath for file viewer context in multi-repo scenarios.
Renderer Stores
apps/desktop/src/renderer/stores/tabs/types.ts, apps/desktop/src/renderer/stores/tabs/utils.ts, apps/desktop/src/renderer/stores/tabs/store.ts, apps/desktop/src/renderer/stores/changes/store.ts
Added repoPath to file viewer pane options and state. Introduced expandedRepos tracking in changes store with toggleRepoExpanded and isRepoExpanded actions to manage per-repo UI expansion state.
Renderer Hooks
apps/desktop/src/renderer/screens/main/components/.../useFileContent/useFileContent.ts, apps/desktop/src/renderer/screens/main/components/.../useFileSave/useFileSave.ts, apps/desktop/src/renderer/screens/main/components/.../FileViewerPane.tsx
Extended file content and save hooks to accept and propagate optional repoPath parameter through backend queries and mutations. File viewer pane extracts repoPath from file context and passes to hooks.
Renderer UI Components — Multi-Repo Changes View
apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx, apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/CommitInput.tsx, apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/RepoSection/RepoSection.tsx, apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/RepoSection/index.ts
Refactored ChangesView to consume multiRepoStatus instead of single status, deriving per-repo and total counts. Added new RepoSection component to display collapsible per-repo change panels with category sections and action buttons. Extended onFileOpen callback signature with optional repoPath. CommitInput now threads repoPath through all mutation calls.
Renderer UI Integration
apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx
Updated handleFileOpenPane callback to accept and propagate optional repoPath when adding file viewer panes, enabling nested repository context for file viewing.

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
Loading
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
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly Related PRs

Poem

🐰 Hoppity-hop through nested repos we go!
Monorepos now show all changes below,
From root to each nook with security in tow,
Multi-repo magic makes workflows just flow!

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat(desktop): add nested git repository support for monorepos' clearly and concisely describes the main change—adding nested git repository support for monorepo workflows.
Description check ✅ Passed The pull request description is comprehensive and well-structured. It includes a clear summary, implementation details for backend and frontend changes, new files, testing instructions, technical notes, limitations, and a screenshot.
Linked Issues check ✅ Passed The PR implements the core requirements from issue #1003: auto-detection of nested git repositories, multi-repo status aggregation, security validation, per-repo git operations, and a multi-repo UI with collapsible sections.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing nested git repository support. File modifications, new utilities, UI components, and API extensions are all necessary for the monorepo feature and aligned with the PR objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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 and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

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 | 🟡 Minor

Don’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] message for 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's path.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 deleteInNestedRepo method largely duplicates the logic from delete (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 delete and deleteInNestedRepo could 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, and gitStashPop.

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.

♻️ 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,
+});
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/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, and handleCommitFileSelect now 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.

Comment on lines +36 to +96
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
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

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);
 	}
 }
As per coding guidelines, Never swallow errors silently; at minimum log them with context.
📝 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
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.

Comment on lines 335 to +450
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>
)}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

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.

@gbourne1 gbourne1 closed this by deleting the head repository Apr 8, 2026
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.

Feature: Support nested git repositories in Source Control panel for monorepos

1 participant