diff --git a/src/features/vcs/__tests__/commitMode.test.ts b/src/features/vcs/__tests__/commitMode.test.ts index 103842eaa..f128c4ef0 100644 --- a/src/features/vcs/__tests__/commitMode.test.ts +++ b/src/features/vcs/__tests__/commitMode.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const vcsLog = vi.fn(); const fsReadText = vi.fn(); +const vcsConfigGet = vi.fn(async (_repo: unknown, _key: string) => ({ status: "ok", data: null as string | null })); vi.mock("@/platform/ipc", async (importOriginal) => { const real = await importOriginal(); @@ -10,7 +11,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => { commands: { ...real.commands, vcsLog: (repo: unknown, query: unknown) => vcsLog(repo, query), - vcsConfigGet: async () => ({ status: "ok", data: null }), + vcsConfigGet: (repo: unknown, key: string) => vcsConfigGet(repo, key), fsReadText: (path: string) => fsReadText(path), }, }; @@ -18,7 +19,13 @@ vi.mock("@/platform/ipc", async (importOriginal) => { import { gitTabs, shownTab, stashTabOf, stashTabs } from "../gitTabs"; import { pushDialogNeeded } from "../commitUi"; -import { installPreparedMessage, keepMessageAfterCommit, messageAfterCommit, useCommit } from "../commitStore"; +import { + installPreparedMessage, + keepMessageAfterCommit, + messageAfterCommit, + templatePath, + useCommit, +} from "../commitStore"; import { closeLogTab, emptyFilters, openLogTab, selectGitTab, setLogFilters, useGitLog } from "../logStore"; import { useVcs } from "../store"; @@ -95,6 +102,43 @@ describe("commit message after a commit", () => { }); }); +describe("commit template", () => { + beforeEach(() => { + fsReadText.mockReset(); + vcsConfigGet.mockImplementation(async (_repo, key) => ({ + status: "ok", + data: key === "commit.template" ? ".gitmessage" : null, + })); + useCommit.setState({ message: "" }); + }); + + it("resolves the configured path as git does", () => { + expect(templatePath("~/.gitmessage", "/p", "/home/me/")).toBe("/home/me/.gitmessage"); + expect(templatePath("/etc/msg", "/p", "")).toBe("/etc/msg"); + expect(templatePath("C:\\msg.txt", "/p", "")).toBe("C:\\msg.txt"); + expect(templatePath(".gitmessage", "/p", "")).toBe("/p/.gitmessage"); + }); + + it("fills the empty message field, and again after a commit clears it", async () => { + fsReadText.mockResolvedValue({ status: "ok", data: "feat: \n\n# Why:\nRefs: \n" }); + useVcs.setState({ root: "/p", branch: null }); + const dispose = installPreparedMessage(); + await vi.waitFor(() => expect(useCommit.getState().message).toBe("feat: \n\nRefs:")); + expect(fsReadText).toHaveBeenCalledWith("/p/.gitmessage"); + + keepMessageAfterCommit("feat: x", true); + await vi.waitFor(() => expect(useCommit.getState().message).toBe("feat: \n\nRefs:")); + // A kept message stays; a typed one is never replaced. + keepMessageAfterCommit("feat: x", false); + useCommit.setState({ message: "typed" }); + useVcs.setState({ root: "/q" }); + useCommit.setState({ message: "typed" }); + await Promise.resolve(); + expect(useCommit.getState().message).toBe("typed"); + dispose(); + }); +}); + describe("log tabs of their own", () => { beforeEach(() => { vcsLog.mockReset().mockResolvedValue({ status: "ok", data: [] }); diff --git a/src/features/vcs/commitStore.ts b/src/features/vcs/commitStore.ts index aa0536c74..968371ea4 100644 --- a/src/features/vcs/commitStore.ts +++ b/src/features/vcs/commitStore.ts @@ -180,6 +180,7 @@ export function keepMessageAfterCommit(committed: string, clear: boolean) { const message = messageAfterCommit(committed, clear); keptMessage = message || null; useCommit.setState({ message }); + if (!message) void loadTemplate(); } /** The field holds nothing the user typed: empty, or what the last commit left there. */ @@ -200,9 +201,35 @@ export function preparedMessageText(raw: string): string { .trim(); } +/** `commit.template` resolved as git does: `~/` in the home directory, a relative path in the repository. */ +export function templatePath(value: string, root: string, home: string): string { + if (value === "~" || value.startsWith("~/")) return home.replace(/[/\\]$/, "") + value.slice(1); + if (value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value)) return value; + return `${root}/${value}`; +} + +/** Fills an empty message field with the repository's commit template (`commit.template`), without its `#` lines. */ +async function loadTemplate() { + const root = useVcs.getState().root; + if (!root || useCommit.getState().message.trim()) return; + try { + const value = await call(commands.vcsConfigGet(root, "commit.template")); + if (!value) return; + const home = value.startsWith("~") ? await (await import("@tauri-apps/api/path")).homeDir() : ""; + const text = preparedMessageText(await call(commands.fsReadText(templatePath(value, root, home)))); + if (!text || useVcs.getState().root !== root || useCommit.getState().message.trim()) return; + // Not typed: a prepared merge message may replace it. + keptMessage = text; + useCommit.setState({ message: text }); + } catch { + // No readable template: the field stays empty. + } +} + /** * Like IDEA, pre-fills the commit message (unless something was typed) with the - * message git prepared for a merge / cherry-pick / revert that stopped (e.g. on conflicts). + * message git prepared for a merge / cherry-pick / revert that stopped (e.g. on conflicts), + * otherwise with the commit template. */ export function installPreparedMessage(): () => void { const load = async () => { @@ -217,6 +244,7 @@ export function installPreparedMessage(): () => void { }; const op = (s = useVcs.getState()) => s.branch?.operation ?? null; if (PREPARED_MESSAGE_OPS.has(op() ?? "")) void load(); + else void loadTemplate(); return useVcs.subscribe((s, prev) => { if (s.root !== prev.root) { // Another repository: nothing typed or ticked for the previous one carries over (Amend least of all). @@ -233,6 +261,7 @@ export function installPreparedMessage(): () => void { selectedShelf: null, }); if (PREPARED_MESSAGE_OPS.has(op(s) ?? "")) void load(); + else void loadTemplate(); return; } const now = op(s);