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
52 changes: 52 additions & 0 deletions apps/server/src/git/Layers/CodexTextGeneration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,58 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
),
);

it.effect("includes additional commit message instructions when provided", () =>
withFakeCodexEnv(
{
output: JSON.stringify({
subject: "Add important change",
body: "",
}),
stdinMustContain: "- Use Conventional Commits. Mention ticket IDs when present.",
},
Effect.gen(function* () {
const textGeneration = yield* TextGeneration;

const generated = yield* textGeneration.generateCommitMessage({
cwd: process.cwd(),
branch: "feature/codex-effect",
stagedSummary: "M README.md",
stagedPatch: "diff --git a/README.md b/README.md",
commitMessageInstructions: "Use Conventional Commits. Mention ticket IDs when present.",
});

expect(generated.subject).toBe("Add important change");
}),
),
);

it.effect("includes custom commit message rules alongside branch generation", () =>
withFakeCodexEnv(
{
output: JSON.stringify({
subject: "Add important change",
body: "",
branch: "feature/add-important-change",
}),
stdinMustContain: "- Use Conventional Commits.",
},
Effect.gen(function* () {
const textGeneration = yield* TextGeneration;

const generated = yield* textGeneration.generateCommitMessage({
cwd: process.cwd(),
branch: "feature/codex-effect",
stagedSummary: "M README.md",
stagedPatch: "diff --git a/README.md b/README.md",
commitMessageInstructions: "Use Conventional Commits.",
includeBranch: true,
});

expect(generated.branch).toBe("feature/add-important-change");
}),
),
);

it.effect("generates PR content and trims markdown body", () =>
withFakeCodexEnv(
{
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/git/Layers/CodexTextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,15 @@ const makeCodexTextGeneration = Effect.gen(function* () {

const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = (input) => {
const wantsBranch = input.includeBranch === true;
const commitMessageInstructions = input.commitMessageInstructions?.trim() ?? "";
const commitMessageInstructionRules =
commitMessageInstructions.length > 0
? commitMessageInstructions
.split(/\r?\n/g)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => `- ${line}`)
: [];

const prompt = [
"You write concise git commit messages.",
Expand All @@ -327,6 +336,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
? ["- branch must be a short semantic git branch fragment for this change"]
: []),
"- capture the primary user-visible or developer-visible change",
...commitMessageInstructionRules,
"",
`Branch: ${input.branch ?? "(detached)"}`,
"",
Expand Down
34 changes: 34 additions & 0 deletions apps/server/src/git/Layers/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ interface FakeGitTextGeneration {
branch: string | null;
stagedSummary: string;
stagedPatch: string;
commitMessageInstructions?: string;
includeBranch?: boolean;
}) => Effect.Effect<
{ subject: string; body: string; branch?: string | undefined },
Expand Down Expand Up @@ -450,6 +451,7 @@ function runStackedAction(
cwd: string;
action: "commit" | "commit_push" | "commit_push_pr";
commitMessage?: string;
commitMessageInstructions?: string;
featureBranch?: boolean;
filePaths?: readonly string[];
},
Expand Down Expand Up @@ -768,6 +770,38 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
}),
);

it.effect("forwards commit message instructions for generated commits", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ninstructions\n");
let receivedInstructions: string | undefined;

const { manager } = yield* makeManager({
textGeneration: {
generateCommitMessage: (input) =>
Effect.sync(() => {
receivedInstructions = input.commitMessageInstructions;
return {
subject: "Implement stacked git actions",
body: "",
...(input.includeBranch ? { branch: "feature/implement-stacked-git-actions" } : {}),
};
}),
},
});

const result = yield* runStackedAction(manager, {
cwd: repoDir,
action: "commit",
commitMessageInstructions: "Use Conventional Commits.",
});

expect(result.commit.status).toBe("created");
expect(receivedInstructions).toBe("Use Conventional Commits.");
}),
);

