diff --git a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts index 48b01555bea..3ed4571ae66 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/file-contents.ts @@ -54,24 +54,27 @@ export const createFileContentsRouter = () => { category: z.enum(["against-base", "committed", "staged", "unstaged"]), commitHash: z.string().optional(), defaultBranch: z.string().optional(), + repoPath: z.string().optional(), }), ) .query(async ({ input }): Promise => { assertRegisteredWorktree(input.worktreePath); - const git = simpleGit(input.worktreePath); + const targetPath = input.repoPath || input.worktreePath; + const git = simpleGit(targetPath); const defaultBranch = input.defaultBranch || "main"; const originalPath = input.oldPath || input.filePath; - const { original, modified } = await getFileVersions( + const { original, modified } = await getFileVersions({ git, - input.worktreePath, - input.filePath, + worktreePath: input.worktreePath, + targetRepoPath: targetPath, + filePath: input.filePath, originalPath, - input.category, + category: input.category, defaultBranch, - input.commitHash, - ); + commitHash: input.commitHash, + }); return { original, @@ -86,11 +89,15 @@ export const createFileContentsRouter = () => { worktreePath: z.string(), filePath: z.string(), content: z.string(), + repoPath: z.string().optional(), }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await secureFs.writeFile( + const targetPath = input.repoPath || input.worktreePath; + // Use nested-repo-aware write that validates both worktree and nested repo + await secureFs.writeFileInNestedRepo( input.worktreePath, + targetPath, input.filePath, input.content, ); @@ -106,17 +113,25 @@ export const createFileContentsRouter = () => { z.object({ worktreePath: z.string(), filePath: z.string(), + repoPath: z.string().optional(), }), ) .query(async ({ input }): Promise => { try { - const stats = await secureFs.stat(input.worktreePath, input.filePath); + const targetPath = input.repoPath || input.worktreePath; + // Use nested-repo-aware methods that validate both worktree and nested repo + const stats = await secureFs.statInNestedRepo( + input.worktreePath, + targetPath, + input.filePath, + ); if (stats.size > MAX_FILE_SIZE) { return { ok: false, reason: "too-large" }; } - const buffer = await secureFs.readFileBuffer( + const buffer = await secureFs.readFileBufferInNestedRepo( input.worktreePath, + targetPath, input.filePath, ); @@ -150,15 +165,29 @@ interface FileVersions { modified: string; } -async function getFileVersions( - git: ReturnType, - worktreePath: string, - filePath: string, - originalPath: string, - category: DiffCategory, - defaultBranch: string, - commitHash?: string, -): Promise { +interface GetFileVersionsParams { + git: ReturnType; + /** The registered parent worktree (for security validation) */ + worktreePath: string; + /** The target repo path (may be nested repo or same as worktreePath) */ + targetRepoPath: string; + filePath: string; + originalPath: string; + category: DiffCategory; + defaultBranch: string; + commitHash?: string; +} + +async function getFileVersions({ + git, + worktreePath, + targetRepoPath, + filePath, + originalPath, + category, + defaultBranch, + commitHash, +}: GetFileVersionsParams): Promise { switch (category) { case "against-base": return getAgainstBaseVersions(git, filePath, originalPath, defaultBranch); @@ -173,7 +202,13 @@ async function getFileVersions( return getStagedVersions(git, filePath, originalPath); case "unstaged": - return getUnstagedVersions(git, worktreePath, filePath, originalPath); + return getUnstagedVersions({ + git, + worktreePath, + targetRepoPath, + filePath, + originalPath, + }); } } @@ -243,12 +278,23 @@ async function getStagedVersions( return { original, modified }; } -async function getUnstagedVersions( - git: ReturnType, - worktreePath: string, - filePath: string, - originalPath: string, -): Promise { +interface GetUnstagedVersionsParams { + git: ReturnType; + /** The registered parent worktree (for security validation) */ + worktreePath: string; + /** The target repo path (may be nested repo or same as worktreePath) */ + targetRepoPath: string; + filePath: string; + originalPath: string; +} + +async function getUnstagedVersions({ + git, + worktreePath, + targetRepoPath, + filePath, + originalPath, +}: GetUnstagedVersionsParams): Promise { // Try staged version first, fall back to HEAD let original = await safeGitShow(git, `:0:${originalPath}`); if (!original) { @@ -257,14 +303,29 @@ async function getUnstagedVersions( let modified = ""; try { - const stats = await secureFs.stat(worktreePath, filePath); + // Use nested-repo-aware methods for proper security validation + const stats = await secureFs.statInNestedRepo( + worktreePath, + targetRepoPath, + filePath, + ); if (stats.size <= MAX_FILE_SIZE) { - modified = await secureFs.readFile(worktreePath, filePath); + modified = await secureFs.readFileInNestedRepo( + worktreePath, + targetRepoPath, + filePath, + ); } else { modified = `[File content truncated - exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit]`; } - } catch { - // File doesn't exist or validation failed - that's ok for diff display + } catch (error) { + // Log the error to help debug + console.error("[getUnstagedVersions] Failed to read file:", { + worktreePath, + targetRepoPath, + filePath, + error: error instanceof Error ? error.message : error, + }); modified = ""; } diff --git a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts index 9ce8aa825d3..661516cd193 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts @@ -4,10 +4,21 @@ import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { isUpstreamMissingError } from "./git-utils"; -import { assertRegisteredWorktree } from "./security"; +import { assertRegisteredWorktree, assertValidNestedRepo } from "./security"; export { isUpstreamMissingError }; +/** + * 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; +} + async function hasUpstreamBranch( git: ReturnType, ): Promise { @@ -97,13 +108,18 @@ export const createGitOperationsRouter = () => { z.object({ worktreePath: z.string(), message: z.string(), + repoPath: z.string().optional(), }), ) .mutation( async ({ input }): Promise<{ success: boolean; hash: string }> => { assertRegisteredWorktree(input.worktreePath); + const targetPath = resolveTargetPath( + input.worktreePath, + input.repoPath, + ); - const git = simpleGit(input.worktreePath); + const git = simpleGit(targetPath); const result = await git.commit(input.message); return { success: true, hash: result.commit }; }, @@ -114,12 +130,17 @@ export const createGitOperationsRouter = () => { z.object({ worktreePath: z.string(), setUpstream: z.boolean().optional(), + repoPath: z.string().optional(), }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { assertRegisteredWorktree(input.worktreePath); + const targetPath = resolveTargetPath( + input.worktreePath, + input.repoPath, + ); - const git = simpleGit(input.worktreePath); + const git = simpleGit(targetPath); const hasUpstream = await hasUpstreamBranch(git); if (input.setUpstream && !hasUpstream) { @@ -147,12 +168,17 @@ export const createGitOperationsRouter = () => { .input( z.object({ worktreePath: z.string(), + repoPath: z.string().optional(), }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { assertRegisteredWorktree(input.worktreePath); + const targetPath = resolveTargetPath( + input.worktreePath, + input.repoPath, + ); - const git = simpleGit(input.worktreePath); + const git = simpleGit(targetPath); try { await git.pull(["--rebase"]); } catch (error) { @@ -172,12 +198,17 @@ export const createGitOperationsRouter = () => { .input( z.object({ worktreePath: z.string(), + repoPath: z.string().optional(), }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { assertRegisteredWorktree(input.worktreePath); + const targetPath = resolveTargetPath( + input.worktreePath, + input.repoPath, + ); - const git = simpleGit(input.worktreePath); + const git = simpleGit(targetPath); try { await git.pull(["--rebase"]); } catch (error) { @@ -197,10 +228,19 @@ export const createGitOperationsRouter = () => { }), fetch: publicProcedure - .input(z.object({ worktreePath: z.string() })) + .input( + z.object({ + worktreePath: z.string(), + repoPath: z.string().optional(), + }), + ) .mutation(async ({ input }): Promise<{ success: boolean }> => { assertRegisteredWorktree(input.worktreePath); - const git = simpleGit(input.worktreePath); + const targetPath = resolveTargetPath( + input.worktreePath, + input.repoPath, + ); + const git = simpleGit(targetPath); await fetchCurrentBranch(git); return { success: true }; }), @@ -209,13 +249,18 @@ export const createGitOperationsRouter = () => { .input( z.object({ worktreePath: z.string(), + repoPath: z.string().optional(), }), ) .mutation( async ({ input }): Promise<{ success: boolean; url: string }> => { assertRegisteredWorktree(input.worktreePath); + const targetPath = resolveTargetPath( + input.worktreePath, + input.repoPath, + ); - const git = simpleGit(input.worktreePath); + const git = simpleGit(targetPath); const branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim(); const hasUpstream = await hasUpstreamBranch(git); diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts b/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts index 567ab1a7aca..267053bb57d 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts @@ -2,8 +2,22 @@ import simpleGit from "simple-git"; import { assertRegisteredWorktree, assertValidGitPath, + assertValidNestedRepo, } from "./path-validation"; +/** + * Resolves the effective repo path for git operations. + * If repoPath is provided, validates it's within worktree bounds and returns it. + * Otherwise returns the worktreePath. + */ +function resolveRepoPath(worktreePath: string, repoPath?: string): string { + if (repoPath) { + assertValidNestedRepo(worktreePath, repoPath); + return repoPath; + } + return worktreePath; +} + /** * Git command helpers with semantic naming. * @@ -27,8 +41,10 @@ import { export async function gitSwitchBranch( worktreePath: string, branch: string, + repoPath?: string, ): Promise { assertRegisteredWorktree(worktreePath); + const targetPath = resolveRepoPath(worktreePath, repoPath); // Validate: reject anything that looks like a flag if (branch.startsWith("-")) { @@ -40,7 +56,7 @@ export async function gitSwitchBranch( throw new Error("Invalid branch name: cannot be empty"); } - const git = simpleGit(worktreePath); + const git = simpleGit(targetPath); try { // Prefer `git switch` - unambiguous branch operation (git 2.23+) @@ -68,11 +84,13 @@ export async function gitSwitchBranch( export async function gitCheckoutFile( worktreePath: string, filePath: string, + repoPath?: string, ): Promise { assertRegisteredWorktree(worktreePath); assertValidGitPath(filePath); + const targetPath = resolveRepoPath(worktreePath, repoPath); - const git = simpleGit(worktreePath); + const git = simpleGit(targetPath); // `--` is correct here - we want path semantics await git.checkout(["--", filePath]); } @@ -86,11 +104,13 @@ export async function gitCheckoutFile( export async function gitStageFile( worktreePath: string, filePath: string, + repoPath?: string, ): Promise { assertRegisteredWorktree(worktreePath); assertValidGitPath(filePath); + const targetPath = resolveRepoPath(worktreePath, repoPath); - const git = simpleGit(worktreePath); + const git = simpleGit(targetPath); await git.add(["--", filePath]); } @@ -99,10 +119,14 @@ export async function gitStageFile( * * Uses `git add -A` to stage all changes (new, modified, deleted). */ -export async function gitStageAll(worktreePath: string): Promise { +export async function gitStageAll( + worktreePath: string, + repoPath?: string, +): Promise { assertRegisteredWorktree(worktreePath); + const targetPath = resolveRepoPath(worktreePath, repoPath); - const git = simpleGit(worktreePath); + const git = simpleGit(targetPath); await git.add("-A"); } @@ -115,11 +139,13 @@ export async function gitStageAll(worktreePath: string): Promise { export async function gitUnstageFile( worktreePath: string, filePath: string, + repoPath?: string, ): Promise { assertRegisteredWorktree(worktreePath); assertValidGitPath(filePath); + const targetPath = resolveRepoPath(worktreePath, repoPath); - const git = simpleGit(worktreePath); + const git = simpleGit(targetPath); await git.reset(["HEAD", "--", filePath]); } @@ -129,10 +155,14 @@ export async function gitUnstageFile( * Uses `git reset HEAD` to unstage all changes without * discarding them. */ -export async function gitUnstageAll(worktreePath: string): Promise { +export async function gitUnstageAll( + worktreePath: string, + repoPath?: string, +): Promise { assertRegisteredWorktree(worktreePath); + const targetPath = resolveRepoPath(worktreePath, repoPath); - const git = simpleGit(worktreePath); + const git = simpleGit(targetPath); await git.reset(["HEAD"]); } @@ -144,10 +174,12 @@ export async function gitUnstageAll(worktreePath: string): Promise { */ export async function gitDiscardAllUnstaged( worktreePath: string, + repoPath?: string, ): Promise { assertRegisteredWorktree(worktreePath); + const targetPath = resolveRepoPath(worktreePath, repoPath); - const git = simpleGit(worktreePath); + const git = simpleGit(targetPath); await git.checkout(["--", "."]); } @@ -157,10 +189,14 @@ export async function gitDiscardAllUnstaged( * Uses `git reset HEAD` followed by `git checkout -- .`. * Does NOT affect untracked files. */ -export async function gitDiscardAllStaged(worktreePath: string): Promise { +export async function gitDiscardAllStaged( + worktreePath: string, + repoPath?: string, +): Promise { assertRegisteredWorktree(worktreePath); + const targetPath = resolveRepoPath(worktreePath, repoPath); - const git = simpleGit(worktreePath); + const git = simpleGit(targetPath); await git.reset(["HEAD"]); await git.checkout(["--", "."]); } @@ -170,10 +206,14 @@ export async function gitDiscardAllStaged(worktreePath: string): Promise { * * Uses `git stash push` to save current work-in-progress. */ -export async function gitStash(worktreePath: string): Promise { +export async function gitStash( + worktreePath: string, + repoPath?: string, +): Promise { assertRegisteredWorktree(worktreePath); + const targetPath = resolveRepoPath(worktreePath, repoPath); - const git = simpleGit(worktreePath); + const git = simpleGit(targetPath); await git.stash(["push"]); } @@ -184,10 +224,12 @@ export async function gitStash(worktreePath: string): Promise { */ export async function gitStashIncludeUntracked( worktreePath: string, + repoPath?: string, ): Promise { assertRegisteredWorktree(worktreePath); + const targetPath = resolveRepoPath(worktreePath, repoPath); - const git = simpleGit(worktreePath); + const git = simpleGit(targetPath); await git.stash(["push", "--include-untracked"]); } @@ -197,9 +239,13 @@ export async function gitStashIncludeUntracked( * Uses `git stash pop` to apply and remove the top stash entry. * Throws if no stash exists or if there are conflicts. */ -export async function gitStashPop(worktreePath: string): Promise { +export async function gitStashPop( + worktreePath: string, + repoPath?: string, +): Promise { assertRegisteredWorktree(worktreePath); + const targetPath = resolveRepoPath(worktreePath, repoPath); - const git = simpleGit(worktreePath); + const git = simpleGit(targetPath); await git.stash(["pop"]); } diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/index.ts b/apps/desktop/src/lib/trpc/routers/changes/security/index.ts index d147bcc7bcd..ffbd332af5d 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/index.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/index.ts @@ -25,6 +25,7 @@ export { export { assertRegisteredWorktree, assertValidGitPath, + assertValidNestedRepo, getRegisteredWorktree, PathValidationError, type PathValidationErrorCode, diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts b/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts index 317994323f3..b5a937f861c 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/path-validation.ts @@ -1,4 +1,5 @@ -import { isAbsolute, normalize, resolve, sep } from "node:path"; +import { accessSync, lstatSync } from "node:fs"; +import { isAbsolute, normalize, relative, resolve, sep } from "node:path"; import { projects, worktrees } from "@superset/local-db"; import { eq } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; @@ -192,3 +193,76 @@ export function resolvePathInWorktree( export function assertValidGitPath(filePath: string): void { validateRelativePath(filePath, { allowRoot: true }); } + +/** + * Validates that a nested repo path is within a registered worktree. + * + * Security checks: + * 1. The worktree itself must be registered + * 2. The nested repo must be within the worktree bounds (no ..) + * 3. The nested repo must contain a .git directory + * 4. The path must not traverse through symlinks that escape the worktree + * + * @param worktreePath - The registered worktree path + * @param nestedRepoPath - The nested repo path to validate + * @throws PathValidationError if validation fails + */ +export function assertValidNestedRepo( + worktreePath: string, + nestedRepoPath: string, +): void { + // First, verify the worktree is registered + assertRegisteredWorktree(worktreePath); + + // If nested repo is the same as worktree, it's valid + if (normalize(nestedRepoPath) === normalize(worktreePath)) { + return; + } + + // Compute relative path and check for traversal + const relativePath = relative(worktreePath, nestedRepoPath); + + // Reject if relative path starts with .. (escapes worktree) + if (relativePath.startsWith("..") || relativePath.startsWith(`${sep}..`)) { + throw new PathValidationError( + "Nested repo path escapes worktree boundary", + "PATH_TRAVERSAL", + ); + } + + // Reject absolute paths + if (isAbsolute(relativePath)) { + throw new PathValidationError( + "Nested repo must be within worktree", + "ABSOLUTE_PATH", + ); + } + + // Check for symlink escape: verify the resolved path is still within worktree + try { + const stats = lstatSync(nestedRepoPath); + if (stats.isSymbolicLink()) { + throw new PathValidationError( + "Nested repo path cannot be a symlink", + "SYMLINK_ESCAPE", + ); + } + } catch (error) { + if (error instanceof PathValidationError) throw error; + throw new PathValidationError( + "Nested repo path does not exist", + "INVALID_TARGET", + ); + } + + // Verify the nested repo contains a .git directory + try { + const gitPath = resolve(nestedRepoPath, ".git"); + accessSync(gitPath); + } catch { + throw new PathValidationError( + "Nested repo path is not a git repository", + "INVALID_TARGET", + ); + } +} diff --git a/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts b/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts index 9a931f0f831..c86972e157f 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/security/secure-fs.ts @@ -11,6 +11,7 @@ import { import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; import { assertRegisteredWorktree, + assertValidNestedRepo, PathValidationError, resolvePathInWorktree, } from "./path-validation"; @@ -271,6 +272,26 @@ async function assertDanglingSymlinkSafe( throw new PathValidationError("Cannot validate path", "SYMLINK_ESCAPE"); } } +/** + * Validates security for nested repo operations. + * Ensures the parent worktree is registered and the nested repo is valid. + * + * @param worktreePath - The registered parent worktree + * @param nestedRepoPath - The nested repo path (or same as worktreePath for root) + */ +function assertNestedRepoSecurity( + worktreePath: string, + nestedRepoPath: string, +): void { + assertRegisteredWorktree(worktreePath); + // Normalize paths for comparison to handle trailing slashes and other differences + const normalizedWorktree = resolve(worktreePath); + const normalizedNested = resolve(nestedRepoPath); + if (normalizedNested !== normalizedWorktree) { + assertValidNestedRepo(worktreePath, nestedRepoPath); + } +} + export const secureFs = { /** * Read a file within a worktree. @@ -466,4 +487,111 @@ export const secureFs = { return false; } }, + + // ============================================================ + // Nested Repo Operations + // ============================================================ + // These methods work with nested git repositories within a worktree. + // They validate the parent worktree is registered, then validate + // the nested repo is within bounds before operating. + + /** + * Read a file within a nested repo. + * + * @param worktreePath - The registered parent worktree + * @param nestedRepoPath - The nested repo path (or same as worktreePath) + * @param filePath - Relative path within the nested repo + */ + async readFileInNestedRepo( + worktreePath: string, + nestedRepoPath: string, + filePath: string, + encoding: BufferEncoding = "utf-8", + ): Promise { + assertNestedRepoSecurity(worktreePath, nestedRepoPath); + const fullPath = resolvePathInWorktree(nestedRepoPath, filePath); + // Validate against the root worktree for symlink escape + await assertRealpathInWorktree(worktreePath, fullPath); + return readFile(fullPath, encoding); + }, + + /** + * Read a file as Buffer within a nested repo. + */ + async readFileBufferInNestedRepo( + worktreePath: string, + nestedRepoPath: string, + filePath: string, + ): Promise { + assertNestedRepoSecurity(worktreePath, nestedRepoPath); + const fullPath = resolvePathInWorktree(nestedRepoPath, filePath); + await assertRealpathInWorktree(worktreePath, fullPath); + return readFile(fullPath); + }, + + /** + * Write content to a file within a nested repo. + */ + async writeFileInNestedRepo( + worktreePath: string, + nestedRepoPath: string, + filePath: string, + content: string, + ): Promise { + assertNestedRepoSecurity(worktreePath, nestedRepoPath); + const fullPath = resolvePathInWorktree(nestedRepoPath, filePath); + await assertRealpathInWorktree(worktreePath, fullPath); + await writeFile(fullPath, content, "utf-8"); + }, + + /** + * Delete a file or directory within a nested repo. + */ + async deleteInNestedRepo( + worktreePath: string, + nestedRepoPath: string, + filePath: string, + ): Promise { + assertNestedRepoSecurity(worktreePath, nestedRepoPath); + const fullPath = resolvePathInWorktree(nestedRepoPath, filePath, { + allowRoot: false, + }); + + 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; + } + + // Validate against root worktree for symlink escape + await assertRealpathInWorktree(worktreePath, fullPath); + await rm(fullPath, { recursive: true, force: true }); + }, + + /** + * Get file stats within a nested repo. + */ + async statInNestedRepo( + worktreePath: string, + nestedRepoPath: string, + filePath: string, + ): Promise { + assertNestedRepoSecurity(worktreePath, nestedRepoPath); + const fullPath = resolvePathInWorktree(nestedRepoPath, filePath); + await assertRealpathInWorktree(worktreePath, fullPath); + return stat(fullPath); + }, }; diff --git a/apps/desktop/src/lib/trpc/routers/changes/staging.ts b/apps/desktop/src/lib/trpc/routers/changes/staging.ts index 3a86468b1e6..bde5e726aee 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/staging.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/staging.ts @@ -21,10 +21,11 @@ export const createStagingRouter = () => { z.object({ worktreePath: z.string(), filePath: z.string(), + repoPath: z.string().optional(), }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await gitStageFile(input.worktreePath, input.filePath); + await gitStageFile(input.worktreePath, input.filePath, input.repoPath); return { success: true }; }), @@ -33,10 +34,15 @@ export const createStagingRouter = () => { z.object({ worktreePath: z.string(), filePath: z.string(), + repoPath: z.string().optional(), }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await gitUnstageFile(input.worktreePath, input.filePath); + await gitUnstageFile( + input.worktreePath, + input.filePath, + input.repoPath, + ); return { success: true }; }), @@ -45,24 +51,39 @@ export const createStagingRouter = () => { z.object({ worktreePath: z.string(), filePath: z.string(), + repoPath: z.string().optional(), }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await gitCheckoutFile(input.worktreePath, input.filePath); + await gitCheckoutFile( + input.worktreePath, + input.filePath, + input.repoPath, + ); return { success: true }; }), stageAll: publicProcedure - .input(z.object({ worktreePath: z.string() })) + .input( + z.object({ + worktreePath: z.string(), + repoPath: z.string().optional(), + }), + ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await gitStageAll(input.worktreePath); + await gitStageAll(input.worktreePath, input.repoPath); return { success: true }; }), unstageAll: publicProcedure - .input(z.object({ worktreePath: z.string() })) + .input( + z.object({ + worktreePath: z.string(), + repoPath: z.string().optional(), + }), + ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await gitUnstageAll(input.worktreePath); + await gitUnstageAll(input.worktreePath, input.repoPath); return { success: true }; }), @@ -71,45 +92,77 @@ export const createStagingRouter = () => { z.object({ worktreePath: z.string(), filePath: z.string(), + repoPath: z.string().optional(), }), ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await secureFs.delete(input.worktreePath, input.filePath); + const targetRepoPath = input.repoPath || input.worktreePath; + // Use nested-repo-aware delete that validates both worktree and nested repo + await secureFs.deleteInNestedRepo( + input.worktreePath, + targetRepoPath, + input.filePath, + ); return { success: true }; }), discardAllUnstaged: publicProcedure - .input(z.object({ worktreePath: z.string() })) + .input( + z.object({ + worktreePath: z.string(), + repoPath: z.string().optional(), + }), + ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await gitDiscardAllUnstaged(input.worktreePath); + await gitDiscardAllUnstaged(input.worktreePath, input.repoPath); return { success: true }; }), discardAllStaged: publicProcedure - .input(z.object({ worktreePath: z.string() })) + .input( + z.object({ + worktreePath: z.string(), + repoPath: z.string().optional(), + }), + ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await gitDiscardAllStaged(input.worktreePath); + await gitDiscardAllStaged(input.worktreePath, input.repoPath); return { success: true }; }), stash: publicProcedure - .input(z.object({ worktreePath: z.string() })) + .input( + z.object({ + worktreePath: z.string(), + repoPath: z.string().optional(), + }), + ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await gitStash(input.worktreePath); + await gitStash(input.worktreePath, input.repoPath); return { success: true }; }), stashIncludeUntracked: publicProcedure - .input(z.object({ worktreePath: z.string() })) + .input( + z.object({ + worktreePath: z.string(), + repoPath: z.string().optional(), + }), + ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await gitStashIncludeUntracked(input.worktreePath); + await gitStashIncludeUntracked(input.worktreePath, input.repoPath); return { success: true }; }), stashPop: publicProcedure - .input(z.object({ worktreePath: z.string() })) + .input( + z.object({ + worktreePath: z.string(), + repoPath: z.string().optional(), + }), + ) .mutation(async ({ input }): Promise<{ success: boolean }> => { - await gitStashPop(input.worktreePath); + await gitStashPop(input.worktreePath, input.repoPath); return { success: true }; }), }); diff --git a/apps/desktop/src/lib/trpc/routers/changes/status.ts b/apps/desktop/src/lib/trpc/routers/changes/status.ts index 43334e8ae03..b36b3f49149 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/status.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/status.ts @@ -1,16 +1,79 @@ -import type { ChangedFile, GitChangesStatus } from "shared/changes-types"; +import type { + ChangedFile, + GitChangesStatus, + MultiRepoGitChangesStatus, + NestedRepoStatus, +} from "shared/changes-types"; import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { getStatusNoLock } from "../workspaces/utils/git"; -import { assertRegisteredWorktree, secureFs } from "./security"; +import { + assertRegisteredWorktree, + assertValidNestedRepo, + secureFs, +} from "./security"; import { applyNumstatToFiles } from "./utils/apply-numstat"; +import { detectNestedRepos, getRepoDisplayName } from "./utils/nested-repos"; import { parseGitLog, parseGitStatus, parseNameStatus, } from "./utils/parse-status"; +/** + * Get git status for a single repository path. + * Internal helper used by both getStatus and getMultiRepoStatus. + * + * @param worktreePath - The registered parent worktree (for security validation) + * @param repoPath - The target repo path (may be nested or same as worktreePath) + * @param defaultBranch - The default branch name for comparison + */ +async function getRepoStatus({ + worktreePath, + repoPath, + defaultBranch, +}: { + worktreePath: string; + repoPath: string; + defaultBranch: string; +}): Promise { + const git = simpleGit(repoPath); + + // First, get status (needed for subsequent operations) + // Use --no-optional-locks to avoid holding locks on the repository + const status = await getStatusNoLock(repoPath); + const parsed = parseGitStatus(status); + + // Run independent operations in parallel + const [branchComparison, trackingStatus] = await Promise.all([ + getBranchComparison(git, defaultBranch), + getTrackingBranchStatus(git), + applyNumstatToFiles(git, parsed.staged, ["diff", "--cached", "--numstat"]), + applyNumstatToFiles(git, parsed.unstaged, ["diff", "--numstat"]), + applyUntrackedLineCount({ + worktreePath, + repoPath, + untracked: parsed.untracked, + }), + ]); + + return { + branch: parsed.branch, + defaultBranch, + againstBase: branchComparison.againstBase, + commits: branchComparison.commits, + staged: parsed.staged, + unstaged: parsed.unstaged, + untracked: parsed.untracked, + ahead: branchComparison.ahead, + behind: branchComparison.behind, + pushCount: trackingStatus.pushCount, + pullCount: trackingStatus.pullCount, + hasUpstream: trackingStatus.hasUpstream, + }; +} + export const createStatusRouter = () => { return router({ getStatus: publicProcedure @@ -18,45 +81,87 @@ export const createStatusRouter = () => { z.object({ worktreePath: z.string(), defaultBranch: z.string().optional(), + repoPath: z.string().optional(), }), ) .query(async ({ input }): Promise => { assertRegisteredWorktree(input.worktreePath); - const git = simpleGit(input.worktreePath); + // If repoPath provided, validate it's within worktree bounds + const targetPath = input.repoPath || input.worktreePath; + if (input.repoPath) { + assertValidNestedRepo(input.worktreePath, input.repoPath); + } + const defaultBranch = input.defaultBranch || "main"; + return getRepoStatus({ + worktreePath: input.worktreePath, + repoPath: targetPath, + defaultBranch, + }); + }), - // First, get status (needed for subsequent operations) - // Use --no-optional-locks to avoid holding locks on the repository - const status = await getStatusNoLock(input.worktreePath); - const parsed = parseGitStatus(status); - - // Run independent operations in parallel - const [branchComparison, trackingStatus] = await Promise.all([ - getBranchComparison(git, defaultBranch), - getTrackingBranchStatus(git), - applyNumstatToFiles(git, parsed.staged, [ - "diff", - "--cached", - "--numstat", - ]), - applyNumstatToFiles(git, parsed.unstaged, ["diff", "--numstat"]), - applyUntrackedLineCount(input.worktreePath, parsed.untracked), - ]); + getMultiRepoStatus: publicProcedure + .input( + z.object({ + worktreePath: z.string(), + defaultBranch: z.string().optional(), + }), + ) + .query(async ({ input }): Promise => { + assertRegisteredWorktree(input.worktreePath); + + const defaultBranch = input.defaultBranch || "main"; + + // Detect all nested repos + const repoPaths = await detectNestedRepos(input.worktreePath); + + // If no repos found, return empty state + if (repoPaths.length === 0) { + return { + repos: [], + totalStaged: 0, + totalUnstaged: 0, + totalUntracked: 0, + }; + } + + // Fetch status for all repos in parallel + const repoStatuses = await Promise.all( + repoPaths.map(async (repoPath): Promise => { + const status = await getRepoStatus({ + worktreePath: input.worktreePath, + repoPath, + defaultBranch, + }); + return { + ...status, + repoPath, + repoName: getRepoDisplayName(input.worktreePath, repoPath), + isRoot: repoPath === input.worktreePath, + }; + }), + ); + + // Calculate totals + const totalStaged = repoStatuses.reduce( + (sum, repo) => sum + repo.staged.length, + 0, + ); + const totalUnstaged = repoStatuses.reduce( + (sum, repo) => sum + repo.unstaged.length, + 0, + ); + const totalUntracked = repoStatuses.reduce( + (sum, repo) => sum + repo.untracked.length, + 0, + ); return { - branch: parsed.branch, - defaultBranch, - againstBase: branchComparison.againstBase, - commits: branchComparison.commits, - staged: parsed.staged, - unstaged: parsed.unstaged, - untracked: parsed.untracked, - ahead: branchComparison.ahead, - behind: branchComparison.behind, - pushCount: trackingStatus.pushCount, - pullCount: trackingStatus.pullCount, - hasUpstream: trackingStatus.hasUpstream, + repos: repoStatuses, + totalStaged, + totalUnstaged, + totalUntracked, }; }), @@ -65,12 +170,18 @@ export const createStatusRouter = () => { z.object({ worktreePath: z.string(), commitHash: z.string(), + repoPath: z.string().optional(), }), ) .query(async ({ input }): Promise => { assertRegisteredWorktree(input.worktreePath); - const git = simpleGit(input.worktreePath); + const targetPath = input.repoPath || input.worktreePath; + if (input.repoPath) { + assertValidNestedRepo(input.worktreePath, input.repoPath); + } + + const git = simpleGit(targetPath); const nameStatus = await git.raw([ "diff-tree", @@ -150,16 +261,32 @@ async function getBranchComparison( /** Max file size for line counting (1 MiB) - skip larger files to avoid OOM */ const MAX_LINE_COUNT_SIZE = 1 * 1024 * 1024; -async function applyUntrackedLineCount( - worktreePath: string, - untracked: ChangedFile[], -): Promise { +async function applyUntrackedLineCount({ + worktreePath, + repoPath, + untracked, +}: { + /** The registered parent worktree (for security validation) */ + worktreePath: string; + /** The target repo path (may be nested or same as worktreePath) */ + repoPath: string; + untracked: ChangedFile[]; +}): Promise { for (const file of untracked) { try { - const stats = await secureFs.stat(worktreePath, file.path); + // Use nested-repo-aware methods for proper security validation + const stats = await secureFs.statInNestedRepo( + worktreePath, + repoPath, + file.path, + ); if (stats.size > MAX_LINE_COUNT_SIZE) continue; - const content = await secureFs.readFile(worktreePath, file.path); + const content = await secureFs.readFileInNestedRepo( + worktreePath, + repoPath, + file.path, + ); const lineCount = content.split("\n").length; file.additions = lineCount; file.deletions = 0; diff --git a/apps/desktop/src/lib/trpc/routers/changes/utils/nested-repos.ts b/apps/desktop/src/lib/trpc/routers/changes/utils/nested-repos.ts new file mode 100644 index 00000000000..ecbe1acbd40 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/changes/utils/nested-repos.ts @@ -0,0 +1,164 @@ +import { access, lstat, readdir } from "node:fs/promises"; +import { join, relative } from "node:path"; + +/** Maximum depth to scan for nested repos */ +const MAX_SCAN_DEPTH = 5; + +/** Directories to exclude from scanning */ +const EXCLUDED_DIRS = new Set([ + "node_modules", + "vendor", + "dist", + "build", + ".git", + "__pycache__", + ".venv", + "venv", + ".next", + ".turbo", + "target", + "coverage", +]); + +/** Cache TTL in milliseconds (30 seconds) */ +const CACHE_TTL = 30_000; + +interface CacheEntry { + repos: string[]; + timestamp: number; +} + +const repoCache = new Map(); + +/** + * Check if a directory contains a .git directory (is a git repo) + */ +async function isGitRepo(dirPath: string): Promise { + 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 { + 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 + } +} + +/** + * Detects nested git repositories within a worktree. + * + * @param worktreePath - The root worktree path + * @returns Array of absolute paths to git repositories, root first + */ +export async function detectNestedRepos( + worktreePath: string, +): Promise { + // Check cache first + const cached = repoCache.get(worktreePath); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + return cached.repos; + } + + const repos: string[] = []; + + // Always include root if it's a git repo + if (await isGitRepo(worktreePath)) { + repos.push(worktreePath); + } + + // Find nested repos + await findNestedReposRecursive({ + basePath: worktreePath, + currentPath: worktreePath, + depth: 1, + results: repos, + }); + + // Update cache + repoCache.set(worktreePath, { + repos, + timestamp: Date.now(), + }); + + return repos; +} + +/** + * Get a display name for a nested repo relative to the worktree root. + * + * @param worktreePath - The root worktree path + * @param repoPath - The absolute path to the nested repo + * @returns Display name like "(root)" or "packages/submodule" + */ +export function getRepoDisplayName( + worktreePath: string, + repoPath: string, +): string { + if (repoPath === worktreePath) { + return "(root)"; + } + return relative(worktreePath, repoPath); +} + +/** + * Clear the nested repos cache for a specific worktree or all worktrees. + */ +export function clearNestedReposCache(worktreePath?: string): void { + if (worktreePath) { + repoCache.delete(worktreePath); + } else { + repoCache.clear(); + } +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx index 3c2e5cea3cc..f0927316fed 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/FileViewerPane.tsx @@ -74,6 +74,7 @@ export function FileViewerPane({ const [showUnsavedDialog, setShowUnsavedDialog] = useState(false); const [isSavingAndSwitching, setIsSavingAndSwitching] = useState(false); const pendingModeRef = useRef(null); + // Extract file viewer state from pane const filePath = fileViewer?.filePath ?? ""; const viewMode = fileViewer?.viewMode ?? "raw"; const isPinned = fileViewer?.isPinned ?? false; @@ -82,6 +83,8 @@ export function FileViewerPane({ const oldPath = fileViewer?.oldPath; const initialLine = fileViewer?.initialLine; const initialColumn = fileViewer?.initialColumn; + // repoPath is set when viewing files from nested repos (monorepo support) + const repoPath = fileViewer?.repoPath; const pinPane = useTabsStore((s) => s.pinPane); @@ -95,6 +98,7 @@ export function FileViewerPane({ originalDiffContentRef, draftContentRef, setIsDirty, + repoPath, }); const { rawFileData, isLoadingRaw, diffData, isLoadingDiff } = useFileContent( @@ -108,6 +112,7 @@ export function FileViewerPane({ isDirty, originalContentRef, originalDiffContentRef, + repoPath, }, ); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts index 857496f4a90..ef1d65d1ade 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileContent/useFileContent.ts @@ -12,6 +12,8 @@ interface UseFileContentParams { isDirty: boolean; originalContentRef: React.MutableRefObject; originalDiffContentRef: React.MutableRefObject; + /** Nested repo path for multi-repo support (if different from worktreePath) */ + repoPath?: string; } export function useFileContent({ @@ -24,6 +26,7 @@ export function useFileContent({ isDirty, originalContentRef, originalDiffContentRef, + repoPath, }: UseFileContentParams) { const { data: branchData } = electronTrpc.changes.getBranches.useQuery( { worktreePath }, @@ -31,14 +34,18 @@ export function useFileContent({ ); const effectiveBaseBranch = branchData?.defaultBranch ?? "main"; + // Query working file for raw/rendered modes + // repoPath tells the backend which nested repo to read from (if any) const { data: rawFileData, isLoading: isLoadingRaw } = electronTrpc.changes.readWorkingFile.useQuery( - { worktreePath, filePath }, + { worktreePath, filePath, repoPath }, { enabled: viewMode !== "diff" && !!filePath && !!worktreePath, }, ); + // Query file contents for diff mode (original vs modified) + // repoPath ensures we query the correct nested repo's git history const { data: diffData, isLoading: isLoadingDiff } = electronTrpc.changes.getFileContents.useQuery( { @@ -49,6 +56,7 @@ export function useFileContent({ commitHash, defaultBranch: diffCategory === "against-base" ? effectiveBaseBranch : undefined, + repoPath, }, { enabled: diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileSave/useFileSave.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileSave/useFileSave.ts index 2473264172b..699d6f288e2 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileSave/useFileSave.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/FileViewerPane/hooks/useFileSave/useFileSave.ts @@ -14,6 +14,8 @@ interface UseFileSaveParams { originalDiffContentRef: MutableRefObject; draftContentRef: MutableRefObject; setIsDirty: (dirty: boolean) => void; + /** Nested repo path for multi-repo support (if different from worktreePath) */ + repoPath?: string; } export function useFileSave({ @@ -26,6 +28,7 @@ export function useFileSave({ originalDiffContentRef, draftContentRef, setIsDirty, + repoPath, }: UseFileSaveParams) { const savingFromRawRef = useRef(false); const savingDiffContentRef = useRef(null); @@ -78,8 +81,9 @@ export function useFileSave({ worktreePath, filePath, content: editorRef.current.getValue(), + repoPath, }); - }, [worktreePath, filePath, saveFileMutation, editorRef]); + }, [worktreePath, filePath, saveFileMutation, editorRef, repoPath]); const handleSaveDiff = useCallback( async (content: string) => { @@ -90,9 +94,10 @@ export function useFileSave({ worktreePath, filePath, content, + repoPath, }); }, - [worktreePath, filePath, saveFileMutation], + [worktreePath, filePath, saveFileMutation, repoPath], ); return { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx index 05394d38b9d..7c9dffddf62 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/ChangesView.tsx @@ -21,12 +21,14 @@ import { ChangesHeader } from "./components/ChangesHeader"; import { CommitInput } from "./components/CommitInput"; import { CommitItem } from "./components/CommitItem"; import { FileList } from "./components/FileList"; +import { RepoSection } from "./components/RepoSection"; interface ChangesViewProps { onFileOpen?: ( file: ChangedFile, category: ChangeCategory, commitHash?: string, + repoPath?: string, ) => void; isExpandedView?: boolean; } @@ -47,11 +49,12 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { const effectiveBaseBranch = baseBranch ?? branchData?.defaultBranch ?? "main"; + // Use multi-repo status query const { - data: status, + data: multiRepoStatus, isLoading, refetch, - } = electronTrpc.changes.getStatus.useQuery( + } = electronTrpc.changes.getMultiRepoStatus.useQuery( { worktreePath: worktreePath || "", defaultBranch: effectiveBaseBranch }, { enabled: !!worktreePath, @@ -188,18 +191,23 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { const [showDiscardUnstagedDialog, setShowDiscardUnstagedDialog] = useState(false); const [showDiscardStagedDialog, setShowDiscardStagedDialog] = useState(false); + const [discardDialogRepoPath, setDiscardDialogRepoPath] = useState< + string | undefined + >(undefined); - const handleDiscard = (file: ChangedFile) => { + const handleDiscard = (file: ChangedFile, repoPath?: string) => { if (!worktreePath) return; if (file.status === "untracked" || file.status === "added") { deleteUntrackedMutation.mutate({ worktreePath, filePath: file.path, + repoPath, }); } else { discardChangesMutation.mutate({ worktreePath, filePath: file.path, + repoPath, }); } }; @@ -211,6 +219,8 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { getSelectedFile, toggleSection, setFileListViewMode, + toggleRepoExpanded, + isRepoExpanded, } = useChangesStore(); const selectedFileState = getSelectedFile(worktreePath || ""); @@ -243,24 +253,47 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { } }); + // Check if we have multiple repos + const repos = multiRepoStatus?.repos ?? []; + const isMultiRepo = repos.length > 1; + + // For single repo mode, get the root repo status + const rootRepo = repos.find((r) => r.isRoot) ?? repos[0]; + const combinedUnstaged = useMemo( () => - status?.unstaged && status?.untracked - ? [...status.unstaged, ...status.untracked] + rootRepo?.unstaged && rootRepo?.untracked + ? [...rootRepo.unstaged, ...rootRepo.untracked] : [], - [status?.unstaged, status?.untracked], + [rootRepo?.unstaged, rootRepo?.untracked], ); - const handleFileSelect = (file: ChangedFile, category: ChangeCategory) => { + /** + * Handles file selection from staged/unstaged lists. + * Passes repoPath so the diff viewer queries the correct nested repo. + */ + const handleFileSelect = ( + file: ChangedFile, + category: ChangeCategory, + repoPath?: string, + ) => { if (!worktreePath) return; - selectFile(worktreePath, file, category, null); - onFileOpen?.(file, category); + selectFile(worktreePath, file, category, null, repoPath); + onFileOpen?.(file, category, undefined, repoPath); }; - const handleCommitFileSelect = (file: ChangedFile, commitHash: string) => { + /** + * Handles file selection from the commits list. + * Passes repoPath to support viewing committed files in nested repos. + */ + const handleCommitFileSelect = ( + file: ChangedFile, + commitHash: string, + repoPath?: string, + ) => { if (!worktreePath) return; - selectFile(worktreePath, file, "committed", commitHash); - onFileOpen?.(file, "committed", commitHash); + selectFile(worktreePath, file, "committed", commitHash, repoPath); + onFileOpen?.(file, "committed", commitHash, repoPath); }; const handleCommitToggle = (hash: string) => { @@ -291,14 +324,7 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { ); } - if ( - !status || - !status.againstBase || - !status.commits || - !status.staged || - !status.unstaged || - !status.untracked - ) { + if (!multiRepoStatus || repos.length === 0) { return (
Unable to load changes @@ -307,21 +333,208 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { } 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 ( +
+ stashMutation.mutate({ worktreePath })} + onStashIncludeUntracked={() => + stashIncludeUntrackedMutation.mutate({ worktreePath }) + } + onStashPop={() => stashPopMutation.mutate({ worktreePath })} + isStashPending={ + stashMutation.isPending || + stashIncludeUntrackedMutation.isPending || + stashPopMutation.isPending + } + /> + + {!hasChanges ? ( +
+ No changes detected +
+ ) : ( +
+ {repos.map((repo) => ( + 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={ + 0} + pushCount={repo.pushCount} + pullCount={repo.pullCount} + hasUpstream={repo.hasUpstream} + hasExistingPR={repo.isRoot && hasExistingPR} + prUrl={repo.isRoot ? prUrl : undefined} + onRefresh={handleRefresh} + repoPath={repo.repoPath} + /> + } + /> + ))} +
+ )} + + + + + + Discard all unstaged changes? + + + This will revert all unstaged modifications. Untracked files + will not be affected. This action cannot be undone. + + + + + + + + + + + + + + Discard all staged changes? + + + This will unstage and revert all staged changes. Untracked files + will not be affected. This action cannot be undone. + + + + + + + + +
+ ); + } + + // Single repo view (backward compatible) return (
toggleSection("against-base")} > toggleSection("committed")} > @@ -401,7 +614,7 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { toggleSection("staged")} actions={ @@ -444,7 +657,7 @@ export function ChangesView({ onFileOpen, isExpandedView }: ChangesViewProps) { } > handleDiscard(file)} category="unstaged" isExpandedView={isExpandedView} /> diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/CommitInput.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/CommitInput.tsx index 0b254dbc65a..202ed5c995c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/CommitInput.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/CommitInput/CommitInput.tsx @@ -31,6 +31,7 @@ interface CommitInputProps { hasExistingPR: boolean; prUrl?: string; onRefresh: () => void; + repoPath?: string; } export function CommitInput({ @@ -42,6 +43,7 @@ export function CommitInput({ hasExistingPR, prUrl, onRefresh, + repoPath, }: CommitInputProps) { const [commitMessage, setCommitMessage] = useState(""); const [isOpen, setIsOpen] = useState(false); @@ -95,6 +97,13 @@ export function CommitInput({ onError: (error) => toast.error(`Fetch failed: ${error.message}`), }); + // Helper to add repoPath to mutation params + const withRepoPath = ( + params: T, + ): T & { repoPath?: string } => { + return repoPath ? { ...params, repoPath } : params; + }; + const isPending = commitMutation.isPending || pushMutation.isPending || @@ -107,38 +116,38 @@ export function CommitInput({ const handleCommit = () => { if (!canCommit) return; - commitMutation.mutate({ worktreePath, message: commitMessage.trim() }); + commitMutation.mutate( + withRepoPath({ worktreePath, message: commitMessage.trim() }), + ); }; const handlePush = () => { const isPublishing = !hasUpstream; - pushMutation.mutate( - { worktreePath, setUpstream: true }, - { - onSuccess: () => { - if (isPublishing) { - createPRMutation.mutate({ worktreePath }); - } - }, + pushMutation.mutate(withRepoPath({ worktreePath, setUpstream: true }), { + onSuccess: () => { + if (isPublishing) { + createPRMutation.mutate(withRepoPath({ worktreePath })); + } }, - ); + }); }; - const handlePull = () => pullMutation.mutate({ worktreePath }); - const handleSync = () => syncMutation.mutate({ worktreePath }); - const handleFetch = () => fetchMutation.mutate({ worktreePath }); + const handlePull = () => pullMutation.mutate(withRepoPath({ worktreePath })); + const handleSync = () => syncMutation.mutate(withRepoPath({ worktreePath })); + const handleFetch = () => + fetchMutation.mutate(withRepoPath({ worktreePath })); const handleFetchAndPull = () => { - fetchMutation.mutate( - { worktreePath }, - { onSuccess: () => pullMutation.mutate({ worktreePath }) }, - ); + fetchMutation.mutate(withRepoPath({ worktreePath }), { + onSuccess: () => pullMutation.mutate(withRepoPath({ worktreePath })), + }); }; - const handleCreatePR = () => createPRMutation.mutate({ worktreePath }); + const handleCreatePR = () => + createPRMutation.mutate(withRepoPath({ worktreePath })); const handleOpenPR = () => prUrl && window.open(prUrl, "_blank"); const handleCommitAndPush = () => { if (!canCommit) return; commitMutation.mutate( - { worktreePath, message: commitMessage.trim() }, + withRepoPath({ worktreePath, message: commitMessage.trim() }), { onSuccess: handlePush }, ); }; @@ -146,12 +155,14 @@ export function CommitInput({ const handleCommitPushAndCreatePR = () => { if (!canCommit) return; commitMutation.mutate( - { worktreePath, message: commitMessage.trim() }, + withRepoPath({ worktreePath, message: commitMessage.trim() }), { onSuccess: () => { pushMutation.mutate( - { worktreePath, setUpstream: true }, - { onSuccess: handleCreatePR }, + withRepoPath({ worktreePath, setUpstream: true }), + { + onSuccess: handleCreatePR, + }, ); }, }, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/RepoSection/RepoSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/RepoSection/RepoSection.tsx new file mode 100644 index 00000000000..4e848698d35 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/RepoSection/RepoSection.tsx @@ -0,0 +1,222 @@ +import { Button } from "@superset/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@superset/ui/collapsible"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import type { ReactNode } from "react"; +import { HiChevronRight, HiMiniMinus, HiMiniPlus } from "react-icons/hi2"; +import { LuGitBranch, LuUndo2 } from "react-icons/lu"; +import type { + ChangeCategory, + ChangedFile, + NestedRepoStatus, +} from "shared/changes-types"; +import { CategorySection } from "../CategorySection"; +import { FileList } from "../FileList"; + +interface RepoSectionProps { + repo: NestedRepoStatus; + worktreePath: string; + isExpanded: boolean; + onToggle: () => void; + selectedFile: ChangedFile | null; + selectedCommitHash: string | null; + fileListViewMode: "grouped" | "tree"; + expandedSections: Record; + onToggleSection: (section: ChangeCategory) => void; + onFileSelect: ( + file: ChangedFile, + category: ChangeCategory, + repoPath: string, + ) => void; + onStageFile: (file: ChangedFile, repoPath: string) => void; + onUnstageFile: (file: ChangedFile, repoPath: string) => void; + onDiscard: (file: ChangedFile, repoPath: string) => void; + onStageAll: (repoPath: string) => void; + onUnstageAll: (repoPath: string) => void; + onDiscardAllUnstaged: (repoPath: string) => void; + onDiscardAllStaged: (repoPath: string) => void; + isStaging: boolean; + isUnstaging: boolean; + isDiscarding: boolean; + isExpandedView?: boolean; + commitInput: ReactNode; +} + +export function RepoSection({ + repo, + worktreePath, + isExpanded, + onToggle, + selectedFile, + selectedCommitHash, + fileListViewMode, + expandedSections, + onToggleSection, + onFileSelect, + onStageFile, + onUnstageFile, + onDiscard, + onStageAll, + onUnstageAll, + onDiscardAllUnstaged, + onDiscardAllStaged, + isStaging, + isUnstaging, + isDiscarding, + isExpandedView, + commitInput, +}: RepoSectionProps) { + const combinedUnstaged = [...repo.unstaged, ...repo.untracked]; + const totalChanges = + repo.staged.length + repo.unstaged.length + repo.untracked.length; + + return ( + +
+ + + + {repo.repoName} + {totalChanges > 0 && ( + + {totalChanges} change{totalChanges !== 1 ? "s" : ""} + + )} + +
+ + + {commitInput} + + onToggleSection("staged")} + actions={ +
+ + + + + + Discard all staged + + + + + + + Unstage all + +
+ } + > + onFileSelect(file, "staged", repo.repoPath)} + onUnstage={(file) => onUnstageFile(file, repo.repoPath)} + isActioning={isUnstaging} + worktreePath={worktreePath} + category="staged" + isExpandedView={isExpandedView} + /> +
+ + onToggleSection("unstaged")} + actions={ +
+ + + + + + Discard all unstaged + + + + + + + Stage all + +
+ } + > + + onFileSelect(file, "unstaged", repo.repoPath) + } + onStage={(file) => onStageFile(file, repo.repoPath)} + isActioning={isStaging || isDiscarding} + worktreePath={worktreePath} + onDiscard={(file) => onDiscard(file, repo.repoPath)} + category="unstaged" + isExpandedView={isExpandedView} + /> +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/RepoSection/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/RepoSection/index.ts new file mode 100644 index 00000000000..694b93b1db3 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/ChangesView/components/RepoSection/index.ts @@ -0,0 +1 @@ +export { RepoSection } from "./RepoSection"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx index 3b40042a545..8dc6b6da47a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/RightSidebar/index.tsx @@ -94,14 +94,25 @@ export function RightSidebar() { [worktreePath, trpcUtils], ); + /** + * Opens a file in the diff viewer pane. + * repoPath is passed for nested repo support - it tells the backend + * which git repository to query for the file contents. + */ const handleFileOpenPane = useCallback( - (file: ChangedFile, category: ChangeCategory, commitHash?: string) => { + ( + file: ChangedFile, + category: ChangeCategory, + commitHash?: string, + repoPath?: string, + ) => { if (!workspaceId || !worktreePath) return; addFileViewerPane(workspaceId, { filePath: file.path, diffCategory: category, commitHash, oldPath: file.oldPath, + repoPath, }); invalidateFileContent(file.path); }, diff --git a/apps/desktop/src/renderer/stores/changes/store.ts b/apps/desktop/src/renderer/stores/changes/store.ts index f792859bfcb..a54aecbf31a 100644 --- a/apps/desktop/src/renderer/stores/changes/store.ts +++ b/apps/desktop/src/renderer/stores/changes/store.ts @@ -12,6 +12,7 @@ interface SelectedFileState { file: ChangedFile; category: ChangeCategory; commitHash: string | null; + repoPath?: string; } interface ChangesState { @@ -22,12 +23,14 @@ interface ChangesState { baseBranch: string | null; showRenderedMarkdown: Record; hideUnchangedRegions: boolean; + expandedRepos: Record; selectFile: ( worktreePath: string, file: ChangedFile | null, category?: ChangeCategory, commitHash?: string | null, + repoPath?: string, ) => void; getSelectedFile: (worktreePath: string) => SelectedFileState | null; setViewMode: (mode: DiffViewMode) => void; @@ -38,6 +41,8 @@ interface ChangesState { toggleRenderedMarkdown: (worktreePath: string) => void; getShowRenderedMarkdown: (worktreePath: string) => boolean; toggleHideUnchangedRegions: () => void; + toggleRepoExpanded: (repoPath: string) => void; + isRepoExpanded: (repoPath: string) => boolean; reset: (worktreePath: string) => void; } @@ -54,6 +59,7 @@ const initialState = { baseBranch: null, showRenderedMarkdown: {} as Record, hideUnchangedRegions: false, + expandedRepos: {} as Record, }; export const useChangesStore = create()( @@ -62,7 +68,7 @@ export const useChangesStore = create()( (set, get) => ({ ...initialState, - selectFile: (worktreePath, file, category, commitHash) => { + selectFile: (worktreePath, file, category, commitHash, repoPath) => { const { selectedFiles } = get(); set({ selectedFiles: { @@ -72,6 +78,7 @@ export const useChangesStore = create()( file, category: category ?? "against-base", commitHash: commitHash ?? null, + repoPath, } : null, }, @@ -132,6 +139,21 @@ export const useChangesStore = create()( set({ hideUnchangedRegions: !get().hideUnchangedRegions }); }, + toggleRepoExpanded: (repoPath) => { + const { expandedRepos } = get(); + set({ + expandedRepos: { + ...expandedRepos, + [repoPath]: expandedRepos[repoPath] === false, + }, + }); + }, + + isRepoExpanded: (repoPath) => { + // Default to expanded (true) if not explicitly set + return get().expandedRepos[repoPath] !== false; + }, + reset: (worktreePath) => { const { selectedFiles } = get(); set({ @@ -152,6 +174,7 @@ export const useChangesStore = create()( baseBranch: state.baseBranch, showRenderedMarkdown: state.showRenderedMarkdown, hideUnchangedRegions: state.hideUnchangedRegions, + expandedRepos: state.expandedRepos, }), }, ), diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index 5ca5d82bb4d..11ba7392011 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -604,6 +604,9 @@ export const useTabsStore = create()( viewMode: options.viewMode, }); + // Update pane state with new file info + // repoPath is preserved for nested repo support - it tells + // the file content queries which git repo to read from set({ panes: { ...state.panes, @@ -620,6 +623,7 @@ export const useTabsStore = create()( oldPath: options.oldPath, initialLine: options.line, initialColumn: options.column, + repoPath: options.repoPath, }, }, }, diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index 58a613e88d0..566e56eef01 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -57,6 +57,8 @@ export interface AddFileViewerPaneOptions { column?: number; /** If true, opens pinned (permanent). If false/undefined, opens in preview mode (can be replaced) */ isPinned?: boolean; + /** Nested repo path for multi-repo support (if different from worktreePath) */ + repoPath?: string; } /** diff --git a/apps/desktop/src/renderer/stores/tabs/utils.ts b/apps/desktop/src/renderer/stores/tabs/utils.ts index 9eff58acf51..7382fcd98c7 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils.ts @@ -172,6 +172,8 @@ export interface CreateFileViewerPaneOptions { line?: number; /** Column to scroll to (raw mode only) */ column?: number; + /** Nested repo path for multi-repo support (if different from worktreePath) */ + repoPath?: string; } /** @@ -189,6 +191,9 @@ export const createFileViewerPane = ( viewMode: options.viewMode, }); + // Build file viewer state + // repoPath enables nested repo support - when set, file content queries + // will use this path instead of worktreePath to access the correct git repo const fileViewer: FileViewerState = { filePath: options.filePath, viewMode: resolvedViewMode, @@ -199,6 +204,7 @@ export const createFileViewerPane = ( oldPath: options.oldPath, initialLine: options.line, initialColumn: options.column, + repoPath: options.repoPath, }; // Use filename for display name diff --git a/apps/desktop/src/shared/changes-types.ts b/apps/desktop/src/shared/changes-types.ts index 7ef045b06d2..f24785e5a17 100644 --- a/apps/desktop/src/shared/changes-types.ts +++ b/apps/desktop/src/shared/changes-types.ts @@ -71,3 +71,18 @@ export interface FileContents { modified: string; // Modified content (after changes) language: string; // Detected language for syntax highlighting } + +/** Status for a nested repository within a worktree */ +export interface NestedRepoStatus extends GitChangesStatus { + repoPath: string; // Absolute path to the nested repo + repoName: string; // Display name (relative from worktree root, or "(root)") + isRoot: boolean; // true for main worktree repo +} + +/** Multi-repo git changes status for a worktree with nested repos */ +export interface MultiRepoGitChangesStatus { + repos: NestedRepoStatus[]; + totalStaged: number; + totalUnstaged: number; + totalUntracked: number; +} diff --git a/apps/desktop/src/shared/tabs-types.ts b/apps/desktop/src/shared/tabs-types.ts index cdc8c56923f..c35d3a5cbaf 100644 --- a/apps/desktop/src/shared/tabs-types.ts +++ b/apps/desktop/src/shared/tabs-types.ts @@ -100,6 +100,8 @@ export interface FileViewerState { initialLine?: number; /** Initial column to scroll to (raw mode only, transient - applied once) */ initialColumn?: number; + /** Nested repo path for multi-repo support (if different from worktreePath) */ + repoPath?: string; } /**