Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 91 additions & 30 deletions apps/desktop/src/lib/trpc/routers/changes/file-contents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FileContents> => {
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,
Expand All @@ -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,
);
Expand All @@ -106,17 +113,25 @@ export const createFileContentsRouter = () => {
z.object({
worktreePath: z.string(),
filePath: z.string(),
repoPath: z.string().optional(),
}),
)
.query(async ({ input }): Promise<ReadWorkingFileResult> => {
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,
);

Expand Down Expand Up @@ -150,15 +165,29 @@ interface FileVersions {
modified: string;
}

async function getFileVersions(
git: ReturnType<typeof simpleGit>,
worktreePath: string,
filePath: string,
originalPath: string,
category: DiffCategory,
defaultBranch: string,
commitHash?: string,
): Promise<FileVersions> {
interface GetFileVersionsParams {
git: ReturnType<typeof simpleGit>;
/** 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<FileVersions> {
switch (category) {
case "against-base":
return getAgainstBaseVersions(git, filePath, originalPath, defaultBranch);
Expand All @@ -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,
});
}
}

Expand Down Expand Up @@ -243,12 +278,23 @@ async function getStagedVersions(
return { original, modified };
}

async function getUnstagedVersions(
git: ReturnType<typeof simpleGit>,
worktreePath: string,
filePath: string,
originalPath: string,
): Promise<FileVersions> {
interface GetUnstagedVersionsParams {
git: ReturnType<typeof simpleGit>;
/** 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<FileVersions> {
// Try staged version first, fall back to HEAD
let original = await safeGitShow(git, `:0:${originalPath}`);
if (!original) {
Expand All @@ -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 = "";
}

Expand Down
61 changes: 53 additions & 8 deletions apps/desktop/src/lib/trpc/routers/changes/git-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof simpleGit>,
): Promise<boolean> {
Expand Down Expand Up @@ -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 };
},
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand All @@ -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 };
}),
Expand All @@ -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);

Expand Down
Loading