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
130 changes: 128 additions & 2 deletions apps/server/src/git/Layers/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import { it } from "@effect/vitest";
import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect";
import { expect } from "vitest";
import type { GitActionProgressEvent, ModelSelection } from "@t3tools/contracts";
import type {
GitActionProgressEvent,
GitPreparePullRequestThreadInput,
ModelSelection,
ThreadId,
} from "@t3tools/contracts";

import { GitCommandError, GitHubCliError, TextGenerationError } from "../Errors.ts";
import { type GitManagerShape } from "../Services/GitManager.ts";
Expand All @@ -21,6 +26,11 @@ import { GitCore } from "../Services/GitCore.ts";
import { makeGitManager } from "./GitManager.ts";
import { ServerConfig } from "../../config.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import {
ProjectSetupScriptRunner,
type ProjectSetupScriptRunnerInput,
type ProjectSetupScriptRunnerShape,
} from "../../projectScripts/Services/ProjectSetupScriptRunner.ts";

interface FakeGhScenario {
prListSequence?: string[];
Expand Down Expand Up @@ -496,14 +506,15 @@ function resolvePullRequest(manager: GitManagerShape, input: { cwd: string; refe

function preparePullRequestThread(
manager: GitManagerShape,
input: { cwd: string; reference: string; mode: "local" | "worktree" },
input: GitPreparePullRequestThreadInput,
) {
return manager.preparePullRequestThread(input);
}

function makeManager(input?: {
ghScenario?: FakeGhScenario;
textGeneration?: Partial<FakeGitTextGeneration>;
setupScriptRunner?: ProjectSetupScriptRunnerShape;
}) {
const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario);
const textGeneration = createTextGeneration(input?.textGeneration);
Expand All @@ -521,6 +532,12 @@ function makeManager(input?: {
const managerLayer = Layer.mergeAll(
Layer.succeed(GitHubCli, gitHubCli),
Layer.succeed(TextGeneration, textGeneration),
Layer.succeed(
ProjectSetupScriptRunner,
input?.setupScriptRunner ?? {
runForThread: () => Effect.succeed({ status: "no-script" as const }),
},
),
gitCoreLayer,
serverSettingsLayer,
).pipe(Layer.provideMerge(NodeServices.layer));
Expand All @@ -531,6 +548,8 @@ function makeManager(input?: {
);
}

const asThreadId = (threadId: string) => threadId as ThreadId;

const GitManagerTestLayer = GitCoreLive.pipe(
Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })),
Layer.provideMerge(NodeServices.layer),
Expand Down Expand Up @@ -1560,6 +1579,59 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
}),
);

it.effect("launches setup only when creating a new PR worktree", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
yield* runGit(repoDir, ["checkout", "-b", "feature/pr-worktree-setup"]);
fs.writeFileSync(path.join(repoDir, "setup.txt"), "setup\n");
yield* runGit(repoDir, ["add", "setup.txt"]);
yield* runGit(repoDir, ["commit", "-m", "PR worktree setup branch"]);
yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-worktree-setup"]);
yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/177/head"]);
yield* runGit(repoDir, ["checkout", "main"]);

const setupCalls: ProjectSetupScriptRunnerInput[] = [];
const { manager } = yield* makeManager({
ghScenario: {
pullRequest: {
number: 177,
title: "Worktree setup PR",
url: "https://github.com/pingdotgg/codething-mvp/pull/177",
baseRefName: "main",
headRefName: "feature/pr-worktree-setup",
state: "open",
},
},
setupScriptRunner: {
runForThread: (setupInput) =>
Effect.sync(() => {
setupCalls.push(setupInput);
return { status: "no-script" as const };
}),
},
});

const result = yield* preparePullRequestThread(manager, {
cwd: repoDir,
reference: "177",
mode: "worktree",
threadId: asThreadId("thread-pr-setup"),
});

expect(result.worktreePath).not.toBeNull();
expect(setupCalls).toHaveLength(1);
expect(setupCalls[0]).toEqual({
threadId: "thread-pr-setup",
projectCwd: repoDir,
worktreePath: result.worktreePath as string,
});
}),
);

