feat(vcs): fill the commit message with the repository's commit template (#1340)

An empty commit message field now starts with the file `commit.template`
names (any git config level; `~/` and repository-relative paths resolve
as git resolves them), without its `#` comment lines, since commits keep
the message as written. It is filled when the repository opens or
changes and when a commit clears the field; a typed message is never
replaced, and a prepared merge / cherry-pick / revert message still
takes precedence.

Fixes #1285
This commit is contained in:
l0ng-ai
2026-11-05 02:05:07 +08:00
committed by GitHub
parent c219c81bd8
commit ce898ea0c6
2 changed files with 76 additions and 3 deletions
+46 -2
View File
@@ -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<typeof import("@/platform/ipc")>();
@@ -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: [] });
+30 -1
View File
@@ -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);