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
14 changes: 8 additions & 6 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1577,16 +1577,18 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
yield* initRepo(repoDir);

const { manager } = yield* makeManager();
const errorMessage = yield* runStackedAction(manager, {
const error = yield* runStackedAction(manager, {
cwd: repoDir,
action: "commit",
featureBranch: true,
}).pipe(
Effect.flip,
Effect.map((error) => error.message),
);
}).pipe(Effect.flip);

expect(errorMessage).toContain("no changes to commit");
expect(error).toMatchObject({
_tag: "GitManagerError",
operation: "runFeatureBranchStep",
cwd: repoDir,
});
expect(error.message).toContain("no changes to commit");
}),
);

Expand Down
143 changes: 86 additions & 57 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,14 +320,6 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo {
};
}

function gitManagerError(operation: string, detail: string, cause?: unknown): GitManagerError {
return new GitManagerError({
operation,
detail,
...(cause !== undefined ? { cause } : {}),
});
}

function limitContext(value: string, maxChars: number): string {
if (value.length <= maxChars) return value;
return `${value.slice(0, maxChars)}\n\n[truncated]`;
Expand Down Expand Up @@ -535,17 +527,27 @@ export const make = Effect.gen(function* () {

const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd });
const serverSettingsService = yield* ServerSettings.ServerSettingsService;
const randomUUIDv4 = crypto.randomUUIDv4.pipe(
Effect.mapError((cause) =>
gitManagerError("randomUUIDv4", "Failed to generate Git operation identifier.", cause),
),
);
const randomUUIDv4 = (cwd: string) =>
crypto.randomUUIDv4.pipe(
Effect.mapError(
(cause) =>
new GitManagerError({
operation: "randomUUIDv4",
cwd,
detail: "Failed to generate Git operation identifier.",
cause,
}),
),
);

const createProgressEmitter = (
input: { cwd: string; action: GitStackedAction },
options?: GitRunStackedActionOptions,
) =>
(options?.actionId === undefined ? randomUUIDv4 : Effect.succeed(options.actionId)).pipe(
(options?.actionId === undefined
? randomUUIDv4(input.cwd)
: Effect.succeed(options.actionId)
).pipe(
Effect.map((actionId) => {
const reporter = options?.progressReporter;
const emit = (event: GitActionProgressPayload) =>
Expand Down Expand Up @@ -1284,16 +1286,18 @@ export const make = Effect.gen(function* () {
const details = yield* gitCore.statusDetails(cwd);
const branch = details.branch ?? fallbackBranch;
if (!branch) {
return yield* gitManagerError(
"runPrStep",
"Cannot create a pull request from detached HEAD.",
);
return yield* new GitManagerError({
operation: "runPrStep",
cwd,
detail: "Cannot create a pull request from detached HEAD.",
});
}
if (!details.hasUpstream) {
return yield* gitManagerError(
"runPrStep",
"Current branch has not been pushed. Push before creating a PR.",
);
return yield* new GitManagerError({
operation: "runPrStep",
cwd,
detail: "Current branch has not been pushed. Push before creating a PR.",
});
}

const headContext = yield* resolveBranchHeadContext(cwd, {
Expand Down Expand Up @@ -1332,14 +1336,21 @@ export const make = Effect.gen(function* () {
modelSelection,
});

const bodyFile = path.join(tempDir, `t3code-pr-body-${process.pid}-${yield* randomUUIDv4}.md`);
yield* fileSystem
.writeFileString(bodyFile, generated.body)
.pipe(
Effect.mapError((cause) =>
gitManagerError("runPrStep", "Failed to write pull request body temp file.", cause),
),
);
const bodyFile = path.join(
tempDir,
`t3code-pr-body-${process.pid}-${yield* randomUUIDv4(cwd)}.md`,
);
yield* fileSystem.writeFileString(bodyFile, generated.body).pipe(
Effect.mapError(
(cause) =>
new GitManagerError({
operation: "runPrStep",
cwd,
detail: "Failed to write pull request body temp file.",
cause,
}),
),
);
yield* emit({
kind: "phase_started",
phase: "pr",
Expand Down Expand Up @@ -1541,10 +1552,12 @@ export const make = Effect.gen(function* () {
};
}
if (existingBranchBeforeFetchPath === rootWorktreePath) {
return yield* gitManagerError(
"preparePullRequestThread",
"This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.",
);
return yield* new GitManagerError({
operation: "preparePullRequestThread",
cwd: input.cwd,
detail:
"This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.",
});
}

yield* materializePullRequestHeadBranch(
Expand All @@ -1569,10 +1582,12 @@ export const make = Effect.gen(function* () {
};
}
if (existingBranchAfterFetchPath === rootWorktreePath) {
return yield* gitManagerError(
"preparePullRequestThread",
"This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.",
);
return yield* new GitManagerError({
operation: "preparePullRequestThread",
cwd: input.cwd,
detail:
"This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.",
});
}

const worktree = yield* gitCore.createWorktree({
Expand Down Expand Up @@ -1607,10 +1622,11 @@ export const make = Effect.gen(function* () {
modelSelection,
});
if (!suggestion) {
return yield* gitManagerError(
"runFeatureBranchStep",
"Cannot create a feature branch because there are no changes to commit.",
);
return yield* new GitManagerError({
operation: "runFeatureBranchStep",
cwd,
detail: "Cannot create a feature branch because there are no changes to commit.",
});
}

const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject);
Expand Down Expand Up @@ -1647,16 +1663,18 @@ export const make = Effect.gen(function* () {
const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr";

if (input.featureBranch && !wantsCommit) {
return yield* gitManagerError(
"runStackedAction",
"Feature-branch checkout is only supported for commit actions.",
);
return yield* new GitManagerError({
operation: "runStackedAction",
cwd: input.cwd,
detail: "Feature-branch checkout is only supported for commit actions.",
});
}
if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) {
return yield* gitManagerError(
"runStackedAction",
"Commit local changes before creating a PR.",
);
return yield* new GitManagerError({
operation: "runStackedAction",
cwd: input.cwd,
detail: "Commit local changes before creating a PR.",
});
}

const phases: GitActionProgressPhase[] = [
Expand All @@ -1672,13 +1690,18 @@ export const make = Effect.gen(function* () {
});

if (!input.featureBranch && wantsPush && !initialStatus.branch) {
return yield* gitManagerError("runStackedAction", "Cannot push from detached HEAD.");
return yield* new GitManagerError({
operation: "runStackedAction",
cwd: input.cwd,
detail: "Cannot push from detached HEAD.",
});
}
if (!input.featureBranch && wantsPr && !initialStatus.branch) {
return yield* gitManagerError(
"runStackedAction",
"Cannot create a pull request from detached HEAD.",
);
return yield* new GitManagerError({
operation: "runStackedAction",
cwd: input.cwd,
detail: "Cannot create a pull request from detached HEAD.",
});
}

let branchStep: { status: "created" | "skipped_not_requested"; name?: string };
Expand All @@ -1687,8 +1710,14 @@ export const make = Effect.gen(function* () {

const modelSelection = yield* serverSettingsService.getSettings.pipe(
Effect.map((settings) => settings.textGenerationModelSelection),
Effect.mapError((cause) =>
gitManagerError("runStackedAction", "Failed to get server settings.", cause),
Effect.mapError(
(cause) =>
new GitManagerError({
operation: "runStackedAction",
cwd: input.cwd,
detail: "Failed to get server settings.",
cause,
}),
),
);

Expand Down
59 changes: 58 additions & 1 deletion apps/server/src/git/GitWorkflowService.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { assert, describe, it, vi } from "@effect/vitest";
import { assert, describe, expect, it, vi } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";

import { VcsRepositoryDetectionError } from "@t3tools/contracts";

import * as GitManager from "./GitManager.ts";
import * as GitWorkflowService from "./GitWorkflowService.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
Expand Down Expand Up @@ -132,4 +134,59 @@ describe("GitWorkflowService", () => {
),
),
);

it.effect("structures workflow detection failures without exposing upstream details", () => {
const cause = new VcsRepositoryDetectionError({
operation: "VcsDriverRegistry.detect",
cwd: "/repo",
detail: "upstream detail must stay in the cause chain",
});

return Effect.gen(function* () {
const workflow = yield* GitWorkflowService.GitWorkflowService;
const error = yield* workflow.status({ cwd: "/repo" }).pipe(Effect.flip);

expect(error).toMatchObject({
_tag: "GitManagerError",
operation: "GitWorkflowService.status",
cwd: "/repo",
detail: "Failed to detect a VCS repository for this Git workflow.",
});
expect(error.message).not.toContain(cause.detail);
}).pipe(
Effect.provide(
makeLayer({
detect: () => Effect.fail(cause),
}),
),
);
});

it.effect("structures command detection failures without exposing upstream details", () => {
const cause = new VcsRepositoryDetectionError({
operation: "VcsDriverRegistry.detect",
cwd: "/repo",
detail: "upstream command detail must stay in the cause chain",
});

return Effect.gen(function* () {
const workflow = yield* GitWorkflowService.GitWorkflowService;
const error = yield* workflow.listRefs({ cwd: "/repo" }).pipe(Effect.flip);

expect(error).toMatchObject({
_tag: "GitCommandError",
operation: "GitWorkflowService.listRefs",
command: "vcs-route",
cwd: "/repo",
detail: "Failed to detect a VCS repository for this Git command.",
});
expect(error.message).not.toContain(cause.detail);
}).pipe(
Effect.provide(
makeLayer({
detect: () => Effect.fail(cause),
}),
),
);
});
});
Loading
Loading