Файли
openidea/src/features/vcs/commitChecks.tsx
T
l0ng-ai eb67595e72 feat(vcs): Add a modal commit mode and fill in Git log, branch popup and settings (#144)
- Settings › Commit › "Use non-modal commit interface": when off, Local
  Changes, Shelf and Stash become tabs of the Git tool window and ⌘K opens
  a Commit dialog; switches live.
- Git tool window: Worktrees tab; Show History and Show Git Log for a
  branch open their own closable tabs instead of changing the Log filters.
- Log: toolbar and context menu for a commit's changed files, vertical
  toolbar, Tags node and hiding for the branches pane, Collapse / Expand
  Linear Branches as separate actions, and a Version Control › Log page.
- Branch popup: collapsible groups, branches grouped by prefix, an options
  menu behind the gear, incoming / outgoing icons and "Pull into … Using
  Rebase / Merge"; New Branch on ⌥⌘N outside the editor.
- Git main menu regrouped with Uncommitted Changes and Current File
  submenus; rebase / merge controls enabled only while one is in progress.
- Settings: Version Control lists its pages; new options for clearing the
  initial commit message, showing Update options, confirming dropped
  commits, revealing created patches, large-file and file-name warnings,
  the Push dialog after Commit and Push, combined stashes and shelves, and
  removing applied files from the shelf.
2026-10-28 21:49:43 +08:00

419 рядки
15 KiB
TypeScript
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
/**
* Commit checks (Settings › Version Control › Commit / Git): before-commit
* modifications (Reformat code, Optimize imports, Cleanup) applied through
* the language servers, then the checks (Analyze code, Check TODO) and the Git
* warnings (detached HEAD / rebase, file names, large files, CRLF line separators).
*/
import type { ReactNode } from "react";
import type { Diagnostic } from "@/bindings";
import { formatExcluded, formatGuardOptions, guardTextEdits } from "@/platform/formatGuard";
import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { workflowSettings } from "@/platform/workflowSettings";
import { maybeEditorService, toolWindowService } from "@/workbench/contributions";
import { baseName, effectiveKind, relTo, stripSlash } from "./model";
import { logConsole } from "./ops";
import { currentRepo, useVcs } from "./store";
import styles from "./common.module.css";
import { tx } from "@/platform/i18n";
// ---------------------------------------------------------------------------
// Diagnostics seen per file (Analyze code)
const diagnostics = new Map<string, Map<string, Diagnostic[]>>();
/** Paths that received at least one diagnostics event. */
const analyzed = new Set<string>();
const waiters = new Set<() => void>();
export function installDiagnosticsTracking(): () => void {
return onIdeEvent("diagnostics", (e) => {
let sources = diagnostics.get(e.path);
if (!sources) diagnostics.set(e.path, (sources = new Map()));
if (e.items.length) sources.set(e.source, e.items);
else sources.delete(e.source);
analyzed.add(e.path);
waiters.forEach((w) => w());
});
}
export function problemCounts(paths: readonly string[]): { errors: number; warnings: number; files: number } {
let errors = 0;
let warnings = 0;
let files = 0;
for (const p of paths) {
const items = [...(diagnostics.get(p)?.values() ?? [])].flat();
const e = items.filter((d) => d.severity === "error").length;
const w = items.filter((d) => d.severity === "warning").length;
errors += e;
warnings += w;
if (e + w) files++;
}
return { errors, warnings, files };
}
/** Waits until every path was analysed (or `timeout`). */
function waitForAnalysis(paths: readonly string[], timeout: number): Promise<void> {
return new Promise((resolve) => {
const check = () => {
if (paths.every((p) => analyzed.has(p))) finish();
};
const timer = setTimeout(() => finish(), timeout);
const finish = () => {
clearTimeout(timer);
waiters.delete(check);
resolve();
};
waiters.add(check);
check();
});
}
// ---------------------------------------------------------------------------
// TODO items in added lines
const TODO_RE = /\b(TODO|FIXME)\b/i;
export interface TodoHit {
path: string;
/** 0-based line in the working text. */
line: number;
text: string;
}
/** TODO / FIXME in lines added or changed relative to `oldText`. */
export async function todosInChanges(path: string, oldText: string, newText: string): Promise<TodoHit[]> {
const hunks = await commands.vcsDiffHunks(oldText, newText);
const lines = newText.split("\n");
const out: TodoHit[] = [];
for (const h of hunks) {
for (let l = h.start; l < h.start + h.lines; l++) {
const text = lines[l] ?? "";
if (TODO_RE.test(text)) out.push({ path, line: l, text: text.trim() });
}
}
return out;
}
// ---------------------------------------------------------------------------
// Headless modifications through the language server
function wholeRange(text: string) {
const lines = text.split("\n");
return { start: { line: 0, col: 0 }, end: { line: lines.length - 1, col: lines[lines.length - 1]!.length } };
}
async function applyKind(doc: number, text: string, kind: string): Promise<boolean> {
const actions = (await call(commands.lspCodeActions(doc, wholeRange(text), [kind]))).filter(
(a) => a.kind?.startsWith(kind) && !a.disabledReason,
);
const action = actions.find((a) => a.isPreferred) ?? actions[0];
if (!action) return false;
await call(commands.lspApplyCodeAction(doc, action));
return true;
}
export interface Modifications {
reformat: boolean;
optimizeImports: boolean;
cleanup: boolean;
}
/** Applies the enabled modifications to one file and saves it. Errors are logged, not thrown. */
async function modifyFile(path: string, mods: Modifications) {
let info;
try {
info = await call(commands.docOpen(path));
} catch {
return;
}
try {
const step = async (label: string, run: () => Promise<unknown>) => {
try {
await run();
} catch (e) {
logConsole("info", `${label} skipped for ${baseName(path)}: ${e instanceof Error ? e.message : String(e)}`);
}
};
if (mods.optimizeImports)
await step("Optimize imports", async () =>
applyKind(info.id, (await call(commands.docSnapshot(info.id))).text, "source.organizeImports"),
);
if (mods.cleanup)
await step("Cleanup", async () =>
applyKind(info.id, (await call(commands.docSnapshot(info.id))).text, "source.fixAll"),
);
const { project, settings } = useProject.getState();
const guard = formatGuardOptions(settings);
if (mods.reformat && !formatExcluded(path, project?.root ?? null, guard.doNotFormat))
await step("Reformat code", async () => {
const editor = settings?.editor as { tabSize?: number; insertSpaces?: boolean } | undefined;
const text = (await call(commands.docSnapshot(info.id))).text;
const edits = guardTextEdits(
text,
await call(
commands.lspFormat(info.id, null, {
tabSize: editor?.tabSize ?? 4,
insertSpaces: editor?.insertSpaces ?? true,
}),
),
guard,
);
if (edits.length) await call(commands.applyWorkspaceEdit({ files: [{ kind: "edit", path, edits }] }));
});
await call(commands.docSave(info.id));
} finally {
void commands.docClose(info.id);
}
}
// ---------------------------------------------------------------------------
// Problems
/**
* A failed before-commit check, shown in the commit panel (as IDEA)
* while the Commit button turns into "Commit Anyway".
*/
export interface CommitProblem {
title: string;
text: ReactNode;
/** "Review" / "Show details" link. */
details?: { label: string; run: () => void };
}
// ---------------------------------------------------------------------------
// Git: names invalid on Windows, large files
const WINDOWS_RESERVED = new Set(
["CON", "PRN", "AUX", "NUL"].concat([1, 2, 3, 4, 5, 6, 7, 8, 9].flatMap((n) => [`COM${n}`, `LPT${n}`])),
);
// eslint-disable-next-line no-control-regex
const WINDOWS_INVALID = /[<>:"\\|?*\u0000-\u001f]/;
/** The first path segment below `root` that Windows rejects (IDEA's GitFileNameCheckinHandler). */
export function badFileName(
paths: readonly string[],
root: string,
): { name: string; kind: "reserved" | "char" } | null {
for (const p of paths) {
for (const name of relTo(root, p).split("/")) {
const dot = name.lastIndexOf(".");
const stem = dot > 0 ? name.slice(0, dot) : name;
if (WINDOWS_RESERVED.has(stem.toUpperCase())) return { name, kind: "reserved" };
if (WINDOWS_INVALID.test(name)) return { name, kind: "char" };
}
}
return null;
}
/** Files bigger than `limit` bytes; sizes come from listing their folders. */
async function largeFiles(paths: readonly string[], limit: number): Promise<Array<{ path: string; size: number }>> {
const byDir = new Map<string, Set<string>>();
for (const p of paths) {
const dir = p.slice(0, p.lastIndexOf("/")) || "/";
(byDir.get(dir) ?? byDir.set(dir, new Set()).get(dir)!).add(p);
}
const out: Array<{ path: string; size: number }> = [];
for (const [dir, wanted] of byDir) {
try {
for (const e of await call(commands.fsReadDir(dir)))
if (wanted.has(e.path) && e.kind === "file" && e.size > limit) out.push({ path: e.path, size: e.size });
} catch {
// unreadable folder
}
}
return out;
}
// ---------------------------------------------------------------------------
export interface CommitCandidate {
/** Absolute path. */
path: string;
/** False for deletions. */
exists: boolean;
}
/**
* Runs the configured before-commit modifications and checks for the files about to
* be committed. Resolves the problems found; the commit goes ahead only without any.
*/
export async function runCommitChecks(files: readonly CommitCandidate[], amend: boolean): Promise<CommitProblem[]> {
const { commit, git } = workflowSettings().vcs;
const branch = useVcs.getState().branch;
const problems: CommitProblem[] = [];
if (git.warnDetachedHead && branch && (!branch.head || branch.operation === "rebase")) {
const rebase = branch.operation === "rebase";
problems.push({
title: rebase ? "Commit during rebase" : "Commit in detached HEAD",
text: rebase
? "A rebase is in progress. The commit will become part of the rebased history; usually you continue the rebase instead."
: "HEAD is detached: the commit won't belong to any branch and may be lost after checking out another branch. Create a branch first to keep it.",
});
}
const existing = files.filter((f) => f.exists).map((f) => f.path);
if (git.warnBadFileNames) {
// New paths only: a name already in the repository is not this commit's problem.
const kinds = new Map(useVcs.getState().status.map((f) => [stripSlash(f.path), effectiveKind(f)]));
const added = existing.filter((p) => ["added", "untracked", "renamed"].includes(kinds.get(p) ?? ""));
const bad = badFileName(added, useVcs.getState().root ?? "");
if (bad)
problems.push({
title: "File names",
text:
bad.kind === "reserved"
? tx`Some files use reserved names that are not allowed on Windows: '${bad.name}'. If you commit, it will not be possible to check out the Git repository on Windows.`
: tx`Some of the file names contain characters that are not allowed on Windows: '${bad.name}'. If you commit, it might not be possible to check out the Git repository on Windows.`,
});
}
if (!isTauri() || !existing.length) return problems;
if (git.warnLargeFiles) {
const large = await largeFiles(existing, git.largeFileMb * 1024 * 1024);
if (large.length) {
const mb = Math.ceil(large.reduce((n, f) => n + f.size, 0) / (1024 * 1024));
problems.push({
title: "Large files",
text:
large.length === 1
? tx`You are about to commit a large file into the repository: ${baseName(large[0]!.path)} (${mb} MB). It is not recommended to store large files in a Git repository. Consider using Git LFS.`
: tx`You are about to commit ${large.length} large files into the repository with the total size of ${mb} MB. It is not recommended to store large files in a Git repository. Consider using Git LFS.`,
});
}
}
const mods: Modifications = {
reformat: commit.reformat,
optimizeImports: commit.optimizeImports,
cleanup: commit.cleanup,
};
if (mods.reformat || mods.optimizeImports || mods.cleanup) {
logConsole(
"info",
`Before commit: ${[mods.optimizeImports && "optimize imports", mods.cleanup && "cleanup", mods.reformat && "reformat code"].filter(Boolean).join(", ")}`,
);
for (const p of existing) await modifyFile(p, mods);
try {
await maybeEditorService()?.saveAll();
} catch {
// best effort
}
}
if (commit.analyzeCode) {
// Opening the files makes their language servers analyse them.
const opened: number[] = [];
for (const p of existing.filter((p) => !analyzed.has(p))) {
try {
opened.push((await call(commands.docOpen(p))).id);
} catch {
// binary / unreadable
}
}
try {
await waitForAnalysis(existing, opened.length ? 2000 : 0);
} finally {
opened.forEach((id) => void commands.docClose(id));
}
const { errors, warnings, files: n } = problemCounts(existing);
if (errors + warnings > 0) {
const errorCount = errors === 1 ? tx`${errors} error` : tx`${errors} errors`;
const warningCount = warnings === 1 ? tx`${warnings} warning` : tx`${warnings} warnings`;
const found = !warnings ? errorCount : !errors ? warningCount : tx`${errorCount} and ${warningCount}`;
problems.push({
title: "Code analysis",
text: n === 1 ? tx`${found} found in ${n} file` : tx`${found} found in ${n} files`,
details: {
label: "Review",
run: () => {
try {
toolWindowService().show("problems", true);
} catch {
// no Problems tool window
}
},
},
});
}
}
if (commit.checkTodo) {
const root = useVcs.getState().root ?? "";
const hits: TodoHit[] = [];
for (const p of existing) {
try {
const [oldText, newText] = await Promise.all([
call(commands.vcsFileAt(p, amend ? "HEAD~1" : "HEAD")).catch(() => null),
call(commands.fsReadText(p)),
]);
hits.push(...(await todosInChanges(p, oldText ?? "", newText)));
} catch {
// unreadable / binary
}
}
if (hits.length) {
problems.push({
title: "TODO",
text: (
<>
<div>
{hits.length === 1
? tx`${hits.length} TODO item found in the changed lines:`
: tx`${hits.length} TODO items found in the changed lines:`}
</div>
<ul className={styles.todoList}>
{hits.slice(0, 20).map((h, i) => (
<li key={i}>
<button
type="button"
className={styles.linkButton}
onClick={() =>
void maybeEditorService()?.openFile(h.path, { selection: { line: h.line, col: 0 }, focus: true })
}
>
{relTo(root, h.path)}:{h.line + 1}
</button>{" "}
{h.text}
</li>
))}
{hits.length > 20 && <li>…</li>}
</ul>
</>
),
});
}
}
if (git.warnCrlf) {
const autocrlf = (
await call(commands.vcsConfigGet(currentRepo(), "core.autocrlf")).catch(() => null)
)?.toLowerCase();
if (autocrlf !== "true" && autocrlf !== "input") {
const crlf: string[] = [];
for (const p of existing) {
try {
if ((await call(commands.fsReadText(p))).includes("\r\n")) crlf.push(p);
} catch {
// skip
}
}
if (crlf.length) {
const crlfNames = crlf.slice(0, 5).map(baseName).join(", ") + (crlf.length > 5 ? ", …" : "");
problems.push({
title: "Line separators",
text: (
<>
{crlf.length === 1
? tx`${crlf.length} file (${crlfNames}) contain CRLF line separators and core.autocrlf is not set, so they would be committed with CRLF. Convert them with the line separator widget in the status bar, or set`
: tx`${crlf.length} files (${crlfNames}) contain CRLF line separators and core.autocrlf is not set, so they would be committed with CRLF. Convert them with the line separator widget in the status bar, or set`}{" "}
<code>git config core.autocrlf input</code>.
</>
),
});
}
}
}
return problems;
}