Skip to content
Merged
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
54 changes: 39 additions & 15 deletions apps/desktop/src/lib/trpc/routers/changes/security/git-commands.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import simpleGit from "simple-git";
import { runWithPostCheckoutHookTolerance } from "../../utils/git-hook-tolerance";
import {
assertRegisteredWorktree,
assertValidGitPath,
Expand All @@ -16,6 +17,22 @@ import {
* 3. Uses the correct git command syntax
*/

async function isCurrentBranch({
worktreePath,
expectedBranch,
}: {
worktreePath: string;
expectedBranch: string;
}): Promise<boolean> {
try {
const git = simpleGit(worktreePath);
const currentBranch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
return currentBranch === expectedBranch;
} catch {
return false;
}
}

/**
* Switch to a branch.
*
Expand All @@ -42,21 +59,28 @@ export async function gitSwitchBranch(

const git = simpleGit(worktreePath);

try {
// Prefer `git switch` - unambiguous branch operation (git 2.23+)
await git.raw(["switch", branch]);
} catch (switchError) {
// Check if it's because `switch` command doesn't exist (old git < 2.23)
// Git outputs: "git: 'switch' is not a git command. See 'git --help'."
const errorMessage = String(switchError);
if (errorMessage.includes("is not a git command")) {
// Fallback for older git versions
// Note: checkout WITHOUT -- is correct for branches
await git.checkout(branch);
} else {
throw switchError;
}
}
await runWithPostCheckoutHookTolerance({
context: `Switched branch to "${branch}" in ${worktreePath}`,
run: async () => {
try {
// Prefer `git switch` - unambiguous branch operation (git 2.23+)
await git.raw(["switch", branch]);
} catch (switchError) {
// Check if it's because `switch` command doesn't exist (old git < 2.23)
// Git outputs: "git: 'switch' is not a git command. See 'git --help'."
const errorMessage = String(switchError);
if (errorMessage.includes("is not a git command")) {
// Fallback for older git versions
// Note: checkout WITHOUT -- is correct for branches
await git.checkout(branch);
} else {
throw switchError;
}
}
},
didSucceed: async () =>
isCurrentBranch({ worktreePath, expectedBranch: branch }),
});
}

/**
Expand Down
51 changes: 51 additions & 0 deletions apps/desktop/src/lib/trpc/routers/utils/git-hook-tolerance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, test } from "bun:test";
import { runWithPostCheckoutHookTolerance } from "./git-hook-tolerance";

describe("runWithPostCheckoutHookTolerance", () => {
test("treats post-checkout hook failures as non-fatal when operation succeeded", async () => {
const hookError = Object.assign(
new Error("husky - post-checkout script failed"),
{
stderr: "husky - command not found in PATH=...",
},
);

await expect(
runWithPostCheckoutHookTolerance({
context: "Switched branch",
run: async () => {
throw hookError;
},
didSucceed: async () => true,
}),
).resolves.toBeUndefined();
});

test("re-throws hook failures when operation did not succeed", async () => {
const hookError = new Error("post-checkout hook failed");

await expect(
runWithPostCheckoutHookTolerance({
context: "Switched branch",
run: async () => {
throw hookError;
},
didSucceed: async () => false,
}),
).rejects.toThrow("post-checkout");
});

test("re-throws non-hook failures", async () => {
const genericError = new Error("fatal: '../worktree' already exists");

await expect(
runWithPostCheckoutHookTolerance({
context: "Created worktree",
run: async () => {
throw genericError;
},
didSucceed: async () => true,
}),
).rejects.toThrow("already exists");
});
});
67 changes: 67 additions & 0 deletions apps/desktop/src/lib/trpc/routers/utils/git-hook-tolerance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
interface GitCommandException extends Error {
stdout?: string;
stderr?: string;
}

function getErrorText(error: unknown): string {
if (error instanceof Error) {
const parts = [error.message];
const gitError = error as GitCommandException;
if (typeof gitError.stderr === "string" && gitError.stderr.trim()) {
parts.push(gitError.stderr);
}
if (typeof gitError.stdout === "string" && gitError.stdout.trim()) {
parts.push(gitError.stdout);
}
return parts.join("\n");
}

return String(error);
}

export function isPostCheckoutHookFailure(error: unknown): boolean {
const text = getErrorText(error).toLowerCase();
if (!text.includes("post-checkout")) {
return false;
}

return (
text.includes("hook") ||
text.includes("husky") ||
text.includes("command not found")
);
}

export async function runWithPostCheckoutHookTolerance({
run,
didSucceed,
context,
}: {
run: () => Promise<void>;
didSucceed: () => Promise<boolean>;
context: string;
}): Promise<void> {
try {
await run();
} catch (error) {
if (!isPostCheckoutHookFailure(error)) {
throw error;
}

let succeeded = false;
try {
succeeded = await didSucceed();
} catch {
succeeded = false;
}

if (!succeeded) {
throw error;
}

const message = getErrorText(error);
console.warn(
`[git] ${context} but post-checkout hook failed (non-fatal): ${message}`,
);
}
}
62 changes: 62 additions & 0 deletions apps/desktop/src/lib/trpc/routers/workspaces/utils/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { execSync } from "node:child_process";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { createWorktree } from "./git";

// We need to test the internal functions, so we'll import the module
// and test the exported functions that use them
Expand All @@ -20,6 +21,14 @@ function createTestRepo(name: string): string {
return repoPath;
}

function seedCommit(repoPath: string): void {
writeFileSync(join(repoPath, "README.md"), "# test\n");
execSync("git add . && git commit -m 'init'", {
cwd: repoPath,
stdio: "ignore",
});
}

describe("LFS Detection", () => {
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true });
Expand Down Expand Up @@ -313,3 +322,56 @@ describe("Shell Environment", () => {
expect(env.PATH || env.Path).toBeDefined();
});
});

describe("createWorktree hook tolerance", () => {
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true });
});

afterEach(() => {
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true });
}
});

test("continues when post-checkout hook fails but worktree is created", async () => {
const repoPath = createTestRepo("worktree-hook-failure");
seedCommit(repoPath);

const hookPath = join(repoPath, ".git", "hooks", "post-checkout");
writeFileSync(
hookPath,
"#!/bin/sh\necho 'post-checkout failed' >&2\nexit 1\n",
);
execSync(`chmod +x "${hookPath}"`);

const worktreePath = join(TEST_DIR, "worktree-hook-failure-wt");
await createWorktree(
repoPath,
"feature/hook-failure",
worktreePath,
"HEAD",
);

expect(existsSync(worktreePath)).toBe(true);
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
cwd: worktreePath,
})
.toString()
.trim();
expect(currentBranch).toBe("feature/hook-failure");
});

test("throws when destination path exists but worktree is not created", async () => {
const repoPath = createTestRepo("worktree-existing-path");
seedCommit(repoPath);

const worktreePath = join(TEST_DIR, "worktree-existing-path-wt");
mkdirSync(worktreePath, { recursive: true });
writeFileSync(join(worktreePath, "keep.txt"), "keep");

await expect(
createWorktree(repoPath, "feature/existing-path", worktreePath, "HEAD"),
).rejects.toThrow("already exists");
});
});
Loading
Loading