Skip to content
Open
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
142 changes: 142 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,148 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
assert.notInclude(error.message, secret);
assert.notProperty(error, "args");
assert.notProperty(error, "stderr");
assert.notProperty(error, "reason");
}),
);

it.effect("names the branch conflict behind a failed worktree add", () =>
Effect.gen(function* () {
const parent = yield* makeTmpDir();
const pathService = yield* Path.Path;
const cwd = pathService.join(parent, "repo");
const fileSystem = yield* FileSystem.FileSystem;
yield* fileSystem.makeDirectory(cwd);
const driver = yield* GitVcsDriver.GitVcsDriver;
yield* initRepoWithCommit(cwd);
const firstWorktree = pathService.join(parent, "first");
yield* git(cwd, ["worktree", "add", firstWorktree, "-b", "shared-branch"]);

const error = yield* driver
.execute({
operation: "GitVcsDriver.test.worktreeAddConflict",
cwd,
args: ["worktree", "add", pathService.join(parent, "second"), "shared-branch"],
env: { LC_ALL: "C" },
})
.pipe(Effect.flip);

assert.deepInclude(error, {
_tag: "GitCommandError",
operation: "GitVcsDriver.test.worktreeAddConflict",
reason: "branch_checked_out_in_worktree",
});
assert.include(error.message, "already checked out in another worktree");
assert.notInclude(error.message, firstWorktree);
}),
);

it.effect("names a missing repository behind a failed command", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const driver = yield* GitVcsDriver.GitVcsDriver;

const error = yield* driver
.execute({
operation: "GitVcsDriver.test.notARepository",
cwd,
args: ["rev-parse", "--abbrev-ref", "HEAD"],
env: { LC_ALL: "C" },
})
.pipe(Effect.flip);

assert.deepInclude(error, {
_tag: "GitCommandError",
operation: "GitVcsDriver.test.notARepository",
reason: "not_a_repository",
});
assert.include(error.message, "not a Git repository");
}),
);

it.effect("separates the reason from a detail that has no closing punctuation", () =>
Effect.gen(function* () {
const parent = yield* makeTmpDir();
const pathService = yield* Path.Path;
const cwd = pathService.join(parent, "repo");
const fileSystem = yield* FileSystem.FileSystem;
yield* fileSystem.makeDirectory(cwd);
const driver = yield* GitVcsDriver.GitVcsDriver;
yield* initRepoWithCommit(cwd);
yield* git(cwd, [
"worktree",
"add",
pathService.join(parent, "taken"),
"-b",
"taken-branch",
]);

const error = yield* driver
.createWorktree({
cwd,
refName: "taken-branch",
path: pathService.join(parent, "second"),
})
.pipe(Effect.flip);

assert.equal(error.detail, "git worktree add failed");
assert.include(
error.message,
"git worktree add failed. That branch is already checked out in another worktree.",
);
}),
);

it.effect("does not read hook output as a git failure reason", () =>
Effect.gen(function* () {
const parent = yield* makeTmpDir();
const pathService = yield* Path.Path;
const remote = pathService.join(parent, "origin.git");
const cwd = pathService.join(parent, "work");
const fileSystem = yield* FileSystem.FileSystem;
const driver = yield* GitVcsDriver.GitVcsDriver;
yield* fileSystem.makeDirectory(cwd);
yield* initRepoWithCommit(cwd);
yield* git(cwd, ["init", "--bare", remote]);
yield* git(cwd, ["remote", "add", "origin", remote]);
yield* writeTextFile(
cwd,
".git/hooks/pre-push",
'#!/bin/sh\necho "authentication failed" >&2\nexit 1\n',
);
yield* fileSystem.chmod(pathService.join(cwd, ".git/hooks/pre-push"), 0o755);

const error = yield* driver
.execute({
operation: "GitVcsDriver.test.hookOutput",
cwd,
args: ["push", "origin", "HEAD"],
env: { LC_ALL: "C" },
})
.pipe(Effect.flip);

assert.notProperty(error, "reason");
assert.notInclude(error.message, "authenticate");
}),
);

it.effect("leaves a tag collision unclassified rather than calling it a path", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const driver = yield* GitVcsDriver.GitVcsDriver;
yield* initRepoWithCommit(cwd);
yield* git(cwd, ["tag", "v1"]);

const error = yield* driver
.execute({
operation: "GitVcsDriver.test.tagCollision",
cwd,
args: ["tag", "v1"],
env: { LC_ALL: "C" },
})
.pipe(Effect.flip);

assert.notProperty(error, "reason");
assert.notInclude(error.message, "target path already exists");
}),
);

Expand Down
45 changes: 45 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";

import {
GitCommandError,
type GitCommandFailureReason,
type ReviewDiffFileContentsInput,
type ReviewDiffPreviewInput,
type ReviewDiffPreviewSource,
Expand Down Expand Up @@ -391,6 +392,46 @@ function gitCommandContext(
} as const;
}

// Git states the actual cause on stderr, but stderr never leaves this module:
// it echoes argv and remote URLs, which can carry credentials. Matching it
// against fixed patterns yields a tag the caller can act on and a message that
// quotes none of the matched text. Patterns run in order, specific first.
const GIT_FAILURE_REASON_PATTERNS: ReadonlyArray<readonly [RegExp, GitCommandFailureReason]> = [
[/would clobber existing tag/i, "tag_would_be_clobbered"],
[/is already (?:used by worktree at|checked out at)/i, "branch_checked_out_in_worktree"],
[/a branch named .+ already exists/i, "branch_already_exists"],
[
/(?:authentication failed|could not read Username|could not read Password|permission denied \(publickey\))/i,
"authentication_failed",
],
[
/(?:could not read from remote repository|does not appear to be a git repository|repository .+ not found)/i,
"remote_unreachable",
],
// Quoted-path forms only: an unquoted `fatal: <thing> already exists` also
// covers tag and ref collisions, which are not path collisions.
[/fatal: '[^']+' already exists|destination path .+ already exists/i, "path_already_exists"],
];

