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
97 changes: 87 additions & 10 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,54 @@ const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({
} satisfies NodeJS.ProcessEnv);
const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100;

const COMMIT_SIGNING_FAILURE_PATTERNS = [
/gpg(?:2)?(?:\.exe)?: .*failed to sign/i,
/gpg failed to sign the data/i,
/signing failed:/i,
/failed to sign the data/i,
/pinentry.*(?:failed|error|not found|no such file|cancell?ed)/i,
/(?:failed|error|no such file|cancell?ed).*pinentry/i,
/inappropriate ioctl for device/i,
/cannot open \/dev\/tty/i,
/no secret key/i,
/secret key not available/i,
/ssh-keygen(?:\.exe)?:?.*(?:failed|error|couldn['’]t).*sign/i,
/couldn['’]t sign (?:message|data)/i,
/couldn['’]t load public key/i,
/no private key found for public key/i,
/load key .*: (?:invalid format|no such file or directory|permission denied)/i,
/agent refused operation/i,
] as const;

export function isCommitSigningFailureStderr(stderr: string): boolean {
return COMMIT_SIGNING_FAILURE_PATTERNS.some((pattern) => pattern.test(stderr));
}

/** Longer than any real git error line, short enough to keep logs readable. */
const GIT_STDERR_LOG_LIMIT = 2000;

/**
* Strip credentials from git output so it can be logged.
*
* git echoes the remote URL it used, and those URLs routinely carry secrets
* (`https://x-access-token:TOKEN@github.com/...`), so raw stderr must never
* reach a log. Redacts the userinfo component of any URL plus bare tokens that
* commonly appear on their own.
*/
export function redactGitOutput(stderr: string): string {
return (
stderr
.slice(0, GIT_STDERR_LOG_LIMIT)
.replace(/([a-zA-Z][\w+.-]*:\/\/)[^/@\s]*@/g, "$1<redacted>@")
.replace(/\b(gh[pousr]_|github_pat_|glpat-)[A-Za-z0-9_-]+/g, "$1<redacted>")
// Take the whole value, not just the scheme word: `Authorization: Bearer X`
// must not redact `Bearer` and leave `X` behind.
.replace(/\b(Authorization)\s*[:=]\s*.*/gi, "$1: <redacted>")
.replace(/\b(Bearer|token)\s*[:=]?\s+\S+/gi, "$1 <redacted>")
);
}

const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitVcsDriver.GitStatusDetails>({
isRepo: false,
hasOriginRemote: false,
Expand Down Expand Up @@ -379,6 +427,7 @@ function gitCommandContext(
command: "git",
cwd: input.cwd,
argumentCount: input.args.length,
failureKind: "unknown" as const,
} as const;
}

Expand Down Expand Up @@ -1737,25 +1786,52 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
body,
options?: GitVcsDriver.GitCommitOptions,
) {
const args = ["commit", "-m", subject];
const args = ["commit"];
if (options?.disableSigning) {
args.push("--no-gpg-sign");
}
args.push("-m", subject);
const trimmedBody = body.trim();
if (trimmedBody.length > 0) {
args.push("-m", trimmedBody);
}
const progress =
options?.progress?.onOutputLine === undefined
? options?.progress
: {
...options.progress,
let hookFailed = false;
const progress: GitVcsDriver.ExecuteGitProgress = {
...(options?.progress?.onOutputLine
? {
onStdoutLine: (line: string) =>
options.progress?.onOutputLine?.({ stream: "stdout", text: line }) ?? Effect.void,
onStderrLine: (line: string) =>
options.progress?.onOutputLine?.({ stream: "stderr", text: line }) ?? Effect.void,
};
yield* executeGit("GitVcsDriver.commit.commit", cwd, args, {
}
: {}),
...(options?.progress?.onHookStarted
? { onHookStarted: options.progress.onHookStarted }
: {}),
onHookFinished: (input) => {
if (input.exitCode !== null && input.exitCode !== 0) {
hookFailed = true;
}
return options?.progress?.onHookFinished?.(input) ?? Effect.void;
},
};
const result = yield* executeGitWithStableDiagnostics("GitVcsDriver.commit.commit", cwd, args, {
allowNonZeroExit: true,
...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
...(progress ? { progress } : {}),
}).pipe(Effect.asVoid);
progress,
});
if (result.exitCode !== 0) {
return yield* new GitCommandError({
...gitCommandContext({ operation: "GitVcsDriver.commit.commit", cwd, args }),
detail: "Git command exited with a non-zero status.",
...(result.exitCode === null ? {} : { exitCode: result.exitCode }),
stdoutLength: result.stdout.length,
stderrLength: result.stderr.length,
...(!options?.disableSigning && !hookFailed && isCommitSigningFailureStderr(result.stderr)
? { failureKind: "commit_signing_failed" as const }
: {}),
});
}
const commitSha = yield* runGitStdout("GitVcsDriver.commit.revParseHead", cwd, [
"rev-parse",
"HEAD",
Expand Down Expand Up @@ -2107,6 +2183,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
operation: "GitVcsDriver.getReviewDiffPreview.hash",
command: "crypto.digest SHA-256",
cwd: input.cwd,
failureKind: "unknown",
detail: "Failed to hash review diff.",
cause,
}),
Expand Down
77 changes: 0 additions & 77 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ import {
ProjectWriteFileError,
RelayClientInstallFailedError,
type RelayClientInstallProgressEvent,
OrchestrationReplayEventsError,
type FilesystemBrowseFailure,
FilesystemBrowseError,
AssetWorkspaceContextNotFoundError,
Expand All @@ -62,7 +61,6 @@ import {
WS_METHODS,
WsRpcGroup,
} from "@t3tools/contracts";
import { clamp } from "effect/Number";
import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http";
import { RpcSerialization, RpcServer } from "effect/unstable/rpc";

Expand Down Expand Up @@ -105,7 +103,6 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
import * as GitWorkflowService from "./git/GitWorkflowService.ts";
import * as ReviewService from "./review/ReviewService.ts";
import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts";
Expand Down Expand Up @@ -356,7 +353,6 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([
[ORCHESTRATION_WS_METHODS.getTurnDiff, AuthOrchestrationReadScope],
[ORCHESTRATION_WS_METHODS.getThreadActivities, AuthOrchestrationReadScope],
[ORCHESTRATION_WS_METHODS.getFullThreadDiff, AuthOrchestrationReadScope],
[ORCHESTRATION_WS_METHODS.replayEvents, AuthOrchestrationReadScope],
[ORCHESTRATION_WS_METHODS.subscribeShell, AuthOrchestrationReadScope],
[ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope],
[ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope],
Expand Down Expand Up @@ -507,8 +503,6 @@ const makeWsRpcLayer = (
const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
const repositoryIdentityResolver =
yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
const sourceControlDiscovery = yield* SourceControlDiscovery.SourceControlDiscovery;
Expand Down Expand Up @@ -672,53 +666,6 @@ const makeWsRpcLayer = (
});
};

const enrichProjectEvent = (
event: OrchestrationEvent,
): Effect.Effect<OrchestrationEvent, never, never> => {
switch (event.type) {
case "project.created":
return repositoryIdentityResolver.resolve(event.payload.workspaceRoot).pipe(
Effect.map((repositoryIdentity) => ({
...event,
payload: {
...event.payload,
repositoryIdentity,
},
})),
);
case "project.meta-updated":
return Effect.gen(function* () {
const workspaceRoot =
event.payload.workspaceRoot ??
Option.match(
yield* projectionSnapshotQuery.getProjectShellById(event.payload.projectId),
{
onNone: () => null,
onSome: (project) => project.workspaceRoot,
},
) ??
null;
if (workspaceRoot === null) {
return event;
}

const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot);
return {
...event,
payload: {
...event.payload,
repositoryIdentity,
},
} satisfies OrchestrationEvent;
}).pipe(Effect.orElseSucceed(() => event));
default:
return Effect.succeed(event);
}
};

const enrichOrchestrationEvents = (events: ReadonlyArray<OrchestrationEvent>) =>
Effect.forEach(events, enrichProjectEvent, { concurrency: 4 });

const toShellStreamEvent = (
event: OrchestrationEvent,
): Effect.Effect<Option.Option<OrchestrationShellStreamEvent>, never, never> => {
Expand Down Expand Up @@ -1418,30 +1365,6 @@ const makeWsRpcLayer = (
),
{ "rpc.aggregate": "orchestration" },
),
[ORCHESTRATION_WS_METHODS.replayEvents]: (input) =>
observeRpcEffect(
ORCHESTRATION_WS_METHODS.replayEvents,
Stream.runCollect(
orchestrationEngine.readEvents(
clamp(input.fromSequenceExclusive, {
maximum: Number.MAX_SAFE_INTEGER,
minimum: 0,
}),
),
).pipe(
Effect.map((events) => Array.from(events)),
Effect.flatMap(enrichOrchestrationEvents),
Effect.map((events) => events.map(projectActivityEvent)),
Effect.mapError(
(cause) =>
new OrchestrationReplayEventsError({
message: "Failed to replay orchestration events",
cause,
}),
),
),
{ "rpc.aggregate": "orchestration" },
),
[ORCHESTRATION_WS_METHODS.subscribeShell]: (input) =>
observeRpcStreamEffect(
ORCHESTRATION_WS_METHODS.subscribeShell,
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/components/BranchToolbar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,19 @@ describe("resolveBranchTriggerLabel", () => {
).toBe("From main");
});

it("shows the bare branch name when reusing the selected worktree base branch", () => {
expect(
resolveBranchTriggerLabel({
activeWorktreePath: null,
effectiveEnvMode: "worktree",
resolvedActiveBranch: "main",
resolvedActiveBranchIsRemote: false,
startFromOrigin: true,
reuseBaseBranch: true,
}),
).toBe("main");
});

it("does not duplicate the origin prefix for an explicit remote ref", () => {
expect(
resolveBranchTriggerLabel({
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/components/BranchToolbar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,18 +173,25 @@ export function resolveBranchTriggerLabel(input: {
resolvedActiveBranch: string | null;
resolvedActiveBranchIsRemote: boolean | null;
startFromOrigin: boolean;
reuseBaseBranch?: boolean;
}): string {
const {
activeWorktreePath,
effectiveEnvMode,
resolvedActiveBranch,
resolvedActiveBranchIsRemote,
startFromOrigin,
reuseBaseBranch = false,
} = input;
if (!resolvedActiveBranch) {
return "Select ref";
}
// Reused base branch is checked out as-is (Tim #15); otherwise "From X" for
// new worktree branches, with optional origin/ prefix (upstream #4680).
if (effectiveEnvMode === "worktree" && !activeWorktreePath) {
if (reuseBaseBranch) {
return resolvedActiveBranch;
}
const baseRef =
startFromOrigin && resolvedActiveBranchIsRemote === false
? `origin/${resolvedActiveBranch}`
Expand Down
Loading
Loading