Skip to content
365 changes: 340 additions & 25 deletions apps/desktop/src/lib/trpc/routers/changes/branches.ts

Large diffs are not rendered by default.

17 changes: 6 additions & 11 deletions apps/desktop/src/lib/trpc/routers/changes/file-contents.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { FileContents } from "shared/changes-types";
import { detectLanguage } from "shared/detect-language";
import type { SimpleGit } from "simple-git";
import { z } from "zod";
import { publicProcedure, router } from "../..";
import { toRegisteredWorktreeRelativePath } from "../workspace-fs-service";
import { getSimpleGitWithShellPath } from "../workspaces/utils/git-client";
import { getProcessEnvWithShellPath } from "../workspaces/utils/shell-env";
import {
execGitWithShellPathBuffer,
getSimpleGitWithShellPath,
} from "../workspaces/utils/git-client";

const MAX_FILE_SIZE = 2 * 1024 * 1024;
const MAX_BINARY_FILE_SIZE = 10 * 1024 * 1024;
const execFileAsync = promisify(execFile);

export const createFileContentsRouter = () => {
return router({
Expand Down Expand Up @@ -83,18 +82,14 @@ export const createFileContentsRouter = () => {
}

try {
const env = await getProcessEnvWithShellPath();
const { stdout } = await execFileAsync(
"git",
const { stdout } = await execGitWithShellPathBuffer(
["cat-file", "-p", spec],
{
cwd: input.worktreePath,
encoding: "buffer",
maxBuffer: MAX_BINARY_FILE_SIZE,
env,
},
);
return { content: (stdout as unknown as Buffer).toString("base64") };
return { content: stdout.toString("base64") };
} catch {
return { content: null };
}
Expand Down
136 changes: 136 additions & 0 deletions apps/desktop/src/lib/trpc/routers/changes/git-blame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ import { z } from "zod";
import { publicProcedure, router } from "../..";
import { toRegisteredWorktreeRelativePath } from "../workspace-fs-service";
import { getSimpleGitWithShellPath } from "../workspaces/utils/git-client";
import {
type GitHubCommitAuthor,
makeGitHubCommitAuthorCacheKey,
readCachedGitHubCommitAuthor,
} from "../workspaces/utils/github/cache";
import {
extractNwoFromUrl,
getRepoContext,
} from "../workspaces/utils/github/repo-context";
import { execWithShellEnv } from "../workspaces/utils/shell-env";
import { assertRegisteredWorktree } from "./security/path-validation";

export interface BlameEntry {
Expand All @@ -12,6 +22,121 @@ export interface BlameEntry {
summary: string;
}

const GitHubCommitResponseSchema = z.object({
author: z
.object({
login: z.string().optional(),
avatar_url: z.string().optional(),
})
.nullable()
.optional(),
});

function isSafeAvatarUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "https:";
} catch {
return false;
}
}

function parseJsonOrNull(stdout: string): unknown | null {
try {
return JSON.parse(stdout) as unknown;
} catch {
return null;
}
}

function getRepoCandidates(
repoContext: Awaited<ReturnType<typeof getRepoContext>>,
): string[] {
if (!repoContext) {
return [];
}

return Array.from(
new Set(
[repoContext.repoUrl, repoContext.upstreamUrl]
.map((url) => extractNwoFromUrl(url))
.filter((value): value is string => Boolean(value)),
),
);
}

async function fetchGitHubCommitAuthorForRepo({
worktreePath,
repoNameWithOwner,
commitHash,
}: {
worktreePath: string;
repoNameWithOwner: string;
commitHash: string;
}): Promise<GitHubCommitAuthor | null> {
const cacheKey = makeGitHubCommitAuthorCacheKey({
repoNameWithOwner,
commitHash,
});

return readCachedGitHubCommitAuthor(cacheKey, async () => {
try {
const { stdout } = await execWithShellEnv(
"gh",
["api", `repos/${repoNameWithOwner}/commits/${commitHash}`],
{ cwd: worktreePath },
);
const raw = parseJsonOrNull(stdout);
if (raw === null) {
return null;
}

const parsed = GitHubCommitResponseSchema.safeParse(raw);
if (!parsed.success) {
return null;
}

const login = parsed.data.author?.login?.trim() || null;
const avatarUrl =
parsed.data.author?.avatar_url &&
isSafeAvatarUrl(parsed.data.author.avatar_url)
? parsed.data.author.avatar_url
: null;

if (!login && !avatarUrl) {
return null;
}

return { login, avatarUrl };
} catch {
return null;
}
});
}

async function getGitHubCommitAuthor({
worktreePath,
commitHash,
}: {
worktreePath: string;
commitHash: string;
}): Promise<GitHubCommitAuthor | null> {
const repoContext = await getRepoContext(worktreePath);

for (const repoNameWithOwner of getRepoCandidates(repoContext)) {
const author = await fetchGitHubCommitAuthorForRepo({
worktreePath,
repoNameWithOwner,
commitHash,
});
if (author) {
return author;
}
}

return null;
}

function parseGitBlamePorcelain(output: string): BlameEntry[] {
const lines = output.split("\n");
const commitCache = new Map<
Expand Down Expand Up @@ -109,5 +234,16 @@ export const createGitBlameRouter = () => {
return { entries: [] };
}
}),
getGitHubCommitAuthor: publicProcedure
.input(
z.object({
worktreePath: z.string(),
commitHash: z.string().regex(/^[0-9a-f]{40}$/i),
}),
)
.query(async ({ input }): Promise<GitHubCommitAuthor | null> => {
assertRegisteredWorktree(input.worktreePath);
return getGitHubCommitAuthor(input);
}),
});
};
107 changes: 97 additions & 10 deletions apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,28 @@ async function getGitWithShellPath(worktreePath: string) {
return getSimpleGitWithShellPath(worktreePath);
}

