diff --git a/apps/server/src/gitManager.test.ts b/apps/server/src/gitManager.test.ts index 092473936b66cb6174eccd352579a6812b3de489..172e85368a5d4cb68f15ce60ea08727e46304136 100644 --- a/apps/server/src/gitManager.test.ts +++ b/apps/server/src/gitManager.test.ts @@ -248,6 +248,40 @@ describe("GitManager", () => { ); }); + it("uses custom commit message when provided", async () => { + const repoDir = makeTempDir("t3code-git-manager-"); + await initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, "README.md"), "hello\ncustom\n"); + let generatedCount = 0; + + const manager = new GitManager({ + textGenerator: createTextGenerator({ + generateCommitMessage: async () => { + generatedCount += 1; + return { + subject: "this should not be used", + body: "", + }; + }, + }), + }); + const result = await manager.runStackedAction({ + cwd: repoDir, + action: "commit", + commitMessage: "feat: custom summary line\n\n- details from user", + }); + + expect(result.commit.status).toBe("created"); + expect(result.commit.subject).toBe("feat: custom summary line"); + expect(generatedCount).toBe(0); + expect(await runGitStdout(repoDir, ["log", "-1", "--pretty=%s"])).toBe( + "feat: custom summary line", + ); + expect(await runGitStdout(repoDir, ["log", "-1", "--pretty=%b"])).toContain( + "- details from user", + ); + }); + it("skips commit when there are no uncommitted changes", async () => { const repoDir = makeTempDir("t3code-git-manager-"); await initRepo(repoDir); diff --git a/apps/server/src/gitManager.ts b/apps/server/src/gitManager.ts index 682d56a0d2b9ae6f0809d53fb634ca086864c231..32bbc4bf3bc04f8020d3f66a176aabcd9a33b705 100644 --- a/apps/server/src/gitManager.ts +++ b/apps/server/src/gitManager.ts @@ -106,6 +106,24 @@ function sanitizeCommitMessage( }; } +function parseCustomCommitMessage(raw: string): CommitMessageGenerationResult | null { + const normalized = raw.replace(/\r\n/g, "\n").trim(); + if (normalized.length === 0) { + return null; + } + + const [firstLine, ...rest] = normalized.split("\n"); + const subject = firstLine?.trim() ?? ""; + if (subject.length === 0) { + return null; + } + + return { + subject, + body: rest.join("\n").trim(), + }; +} + function extractBranchFromRef(ref: string): string { const normalized = ref.trim(); @@ -222,7 +240,11 @@ export class GitManager { throw new Error("Cannot create a pull request from detached HEAD."); } - const commit = await this.runCommitStep(input.cwd, initialStatus.branch); + const commit = await this.runCommitStep( + input.cwd, + initialStatus.branch, + input.commitMessage, + ); const push = wantsPush ? await this.gitCore.pushCurrentBranch(input.cwd, initialStatus.branch) @@ -243,6 +265,7 @@ export class GitManager { private async runCommitStep( cwd: string, branch: string | null, + commitMessage?: string, ): Promise<{ status: "created" | "skipped_no_changes"; commitSha?: string | undefined; @@ -253,18 +276,23 @@ export class GitManager { return { status: "skipped_no_changes" }; } - let generated: CommitMessageGenerationResult; - try { - generated = sanitizeCommitMessage( - await this.textGenerator.generateCommitMessage({ - cwd, - branch, - stagedSummary: limitContext(context.stagedSummary, 8_000), - stagedPatch: limitContext(context.stagedPatch, 50_000), - }), - ); - } catch (error) { - throw asCommandNotFound("codex", error) ?? error; + let generated: CommitMessageGenerationResult | null = parseCustomCommitMessage( + commitMessage ?? "", + ); + + if (!generated) { + try { + generated = sanitizeCommitMessage( + await this.textGenerator.generateCommitMessage({ + cwd, + branch, + stagedSummary: limitContext(context.stagedSummary, 8_000), + stagedPatch: limitContext(context.stagedPatch, 50_000), + }), + ); + } catch (error) { + throw asCommandNotFound("codex", error) ?? error; + } } const { commitSha } = await this.gitCore.commit(cwd, generated.subject, generated.body); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 12a07299b002a49350f591f42b97f9bc7e1f3263..4c628420cb072a8a303cac9414de1d62b7ff3690 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,5 +1,6 @@ import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + type GitRunStackedActionResult, type GitStackedAction, type GitStatusResult, type ProviderApprovalDecision, @@ -40,7 +41,16 @@ import BranchToolbar from "./BranchToolbar"; import { isTerminalToggleShortcut } from "../terminal-shortcuts"; import ChatMarkdown from "./ChatMarkdown"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; -import { CloudUploadIcon, GitCommitIcon, GithubIcon } from "lucide-react"; +import { + CheckIcon, + CircleIcon, + CloudUploadIcon, + GitCommitIcon, + GithubIcon, + Loader2Icon, + MinusIcon, + XIcon, +} from "lucide-react"; function formatMessageMeta(createdAt: string, duration: string | null): string { if (!duration) return formatTimestamp(createdAt); @@ -81,6 +91,88 @@ function GitActionIcon(props: { icon: GitActionMenuItem["icon"]; disabled: boole return ; } +type GitProgressStepStatus = + | "pending" + | "running" + | "completed" + | "skipped" + | "failed"; + +interface GitProgressStep { + id: "generate" | "commit" | "push" | "pr"; + label: string; + status: GitProgressStepStatus; + detail?: string; +} + +function gitActionModalTitle(action: GitStackedAction): string { + if (action === "commit") return "Commit your changes"; + if (action === "commit_push") return "Commit and push changes"; + return "Commit, push and open PR"; +} + +function initialGitProgressSteps( + action: GitStackedAction, + commitMessage: string, +): GitProgressStep[] { + const hasCustomMessage = commitMessage.trim().length > 0; + const steps: GitProgressStep[] = []; + + if (!hasCustomMessage) { + steps.push({ + id: "generate", + label: "Generate commit message", + status: "pending", + }); + } + + steps.push({ + id: "commit", + label: "Commit changes", + status: "pending", + }); + + if (action !== "commit") { + steps.push({ + id: "push", + label: "Push branch", + status: "pending", + }); + } + + if (action === "commit_push_pr") { + steps.push({ + id: "pr", + label: "Create or open PR", + status: "pending", + }); + } + + return steps; +} + +function updateProgressStep( + steps: GitProgressStep[], + id: GitProgressStep["id"], + status: GitProgressStepStatus, + detail?: string, +): GitProgressStep[] { + return steps.map((step) => { + if (step.id !== id) return step; + return { + ...step, + status, + ...(detail ? { detail } : {}), + }; + }); +} + +function runActionLabel(action: GitStackedAction): string { + if (action === "commit") return "Commit"; + if (action === "commit_push") return "Commit & Push"; + return "Commit, Push & Open PR"; +} + function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; if (tone === "tool") return "text-muted-foreground/70"; @@ -166,6 +258,11 @@ export default function ChatView() { const [isGitActionRunning, setIsGitActionRunning] = useState(false); const [gitStatus, setGitStatus] = useState(null); const [gitActionError, setGitActionError] = useState(null); + const [gitModalAction, setGitModalAction] = useState(null); + const [gitModalCommitMessage, setGitModalCommitMessage] = useState(""); + const [gitModalProgress, setGitModalProgress] = useState([]); + const [gitModalError, setGitModalError] = useState(null); + const [gitModalResult, setGitModalResult] = useState(null); const [lastEditor, setLastEditor] = useState(() => { const stored = localStorage.getItem(LAST_EDITOR_KEY); return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; @@ -325,6 +422,16 @@ export default function ChatView() { }, ]; }, [gitBaseDisabled, gitStatus]); + const isGitModalOpen = gitModalAction !== null; + const gitModalPreviewSteps = useMemo( + () => + gitModalAction + ? initialGitProgressSteps(gitModalAction, gitModalCommitMessage) + : ([] as GitProgressStep[]), + [gitModalAction, gitModalCommitMessage], + ); + const gitModalSteps = + gitModalProgress.length > 0 ? gitModalProgress : gitModalPreviewSteps; useEffect(() => { latestGitCwdRef.current = gitCwd; @@ -343,34 +450,161 @@ export default function ChatView() { setGitActionError(null); }, [api, gitCwd]); - const runGitAction = useCallback( - async (action: GitStackedAction) => { - if (!api || !gitCwd) return; - const actionCwd = gitCwd; - - setIsGitMenuOpen(false); - setIsGitActionRunning(true); - setGitActionError(null); + const openGitActionModal = useCallback((action: GitStackedAction) => { + setIsGitMenuOpen(false); + setGitModalAction(action); + setGitModalCommitMessage(""); + setGitModalProgress([]); + setGitModalError(null); + setGitModalResult(null); + setGitActionError(null); + }, []); + + const closeGitActionModal = useCallback(() => { + if (isGitActionRunning) return; + setGitModalAction(null); + setGitModalCommitMessage(""); + setGitModalProgress([]); + setGitModalError(null); + setGitModalResult(null); + }, [isGitActionRunning]); + + const runGitAction = useCallback(async () => { + if (!api || !gitCwd || !gitModalAction) return; + const actionCwd = gitCwd; + const action = gitModalAction; + const commitMessage = gitModalCommitMessage.trim(); + const includeGeneratedCommitMessage = commitMessage.length === 0; + + setIsGitActionRunning(true); + setGitModalError(null); + setGitActionError(null); + setGitModalResult(null); + setGitModalProgress(initialGitProgressSteps(action, commitMessage)); + + let commit: GitRunStackedActionResult["commit"] = { + status: "skipped_no_changes", + }; + let push: GitRunStackedActionResult["push"] = { + status: "skipped_not_requested", + }; + let pr: GitRunStackedActionResult["pr"] = { + status: "skipped_not_requested", + }; + + const updateStep = ( + id: GitProgressStep["id"], + status: GitProgressStepStatus, + detail?: string, + ) => { + setGitModalProgress((steps) => updateProgressStep(steps, id, status, detail)); + }; + + try { + if (includeGeneratedCommitMessage) { + updateStep("generate", "running"); + } else { + updateStep("commit", "running"); + } + + const commitRun = await api.git.runStackedAction({ + cwd: actionCwd, + action: "commit", + ...(commitMessage.length > 0 ? { commitMessage } : {}), + }); + commit = commitRun.commit; + + if (includeGeneratedCommitMessage) { + if (commitRun.commit.status === "created") { + updateStep( + "generate", + "completed", + commitRun.commit.subject + ? `Generated: ${commitRun.commit.subject}` + : "Generated commit message.", + ); + } else { + updateStep("generate", "skipped", "No local changes to commit."); + } + } + + if (commitRun.commit.status === "created") { + updateStep( + "commit", + "completed", + commitRun.commit.subject ?? "Committed local changes.", + ); + } else { + updateStep("commit", "skipped", "No local changes to commit."); + } + + if (action !== "commit") { + updateStep("push", "running"); + const pushRun = await api.git.runStackedAction({ + cwd: actionCwd, + action: "commit_push", + }); + push = pushRun.push; + if (pushRun.push.status === "pushed") { + updateStep( + "push", + "completed", + pushRun.push.upstreamBranch + ? `Pushed to ${pushRun.push.upstreamBranch}.` + : "Pushed latest commits.", + ); + } else { + updateStep("push", "skipped", "Branch already up to date."); + } + } + + if (action === "commit_push_pr") { + updateStep("pr", "running"); + const prRun = await api.git.runStackedAction({ + cwd: actionCwd, + action: "commit_push_pr", + }); + pr = prRun.pr; + if (prRun.pr.status === "opened_existing") { + updateStep( + "pr", + "completed", + prRun.pr.number + ? `Opened existing PR #${prRun.pr.number}.` + : "Opened existing PR.", + ); + } else if (prRun.pr.status === "created") { + updateStep( + "pr", + "completed", + prRun.pr.number ? `Created PR #${prRun.pr.number}.` : "Created PR.", + ); + } else { + updateStep("pr", "skipped", "PR step was not requested."); + } + } + + setGitModalResult({ action, commit, push, pr }); + } catch (error) { + const message = error instanceof Error ? error.message : "Git action failed."; + setGitModalError(message); + setGitActionError(message); + setGitModalProgress((steps) => { + const active = steps.find((step) => step.status === "running"); + if (!active) return steps; + return updateProgressStep(steps, active.id, "failed", message); + }); + } finally { + setIsGitActionRunning(false); try { - await api.git.runStackedAction({ - cwd: actionCwd, - action, - }); - } catch (error) { - setGitActionError(error instanceof Error ? error.message : "Git action failed."); - } finally { - setIsGitActionRunning(false); - try { - if (latestGitCwdRef.current === actionCwd) { - await refreshGitStatus(); - } - } catch { - setGitStatus(null); + if (latestGitCwdRef.current === actionCwd) { + await refreshGitStatus(); } + } catch { + setGitStatus(null); } - }, - [api, gitCwd, refreshGitStatus], - ); + } + }, [api, gitCwd, gitModalAction, gitModalCommitMessage, refreshGitStatus]); const envLocked = Boolean( activeThread && @@ -436,6 +670,11 @@ export default function ChatView() { useEffect(() => { setGitActionError(null); + setGitModalError(null); + setGitModalAction(null); + setGitModalCommitMessage(""); + setGitModalProgress([]); + setGitModalResult(null); }, [activeProject?.id, activeWorktreePath]); useEffect(() => { @@ -537,6 +776,21 @@ export default function ChatView() { }; }, [isGitMenuOpen]); + useEffect(() => { + if (!isGitModalOpen) return; + + const handleEscape = (event: globalThis.KeyboardEvent) => { + if (event.key !== "Escape") return; + if (isGitActionRunning) return; + closeGitActionModal(); + }; + + window.addEventListener("keydown", handleEscape); + return () => { + window.removeEventListener("keydown", handleEscape); + }; + }, [closeGitActionModal, isGitActionRunning, isGitModalOpen]); + // Cmd+O / Ctrl+O to open in last-used editor useEffect(() => { const handler = (e: globalThis.KeyboardEvent) => { @@ -882,7 +1136,7 @@ export default function ChatView() { className="mb-1.5 flex w-full items-center gap-3 rounded-2xl px-3 py-2.5 text-left text-[14px] text-foreground transition-colors duration-150 hover:bg-accent disabled:cursor-not-allowed disabled:text-muted-foreground/65" disabled={item.disabled} onClick={() => { - void runGitAction(item.id); + openGitActionModal(item.id); }} > @@ -941,12 +1195,6 @@ export default function ChatView() { )} - {gitActionError && ( -
- {gitActionError} -
- )} - {pendingApprovals.length > 0 && (
{pendingApprovals.map((approval) => { @@ -1430,6 +1678,180 @@ export default function ChatView() {
+ {isGitModalOpen && gitModalAction && ( +
{ + closeGitActionModal(); + }} + > +
{ + event.stopPropagation(); + }} + > +
+
+
+ +
+
+

+ Git actions +

+

+ {gitActionModalTitle(gitModalAction)} +

+
+
+ +
+ +
+
+ Branch + + {gitStatus?.branch ?? "(detached HEAD)"} + +
+
+ Changes + + {gitStatus?.hasWorkingTreeChanges ? "Working tree has changes" : "No local changes"} + +
+
+ +
+ +