// Hooks write to the same stream git does, unprefixed and uncontrolled: a
// pre-push hook echoing "authentication failed" would otherwise be read as a
// credential failure. Git prefixes its own diagnostics, and states a push
// rejection on a `! [rejected]` line, so only those lines are classified.
const GIT_DIAGNOSTIC_LINE_PATTERN = /^(?:fatal|error|remote):|^!\s|^\s+!\s/;

function classifyGitFailure(stderr: string): GitCommandFailureReason | null {
const diagnostics = stderr
.split(/\r?\n/)
.filter((line) => GIT_DIAGNOSTIC_LINE_PATTERN.test(line))
.join("\n");
if (diagnostics.length === 0) return null;
if (isNonRepositoryGitStderr(diagnostics)) return "not_a_repository";
for (const [pattern, reason] of GIT_FAILURE_REASON_PATTERNS) {
if (pattern.test(diagnostics)) return reason;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

SSH auth tagged as unreachable

Medium Severity

SSH publickey failures print Permission denied (publickey) without a git prefix, so GIT_DIAGNOSTIC_LINE_PATTERN drops that line. The leftover fatal: Could not read from remote repository. then matches remote_unreachable. fetchRemote and other git-over-SSH calls therefore report an unreachable remote instead of authentication_failed.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit dc39834. Configure here.

}
return null;
}

function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string): string | null {
const trimmed = value.trim();
const prefix = `refs/remotes/${remoteName}/`;
Expand Down Expand Up @@ -811,8 +852,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
yield* trace2Monitor.flush;

if (!input.allowNonZeroExit && exitCode !== 0) {
const reason = classifyGitFailure(stderr.text);
return yield* new GitCommandError({
...gitCommandContext(commandInput),
...(reason === null ? {} : { reason }),
detail: "Git command exited with a non-zero status.",
exitCode,
stdoutLength: stdout.text.length,
Expand Down Expand Up @@ -895,9 +938,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
if (options.allowNonZeroExit || result.exitCode === 0) {
return Effect.succeed(result);
}
const reason = classifyGitFailure(result.stderr);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
return Effect.fail(
new GitCommandError({
...gitCommandContext({ operation, cwd, args }),
...(reason === null ? {} : { reason }),
detail: options.fallbackErrorDetail ?? "Git command exited with a non-zero status.",
...(result.exitCode === null ? {} : { exitCode: result.exitCode }),
stdoutLength: result.stdout.length,
Expand Down
36 changes: 35 additions & 1 deletion packages/contracts/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,34 @@ export const VcsPullResult = Schema.Struct({
export type VcsPullResult = typeof VcsPullResult.Type;

// RPC / domain errors

// Well-known git failures, recognized from stderr at the driver and carried as
// a closed set of tags. Git's stderr itself stays off the error: it echoes argv
// and remote URLs, which can hold credentials.
export const GitCommandFailureReason = Schema.Literals([
"authentication_failed",
"branch_already_exists",
"branch_checked_out_in_worktree",
"not_a_repository",
"path_already_exists",
"remote_unreachable",
"tag_would_be_clobbered",
]);
export type GitCommandFailureReason = typeof GitCommandFailureReason.Type;

const GIT_COMMAND_FAILURE_REASON_MESSAGES: Record<GitCommandFailureReason, string> = {
authentication_failed: "Git could not authenticate with the remote.",
branch_already_exists: "A branch with that name already exists.",
branch_checked_out_in_worktree: "That branch is already checked out in another worktree.",
not_a_repository: "That directory is not a Git repository.",
path_already_exists: "The target path already exists.",
remote_unreachable: "The remote could not be reached.",
tag_would_be_clobbered: "A local tag differs from the remote tag and would be overwritten.",
};

export const gitCommandFailureReasonMessage = (reason: GitCommandFailureReason): string =>
GIT_COMMAND_FAILURE_REASON_MESSAGES[reason];

export class GitCommandError extends Schema.TaggedErrorClass<GitCommandError>()("GitCommandError", {
operation: Schema.String,
command: Schema.String,
Expand All @@ -344,11 +372,17 @@ export class GitCommandError extends Schema.TaggedErrorClass<GitCommandError>()(
stdoutLength: Schema.optional(Schema.Number),
stderrLength: Schema.optional(Schema.Number),
outputLength: Schema.optional(Schema.Number),
reason: Schema.optional(GitCommandFailureReason),
detail: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {
override get message(): string {
return `Git command failed in ${this.operation} (${this.cwd}): ${this.detail}`;
// Callers pass details both with and without closing punctuation, so the
// reason needs its own sentence break rather than running into them.
const separator = /[.!?]$/.test(this.detail) ? " " : ". ";
const reason =
this.reason === undefined ? "" : `${separator}${gitCommandFailureReasonMessage(this.reason)}`;
return `Git command failed in ${this.operation} (${this.cwd}): ${this.detail}${reason}`;
Comment thread
cursor[bot] marked this conversation as resolved.
}
}

Expand Down
Loading