function assertValidBranchName(branch: string): void {
// Validate: reject anything that looks like a flag
if (branch.startsWith("-")) {
throw new Error("Invalid branch name: cannot start with -");
}

// Validate: reject empty branch names
if (!branch.trim()) {
throw new Error("Invalid branch name: cannot be empty");
}
}

function assertValidStartPoint(startPoint: string): void {
if (startPoint.startsWith("-")) {
throw new Error("Invalid start point: cannot start with -");
}

if (!startPoint.trim()) {
throw new Error("Invalid start point: cannot be empty");
}
}

async function isCurrentBranch({
worktreePath,
expectedBranch,
Expand Down Expand Up @@ -50,22 +72,44 @@ export async function gitSwitchBranch(
branch: string,
): Promise<void> {
assertRegisteredWorktree(worktreePath);

// Validate: reject anything that looks like a flag
if (branch.startsWith("-")) {
throw new Error("Invalid branch name: cannot start with -");
}

// Validate: reject empty branch names
if (!branch.trim()) {
throw new Error("Invalid branch name: cannot be empty");
}
assertValidBranchName(branch);

const git = await getGitWithShellPath(worktreePath);

await runWithPostCheckoutHookTolerance({
context: `Switched branch to "${branch}" in ${worktreePath}`,
run: async () => {
const localBranches = await git.branchLocal();
if (localBranches.all.includes(branch)) {
try {
await git.raw(["switch", branch]);
return;
} catch (switchError) {
const errorMessage = String(switchError);
if (errorMessage.includes("is not a git command")) {
await git.checkout(branch);
return;
}
throw switchError;
}
}

const remoteBranches = await git.branch(["-r"]);
const remoteBranch = `origin/${branch}`;
if (remoteBranches.all.includes(remoteBranch)) {
try {
await git.raw(["switch", "--track", "-c", branch, remoteBranch]);
return;
} catch (switchError) {
const errorMessage = String(switchError);
if (errorMessage.includes("is not a git command")) {
await git.checkout(["-b", branch, "--track", remoteBranch]);
return;
}
throw switchError;
}
}

try {
// Prefer `git switch` - unambiguous branch operation (git 2.23+)
await git.raw(["switch", branch]);
Expand All @@ -87,6 +131,49 @@ export async function gitSwitchBranch(
});
}

/**
* Create and switch to a new branch, optionally from a specific ref.
*
* Uses `git switch -c` (or `git checkout -b` as a fallback).
*/
export async function gitCreateBranch(
worktreePath: string,
branch: string,
startPoint?: string,
): Promise<void> {
assertRegisteredWorktree(worktreePath);
assertValidBranchName(branch);
if (startPoint) {
assertValidStartPoint(startPoint);
}

const git = await getGitWithShellPath(worktreePath);

await runWithPostCheckoutHookTolerance({
context: `Created branch "${branch}" in ${worktreePath}`,
run: async () => {
try {
await git.raw(
startPoint
? ["switch", "-c", branch, startPoint]
: ["switch", "-c", branch],
);
} catch (switchError) {
const errorMessage = String(switchError);
if (errorMessage.includes("is not a git command")) {
await git.checkout(
startPoint ? ["-b", branch, startPoint] : ["-b", branch],
);
return;
}
throw switchError;
}
},
didSucceed: async () =>
isCurrentBranch({ worktreePath, expectedBranch: branch }),
});
}

/**
* Checkout (restore) a file path, discarding local changes.
*
Expand Down
Loading
Loading