From 12d863d7021e3dc81e5608bf9e5baab2787c0b37 Mon Sep 17 00:00:00 2001 From: Riccardo Palleschi Date: Mon, 9 Mar 2026 18:43:16 +0100 Subject: [PATCH 1/3] chore(workspace): add local node pin and upstream sync helper --- .node-version | 1 + scripts/rebase-head-onto-upstream.sh | 31 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 .node-version create mode 100755 scripts/rebase-head-onto-upstream.sh diff --git a/.node-version b/.node-version new file mode 100644 index 000000000..32f8c50de --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24.13.1 diff --git a/scripts/rebase-head-onto-upstream.sh b/scripts/rebase-head-onto-upstream.sh new file mode 100755 index 000000000..4fa73eb9f --- /dev/null +++ b/scripts/rebase-head-onto-upstream.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +set -euo pipefail + +current_branch="$(git branch --show-current)" +if [[ -z "$current_branch" ]]; then + echo "Cannot update main from a detached HEAD." >&2 + exit 1 +fi + +upstream_remote="upstream" +origin_remote="origin" +upstream_main_ref="$upstream_remote/main" + +printf "Fetching %s and %s\n" "$upstream_remote" "$origin_remote" +git fetch --quiet --no-tags "$upstream_remote" +git fetch --quiet --no-tags "$origin_remote" + +if [[ "$current_branch" == "main" ]]; then + printf "Fast-forwarding main to %s\n" "$upstream_main_ref" + git merge --ff-only "$upstream_main_ref" + printf "Pushing main to %s\n" "$origin_remote" + exec git push "$origin_remote" main +fi + +printf "Fast-forwarding main to %s\n" "$upstream_main_ref" +git switch --quiet main +git merge --ff-only "$upstream_main_ref" +printf "Pushing main to %s\n" "$origin_remote" +git push "$origin_remote" main +git switch --quiet "$current_branch" From 8af745d1a340caba7f9783b4f9c9f2ee11507a54 Mon Sep 17 00:00:00 2001 From: Riccardo Palleschi Date: Fri, 13 Mar 2026 14:28:58 +0100 Subject: [PATCH 2/3] Add custom commit-message instructions to git actions - Add settings UI and persistence for commit message instructions - Validate and trim instructions in shared contracts and web mutation layer - Forward instructions through GitManager to Codex prompt generation with tests --- .../git/Layers/CodexTextGeneration.test.ts | 52 ++++++++++++++++++ .../src/git/Layers/CodexTextGeneration.ts | 10 ++++ apps/server/src/git/Layers/GitManager.test.ts | 34 ++++++++++++ apps/server/src/git/Layers/GitManager.ts | 10 ++++ .../server/src/git/Services/TextGeneration.ts | 1 + apps/web/src/appSettings.test.ts | 7 +++ apps/web/src/appSettings.ts | 6 +- apps/web/src/components/GitActionsControl.tsx | 6 +- apps/web/src/lib/gitReactQuery.test.ts | 55 ++++++++++++++++++- apps/web/src/lib/gitReactQuery.ts | 6 ++ apps/web/src/routes/_chat.settings.tsx | 49 ++++++++++++++++- packages/contracts/src/git.ts | 6 ++ 12 files changed, 238 insertions(+), 4 deletions(-) diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index 1cf2d0e09..1ac92ca24 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -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( { diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 9a8d1d93f..6fdd0659b 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -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.", @@ -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)"}`, "", diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 8c72941cd..a78091103 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -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 }, @@ -450,6 +451,7 @@ function runStackedAction( cwd: string; action: "commit" | "commit_push" | "commit_push_pr"; commitMessage?: string; + commitMessageInstructions?: string; featureBranch?: boolean; filePaths?: readonly string[]; }, @@ -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-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 835779517..bd8c3bb19 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -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[]; @@ -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))); @@ -680,6 +684,7 @@ export const makeGitManager = Effect.gen(function* () { cwd: string, branch: string | null, commitMessage?: string, + commitMessageInstructions?: string, preResolvedSuggestion?: CommitAndBranchSuggestion, filePaths?: readonly string[], ) => @@ -690,6 +695,7 @@ export const makeGitManager = Effect.gen(function* () { cwd, branch, ...(commitMessage ? { commitMessage } : {}), + ...(commitMessageInstructions ? { commitMessageInstructions } : {}), ...(filePaths ? { filePaths } : {}), })); if (!suggestion) { @@ -971,6 +977,7 @@ export const makeGitManager = Effect.gen(function* () { cwd: string, branch: string | null, commitMessage?: string, + commitMessageInstructions?: string, filePaths?: readonly string[], ) => Effect.gen(function* () { @@ -978,6 +985,7 @@ export const makeGitManager = Effect.gen(function* () { cwd, branch, ...(commitMessage ? { commitMessage } : {}), + ...(commitMessageInstructions ? { commitMessageInstructions } : {}), ...(filePaths ? { filePaths } : {}), includeBranch: true, }); @@ -1027,6 +1035,7 @@ export const makeGitManager = Effect.gen(function* () { input.cwd, initialStatus.branch, input.commitMessage, + input.commitMessageInstructions, input.filePaths, ); branchStep = result.branchStep; @@ -1042,6 +1051,7 @@ export const makeGitManager = Effect.gen(function* () { input.cwd, currentBranch, commitMessageForStep, + input.commitMessageInstructions, preResolvedCommitSuggestion, input.filePaths, ); diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index daae27fe6..370ff919a 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -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; } diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 326bceaac..77ac676ba 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + MAX_COMMIT_MESSAGE_INSTRUCTIONS_LENGTH, DEFAULT_TIMESTAMP_FORMAT, getAppModelOptions, normalizeCustomModelSlugs, @@ -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); + }); +}); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 18e76d2f9..19c83f793 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -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"; @@ -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)), ), diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index e4fad02af..8a2b87979 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -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, @@ -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], @@ -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, @@ -349,6 +352,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const promise = runImmediateGitActionMutation.mutateAsync({ action, ...(commitMessage ? { commitMessage } : {}), + ...(commitMessageInstructions ? { commitMessageInstructions } : {}), ...(featureBranch ? { featureBranch } : {}), ...(filePaths ? { filePaths } : {}), }); @@ -440,13 +444,13 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }); } }, - [ isDefaultBranch, runImmediateGitActionMutation, setPendingDefaultBranchAction, threadToastData, gitStatusForActions, + commitMessageInstructions, ], ); diff --git a/apps/web/src/lib/gitReactQuery.test.ts b/apps/web/src/lib/gitReactQuery.test.ts index 964d14fb8..c43ee6a77 100644 --- a/apps/web/src/lib/gitReactQuery.test.ts +++ b/apps/web/src/lib/gitReactQuery.test.ts @@ -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", () => { @@ -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", + }); + }); }); diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index 9b5fe7731..55b703f3c 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -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 } : {}), }); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index b4afcdefa..6e003c280 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -3,7 +3,11 @@ import { useQuery } from "@tanstack/react-query"; import { useCallback, useState } from "react"; import { type ProviderKind } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; -import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; +import { + MAX_COMMIT_MESSAGE_INSTRUCTIONS_LENGTH, + MAX_CUSTOM_MODEL_LENGTH, + useAppSettings, +} from "../appSettings"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; @@ -19,6 +23,7 @@ import { SelectValue, } from "../components/ui/select"; import { Switch } from "../components/ui/switch"; +import { Textarea } from "../components/ui/textarea"; import { APP_VERSION } from "../branding"; import { SidebarInset } from "~/components/ui/sidebar"; @@ -666,6 +671,48 @@ function SettingsRouteView() { ) : null} +
+
+

Agent Instructions

+

+ Configure custom instructions for built-in agent tasks. +

+
+ +
+
+

Commit message preferences

+
+ +