it.effect("commits only selected files when filePaths is provided", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/git/Layers/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,7 @@ export const makeGitManager = Effect.gen(function* () {
cwd: string;
branch: string | null;
commitMessage?: string;
commitMessageInstructions?: string;
/** When true, also produce a semantic feature branch name. */
includeBranch?: boolean;
filePaths?: readonly string[];
Expand Down Expand Up @@ -664,6 +665,9 @@ export const makeGitManager = Effect.gen(function* () {
branch: input.branch,
stagedSummary: limitContext(context.stagedSummary, 8_000),
stagedPatch: limitContext(context.stagedPatch, 50_000),
...(input.commitMessageInstructions
? { commitMessageInstructions: input.commitMessageInstructions }
: {}),
...(input.includeBranch ? { includeBranch: true } : {}),
})
.pipe(Effect.map((result) => sanitizeCommitMessage(result)));
Expand All @@ -680,6 +684,7 @@ export const makeGitManager = Effect.gen(function* () {
cwd: string,
branch: string | null,
commitMessage?: string,
commitMessageInstructions?: string,
preResolvedSuggestion?: CommitAndBranchSuggestion,
filePaths?: readonly string[],
) =>
Expand All @@ -690,6 +695,7 @@ export const makeGitManager = Effect.gen(function* () {
cwd,
branch,
...(commitMessage ? { commitMessage } : {}),
...(commitMessageInstructions ? { commitMessageInstructions } : {}),
...(filePaths ? { filePaths } : {}),
}));
if (!suggestion) {
Expand Down Expand Up @@ -971,13 +977,15 @@ export const makeGitManager = Effect.gen(function* () {
cwd: string,
branch: string | null,
commitMessage?: string,
commitMessageInstructions?: string,
filePaths?: readonly string[],
) =>
Effect.gen(function* () {
const suggestion = yield* resolveCommitAndBranchSuggestion({
cwd,
branch,
...(commitMessage ? { commitMessage } : {}),
...(commitMessageInstructions ? { commitMessageInstructions } : {}),
...(filePaths ? { filePaths } : {}),
includeBranch: true,
});
Expand Down Expand Up @@ -1027,6 +1035,7 @@ export const makeGitManager = Effect.gen(function* () {
input.cwd,
initialStatus.branch,
input.commitMessage,
input.commitMessageInstructions,
input.filePaths,
);
branchStep = result.branchStep;
Expand All @@ -1042,6 +1051,7 @@ export const makeGitManager = Effect.gen(function* () {
input.cwd,
currentBranch,
commitMessageForStep,
input.commitMessageInstructions,
preResolvedCommitSuggestion,
input.filePaths,
);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/git/Services/TextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface CommitMessageGenerationInput {
branch: string | null;
stagedSummary: string;
stagedPatch: string;
commitMessageInstructions?: string;
/** When true, the model also returns a semantic branch name for the change. */
includeBranch?: boolean;
}
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/appSettings.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";

import {
MAX_COMMIT_MESSAGE_INSTRUCTIONS_LENGTH,
DEFAULT_TIMESTAMP_FORMAT,
getAppModelOptions,
normalizeCustomModelSlugs,
Expand Down Expand Up @@ -64,3 +65,9 @@ describe("timestamp format defaults", () => {
expect(DEFAULT_TIMESTAMP_FORMAT).toBe("locale");
});
});

describe("commit message instruction defaults", () => {
it("exports the supported instruction length limit", () => {
expect(MAX_COMMIT_MESSAGE_INSTRUCTIONS_LENGTH).toBe(2_000);
});
});
6 changes: 5 additions & 1 deletion apps/web/src/appSettings.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { useCallback } from "react";
import { Option, Schema } from "effect";
import { type ProviderKind } from "@t3tools/contracts";
import { GIT_COMMIT_MESSAGE_INSTRUCTIONS_MAX_LENGTH, type ProviderKind } from "@t3tools/contracts";
import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
import { useLocalStorage } from "./hooks/useLocalStorage";

const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
const MAX_CUSTOM_MODEL_COUNT = 32;
export const MAX_CUSTOM_MODEL_LENGTH = 256;
export const MAX_COMMIT_MESSAGE_INSTRUCTIONS_LENGTH = GIT_COMMIT_MESSAGE_INSTRUCTIONS_MAX_LENGTH;
export const TIMESTAMP_FORMAT_OPTIONS = ["locale", "12-hour", "24-hour"] as const;
export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number];
export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";
Expand All @@ -25,6 +26,9 @@ const AppSettingsSchema = Schema.Struct({
Schema.withConstructorDefault(() => Option.some("local")),
),
confirmThreadDelete: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))),
commitMessageInstructions: Schema.String.check(
Schema.isMaxLength(MAX_COMMIT_MESSAGE_INSTRUCTIONS_LENGTH),
).pipe(Schema.withConstructorDefault(() => Option.some(""))),
enableAssistantStreaming: Schema.Boolean.pipe(
Schema.withConstructorDefault(() => Option.some(false)),
),
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/components/GitActionsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/
import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react";
import { GitHubIcon } from "./Icons";
import { useAppSettings } from "~/appSettings";
import {
buildGitActionProgressStages,
buildMenuItems,
Expand Down Expand Up @@ -154,6 +155,7 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) {
}

export default function GitActionsControl({ gitCwd, activeThreadId }: GitActionsControlProps) {
const { settings } = useAppSettings();
const threadToastData = useMemo(
() => (activeThreadId ? { threadId: activeThreadId } : undefined),
[activeThreadId],
Expand Down Expand Up @@ -218,6 +220,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
const quickActionDisabledReason = quickAction.disabled
? (quickAction.hint ?? "This action is currently unavailable.")
: null;
const commitMessageInstructions = settings.commitMessageInstructions.trim();
const pendingDefaultBranchActionCopy = pendingDefaultBranchAction
? resolveDefaultBranchActionDialogCopy({
action: pendingDefaultBranchAction.action,
Expand Down Expand Up @@ -349,6 +352,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
const promise = runImmediateGitActionMutation.mutateAsync({
action,
...(commitMessage ? { commitMessage } : {}),
...(commitMessageInstructions ? { commitMessageInstructions } : {}),
...(featureBranch ? { featureBranch } : {}),
...(filePaths ? { filePaths } : {}),
});
Expand Down Expand Up @@ -440,13 +444,13 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
});
}
},

[
isDefaultBranch,
runImmediateGitActionMutation,
setPendingDefaultBranchAction,
threadToastData,
gitStatusForActions,
commitMessageInstructions,
],
);

Expand Down
55 changes: 54 additions & 1 deletion apps/web/src/lib/gitReactQuery.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { NativeApi } from "@t3tools/contracts";
import {
gitMutationKeys,
gitPreparePullRequestThreadMutationOptions,
gitPullMutationOptions,
gitRunStackedActionMutationOptions,
} from "./gitReactQuery";
import * as nativeApi from "../nativeApi";

afterEach(() => {
vi.restoreAllMocks();
});

describe("gitMutationKeys", () => {
it("scopes stacked action keys by cwd", () => {
Expand Down Expand Up @@ -45,4 +51,51 @@ describe("git mutation options", () => {
});
expect(options.mutationKey).toEqual(gitMutationKeys.preparePullRequestThread("/repo/a"));
});

it("forwards commit message instructions for stacked actions", async () => {
const runStackedAction = vi.fn().mockResolvedValue({});
vi.spyOn(nativeApi, "ensureNativeApi").mockReturnValue({
git: { runStackedAction },
} as unknown as NativeApi);

const options = gitRunStackedActionMutationOptions({ cwd: "/repo/a", queryClient });
const mutationFn = options.mutationFn;
expect(mutationFn).toBeDefined();
await mutationFn!(
{
action: "commit",
commitMessageInstructions: " Use Conventional Commits ",
},
{} as never,
);

expect(runStackedAction).toHaveBeenCalledWith({
cwd: "/repo/a",
action: "commit",
commitMessageInstructions: "Use Conventional Commits",
});
});

it("omits blank commit message instructions for stacked actions", async () => {
const runStackedAction = vi.fn().mockResolvedValue({});
vi.spyOn(nativeApi, "ensureNativeApi").mockReturnValue({
git: { runStackedAction },
} as unknown as NativeApi);

const options = gitRunStackedActionMutationOptions({ cwd: "/repo/a", queryClient });
const mutationFn = options.mutationFn;
expect(mutationFn).toBeDefined();
await mutationFn!(
{
action: "commit",
commitMessageInstructions: " ",
},
{} as never,
);

expect(runStackedAction).toHaveBeenCalledWith({
cwd: "/repo/a",
action: "commit",
});
});
});
6 changes: 6 additions & 0 deletions apps/web/src/lib/gitReactQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,20 +118,26 @@ export function gitRunStackedActionMutationOptions(input: {
mutationFn: async ({
action,
commitMessage,
commitMessageInstructions,
featureBranch,
filePaths,
}: {
action: GitStackedAction;
commitMessage?: string;
commitMessageInstructions?: string;
featureBranch?: boolean;
filePaths?: string[];
}) => {
const api = ensureNativeApi();
if (!input.cwd) throw new Error("Git action is unavailable.");
const trimmedCommitMessageInstructions = commitMessageInstructions?.trim();
return api.git.runStackedAction({
cwd: input.cwd,
action,
...(commitMessage ? { commitMessage } : {}),
...(trimmedCommitMessageInstructions
? { commitMessageInstructions: trimmedCommitMessageInstructions }
: {}),
...(featureBranch ? { featureBranch } : {}),
...(filePaths ? { filePaths } : {}),
});
Expand Down
Loading