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
6 changes: 5 additions & 1 deletion apps/mobile/src/features/terminal/terminalMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,11 @@ export function resolveProjectScriptTerminalId(input: {
}

export function projectScriptMenuLabel(script: ProjectScript): string {
return script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name;
const tags: string[] = [];
if (script.runOnWorktreeCreate) tags.push("setup");
if (script.runOnWorktreeRemove === true) tags.push("teardown");
if (script.runOnPrMerged === true) tags.push("pr-merged");
return tags.length > 0 ? `${script.name} (${tags.join(", ")})` : script.name;
}

export function projectScriptMenuIcon(icon: ProjectScript["icon"]) {
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/git/GitWorkflowService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,17 @@ import { VcsRepositoryDetectionError } from "@t3tools/contracts";

import * as GitManager from "./GitManager.ts";
import * as GitWorkflowService from "./GitWorkflowService.ts";
import * as ProjectLifecycleScriptRunner from "../project/ProjectLifecycleScriptRunner.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";

const lifecycleScriptRunnerMock = Layer.mock(
ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner,
)({
runWorktreeRemove: () => Effect.succeed({ status: "no-script" as const }),
runPrMerged: () => Effect.succeed({ status: "no-script" as const }),
});

function makeLayer(input: {
readonly detect: VcsDriverRegistry.VcsDriverRegistry["Service"]["detect"];
}) {
Expand All @@ -20,6 +28,7 @@ function makeLayer(input: {
),
Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
Layer.provide(Layer.mock(GitManager.GitManager)({})),
Layer.provide(lifecycleScriptRunnerMock),
);
}

Expand Down Expand Up @@ -100,6 +109,7 @@ describe("GitWorkflowService", () => {
status,
}),
),
Layer.provide(lifecycleScriptRunnerMock),
);

return Effect.gen(function* () {
Expand Down
72 changes: 71 additions & 1 deletion apps/server/src/git/GitWorkflowService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
} from "@t3tools/contracts";

import * as GitManager from "./GitManager.ts";
import * as ProjectLifecycleScriptRunner from "../project/ProjectLifecycleScriptRunner.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";

Expand Down Expand Up @@ -135,10 +136,42 @@ function nonRepositoryListRefs(): VcsListRefsResult {
};
}

function lifecycleScriptToGitCommandError(
operation: string,
input: VcsRemoveWorktreeInput,
error: ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunnerError,
): GitCommandError {
if (error._tag === "ProjectLifecycleScriptFailedError") {
const detailParts = [
error.message,
error.stderr.trim().length > 0 ? error.stderr.trim() : null,
error.stdout.trim().length > 0 ? error.stdout.trim() : null,
].filter((part): part is string => part !== null);
return new GitCommandError({
operation,
command: `lifecycle:${error.lifecycle}`,
cwd: input.path,
exitCode: error.exitCode ?? undefined,
failureKind: "unknown",
detail: detailParts.join("\n"),
cause: error,
});
}
return new GitCommandError({
operation,
command: `lifecycle:${error.lifecycle}`,
cwd: input.path,
failureKind: "unknown",
detail: error.message,
cause: error,
});
}

export const make = Effect.gen(function* () {
const registry = yield* VcsDriverRegistry.VcsDriverRegistry;
const git = yield* GitVcsDriver.GitVcsDriver;
const gitManager = yield* GitManager.GitManager;
const lifecycleScriptRunner = yield* ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner;

const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* (
operation: string,
Expand Down Expand Up @@ -322,7 +355,44 @@ export const make = Effect.gen(function* () {
),
removeWorktree: (input) =>
ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe(
Effect.andThen(git.removeWorktree(input)),
Effect.andThen(
Effect.gen(function* () {
// Prefer the PR associated with the worktree branch (status cwd = worktree path).
const associatedPr = yield* gitManager.remoteStatus({ cwd: input.path }).pipe(
Effect.map((remote) => remote?.pr ?? null),
Effect.orElseSucceed(() => null),
);

// Teardown must finish successfully before the worktree directory is removed.
// PR-merged lifecycle is a separate trigger (status transition), not part of remove.
yield* lifecycleScriptRunner
.runWorktreeRemove({
projectCwd: input.cwd,
worktreePath: input.path,
pr: associatedPr
? {
number: associatedPr.number,
url: associatedPr.url,
title: associatedPr.title,
baseRef: associatedPr.baseRef,
headRef: associatedPr.headRef,
state: associatedPr.state,
}
: null,
})
.pipe(
Effect.mapError((error) =>
lifecycleScriptToGitCommandError(
"GitWorkflowService.removeWorktree",
input,
error,
),
),
);

yield* git.removeWorktree(input);
}),
),
),
createRef: (input) =>
ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe(
Expand Down
Loading
Loading