it.effect("preserves fork upstream tracking when preparing a worktree PR thread", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down Expand Up @@ -1744,6 +1816,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
const worktreePath = path.join(repoDir, "..", `pr-existing-${Date.now()}`);
yield* runGit(repoDir, ["worktree", "add", worktreePath, "feature/pr-existing-worktree"]);

const setupCalls: ProjectSetupScriptRunnerInput[] = [];
const { manager } = yield* makeManager({
ghScenario: {
pullRequest: {
Expand All @@ -1755,18 +1828,27 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
state: "open",
},
},
setupScriptRunner: {
runForThread: (setupInput) =>
Effect.sync(() => {
setupCalls.push(setupInput);
return { status: "no-script" as const };
}),
},
});

const result = yield* preparePullRequestThread(manager, {
cwd: repoDir,
reference: "78",
mode: "worktree",
threadId: asThreadId("thread-pr-existing-worktree"),
});

expect(result.worktreePath && fs.realpathSync.native(result.worktreePath)).toBe(
fs.realpathSync.native(worktreePath),
);
expect(result.branch).toBe("feature/pr-existing-worktree");
expect(setupCalls).toHaveLength(0);
}),
);

Expand Down Expand Up @@ -1946,6 +2028,50 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
}),
);

it.effect("does not fail PR worktree prep when setup terminal startup fails", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
yield* runGit(repoDir, ["checkout", "-b", "feature/pr-setup-failure"]);
fs.writeFileSync(path.join(repoDir, "setup-failure.txt"), "setup failure\n");
yield* runGit(repoDir, ["add", "setup-failure.txt"]);
yield* runGit(repoDir, ["commit", "-m", "PR setup failure branch"]);
yield* runGit(repoDir, ["push", "-u", "origin", "feature/pr-setup-failure"]);
yield* runGit(repoDir, ["push", "origin", "HEAD:refs/pull/184/head"]);
yield* runGit(repoDir, ["checkout", "main"]);

const { manager } = yield* makeManager({
ghScenario: {
pullRequest: {
number: 184,
title: "Setup failure PR",
url: "https://github.com/pingdotgg/codething-mvp/pull/184",
baseRefName: "main",
headRefName: "feature/pr-setup-failure",
state: "open",
},
},
setupScriptRunner: {
runForThread: () => Effect.fail(new Error("terminal start failed")),
},
});

const result = yield* preparePullRequestThread(manager, {
cwd: repoDir,
reference: "184",
mode: "worktree",
threadId: asThreadId("thread-pr-setup-failure"),
});

expect(result.branch).toBe("feature/pr-setup-failure");
expect(result.worktreePath).not.toBeNull();
expect(fs.existsSync(result.worktreePath as string)).toBe(true);
}),
);

it.effect("rejects worktree prep when the PR head branch is checked out in the main repo", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down
22 changes: 22 additions & 0 deletions apps/server/src/git/Layers/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import { GitCore } from "../Services/GitCore.ts";
import { GitHubCli } from "../Services/GitHubCli.ts";
import { TextGeneration } from "../Services/TextGeneration.ts";
import { ProjectSetupScriptRunner } from "../../projectScripts/Services/ProjectSetupScriptRunner.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import type { GitManagerServiceError } from "../Errors.ts";

Expand Down Expand Up @@ -365,6 +366,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
const gitCore = yield* GitCore;
const gitHubCli = yield* GitHubCli;
const textGeneration = yield* TextGeneration;
const projectSetupScriptRunner = yield* ProjectSetupScriptRunner;
const serverSettingsService = yield* ServerSettingsService;

const createProgressEmitter = (
Expand Down Expand Up @@ -993,6 +995,25 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn(
"preparePullRequestThread",
)(function* (input) {
const maybeRunSetupScript = (worktreePath: string) => {
if (!input.threadId) {
return Effect.void;
}
return projectSetupScriptRunner
.runForThread({
threadId: input.threadId,
projectCwd: input.cwd,
worktreePath,
})
.pipe(
Effect.catch((error) =>
Effect.logWarning(
`GitManager.preparePullRequestThread: failed to launch worktree setup script for thread ${input.threadId} in ${worktreePath}: ${error.message}`,
).pipe(Effect.asVoid),
),
);
};

const normalizedReference = normalizePullRequestReference(input.reference);
const rootWorktreePath = canonicalizeExistingPath(input.cwd);
const pullRequestSummary = yield* gitHubCli.getPullRequest({
Expand Down Expand Up @@ -1124,6 +1145,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
path: null,
});
yield* ensureExistingWorktreeUpstream(worktree.worktree.path);
yield* maybeRunSetupScript(worktree.worktree.path);

return {
pullRequest,
Expand Down
Loading
Loading