Release Git Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 09:45:57 +02:00
parent ed0acbf8c8
commit 320e7212ef
25 changed files with 1453 additions and 76 deletions
+5 -1
View File
@@ -31,7 +31,11 @@ export function HelpDialog({
×
</button>
</div>
<p>Inspect and author Git interchange formats locally in the browser.</p>
<p>
Inspect and author Git interchange formats locally in the browser, apply
or reverse text patches against explicit JSON snapshots, explain ignore
and attribute rules, and draft an advisory release plan.
</p>
<p>
All processing is performed in this browser. Imported data is treated as
untrusted and bounded before parsing.
+475 -11
View File
@@ -4,15 +4,23 @@ import {
CONVENTIONAL_TYPES,
buildConventionalCommit,
compareSemVer,
applyPatchToSnapshot,
createSnapshotZip,
createUnifiedPatch,
evaluateGitAttributes,
explainGitignore,
explainGitignoreWorkspace,
lintConventionalCommit,
normalizeChangelog,
parseSemVer,
parseUnifiedPatch,
planRelease,
satisfiesSemVer,
type GitAttributeResult,
type IgnoreResult,
type PatchApplicationResult,
type PatchDocument,
type ReleasePlan,
} from "../git/tools";
const patchSample = `diff --git a/src/example.ts b/src/example.ts
@@ -62,7 +70,25 @@ const changelogSample = `# Changelog
- Improve diagnostics
`;
type Tab = "patch" | "author" | "ignore" | "versions" | "commit" | "changelog";
const attributeSample = `* text=auto
*.sh text eol=lf
*.bat text eol=crlf
*.png -text binary
docs/** linguist-documentation
`;
const releaseCommitSample = `feat(git): apply text patches to snapshots
fix(ignore): explain nested precedence
docs: clarify privacy boundary`;
type Tab =
| "patch"
| "apply"
| "author"
| "ignore"
| "attributes"
| "versions"
| "commit"
| "release"
| "changelog";
function download(value: string, filename: string, type = "text/plain") {
triggerBlobDownload(
@@ -78,6 +104,13 @@ export function Workbench() {
const [patch, setPatch] = useState<PatchDocument>(() =>
parseUnifiedPatch(patchSample),
);
const [snapshotSource, setSnapshotSource] = useState(() =>
JSON.stringify({ "src/example.ts": beforeSample }, null, 2),
);
const [reverseApply, setReverseApply] = useState(false);
const [applyResult, setApplyResult] = useState<PatchApplicationResult | null>(
null,
);
const [before, setBefore] = useState(beforeSample);
const [after, setAfter] = useState(afterSample);
const [path, setPath] = useState("src/example.ts");
@@ -86,12 +119,25 @@ export function Workbench() {
createUnifiedPatch(beforeSample, afterSample, "src/example.ts", 3),
);
const [ignoreSource, setIgnoreSource] = useState(ignoreSample);
const [globalExcludes, setGlobalExcludes] = useState("");
const [infoExcludes, setInfoExcludes] = useState("");
const [nestedIgnores, setNestedIgnores] = useState(
'{\n "src/generated/.gitignore": "*\\n!README.md\\n"\n}',
);
const [trackedPaths, setTrackedPaths] = useState("");
const [ignorePaths, setIgnorePaths] = useState(
"dist/app.js\nnotes.log\n.keep.log\ncache/README.md\nsrc/main.ts",
);
const [ignoreResults, setIgnoreResults] = useState<IgnoreResult[]>(() =>
explainGitignore(ignoreSample, ignorePaths.split("\n")),
);
const [attributeSource, setAttributeSource] = useState(attributeSample);
const [attributePaths, setAttributePaths] = useState(
"src/main.ts\nscripts/release.sh\ndocs/guide.md\nassets/logo.png",
);
const [attributeResults, setAttributeResults] = useState<
GitAttributeResult[]
>(() => evaluateGitAttributes(attributeSample, attributePaths.split("\n")));
const [versions, setVersions] = useState(
"1.2.3\n1.3.0-beta.1\n1.3.0\n2.0.0\n0.9.8",
);
@@ -110,6 +156,16 @@ export function Workbench() {
const [normalized, setNormalized] = useState(() =>
normalizeChangelog(changelogSample),
);
const [releaseVersion, setReleaseVersion] = useState("0.2.0");
const [releaseDate, setReleaseDate] = useState("2026-09-01");
const [releaseCommits, setReleaseCommits] = useState(releaseCommitSample);
const [releasePlan, setReleasePlan] = useState<ReleasePlan>(() =>
planRelease({
currentVersion: "0.2.0",
commits: releaseCommitSample.split("\n"),
date: "2026-09-01",
}),
);
const linted = useMemo(() => lintConventionalCommit(message), [message]);
const versionRows = useMemo(
() =>
@@ -166,10 +222,39 @@ export function Workbench() {
);
}
};
const applyPatch = () => {
try {
const parsed = JSON.parse(snapshotSource) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
throw new Error(
"The repository snapshot must be a JSON path-to-text object.",
);
const result = applyPatchToSnapshot(
patchSource,
parsed as Record<string, string>,
reverseApply,
);
setApplyResult(result);
setError("");
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Patch application failed.",
);
}
};
const testIgnore = () => {
try {
setIgnoreResults(
explainGitignore(ignoreSource, ignorePaths.split(/\r?\n/u)),
explainGitignoreWorkspace(
{
root: ignoreSource,
globalExclude: globalExcludes,
infoExclude: infoExcludes,
nested: JSON.parse(nestedIgnores || "{}") as Record<string, string>,
tracked: trackedPaths.split(/\r?\n/u).filter(Boolean),
},
ignorePaths.split(/\r?\n/u),
),
);
setError("");
} catch (reason) {
@@ -178,6 +263,20 @@ export function Workbench() {
);
}
};
const testAttributes = () => {
try {
setAttributeResults(
evaluateGitAttributes(attributeSource, attributePaths.split(/\r?\n/u)),
);
setError("");
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: ".gitattributes test failed.",
);
}
};
const buildCommit = () => {
try {
setMessage(
@@ -210,6 +309,22 @@ export function Workbench() {
);
}
};
const buildReleasePlan = () => {
try {
setReleasePlan(
planRelease({
currentVersion: releaseVersion,
commits: releaseCommits.split(/\r?\n/u),
date: releaseDate,
}),
);
setError("");
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Release planning failed.",
);
}
};
return (
<main className="workbench">
@@ -233,18 +348,20 @@ export function Workbench() {
{(
[
"patch",
"apply",
"author",
"ignore",
"attributes",
"versions",
"commit",
"release",
"changelog",
] as const
).map((value) => (
<button
key={value}
type="button"
role="tab"
aria-selected={tab === value}
aria-pressed={tab === value}
onClick={() => setTab(value)}
>
{value === "ignore"
@@ -284,7 +401,8 @@ export function Workbench() {
</div>
<p className="muted">
The last valid inspection remains visible when later input is
malformed. Applying patches is intentionally out of scope.
malformed. Use the Apply tab for exact, text-only in-memory
application or reversal.
</p>
</section>
<section className="panel workspace">
@@ -376,6 +494,117 @@ export function Workbench() {
</div>
)}
{tab === "apply" && (
<div className="split-layout">
<section className="panel workspace">
<div className="panel-heading">
<div>
<p className="eyebrow">Exact and atomic</p>
<h2>Apply patch to a text snapshot</h2>
</div>
<button type="button" className="primary" onClick={applyPatch}>
{reverseApply ? "Reverse patch" : "Apply patch"}
</button>
</div>
<p className="muted">
Supply a JSON object whose keys are repository-relative paths and
values are complete text files. Every hunk must match exactly;
binary patches, unsafe paths, partial and fuzzy application are
rejected, and failure leaves the last valid result unchanged.
</p>
<label className="field">
<span>Repository snapshot JSON</span>
<textarea
aria-label="Repository snapshot JSON"
value={snapshotSource}
onChange={(event) => setSnapshotSource(event.target.value)}
spellCheck={false}
/>
</label>
<label className="check">
<input
type="checkbox"
checked={reverseApply}
onChange={(event) => setReverseApply(event.target.checked)}
/>
Reverse old and new sides
</label>
<p className="muted">
The patch comes from the Patch tab. Nothing is written to your
working tree.
</p>
</section>
<section className="panel workspace">
<div className="panel-heading">
<div>
<p className="eyebrow">Result snapshot</p>
<h2>
{applyResult
? `${applyResult.changed.length} path(s) changed`
: "Not applied yet"}
</h2>
</div>
</div>
{applyResult ? (
<>
<ul className="steps">
{applyResult.changed.map((item) => (
<li key={`${item.oldPath}-${item.newPath}`}>
<code>{item.newPath}</code>
<span>
{item.status}
{item.oldPath !== item.newPath
? ` from ${item.oldPath}`
: ""}
</span>
<small>{item.bytes} bytes</small>
</li>
))}
</ul>
<textarea
aria-label="Applied snapshot JSON"
readOnly
value={JSON.stringify(applyResult.files, null, 2)}
/>
<div className="actions">
<button
type="button"
onClick={() =>
download(
`${JSON.stringify(applyResult.files, null, 2)}\n`,
"patched-snapshot.json",
"application/json",
)
}
>
Download JSON
</button>
<button
type="button"
onClick={() => {
const archive = createSnapshotZip(applyResult.files);
triggerBlobDownload(
new Blob([archive.slice().buffer as ArrayBuffer], {
type: "application/zip",
}),
"patched-snapshot.zip",
);
}}
>
Download ZIP
</button>
</div>
</>
) : (
<p className="notice">
Apply or reverse the current patch to create a downloadable
in-memory result.
</p>
)}
</section>
</div>
)}
{tab === "author" && (
<section className="panel workspace">
<div className="panel-heading">
@@ -477,6 +706,38 @@ export function Workbench() {
onChange={(event) => setIgnoreSource(event.target.value)}
/>
</label>
<label className="field">
<span>Global excludes</span>
<textarea
aria-label="Global excludes"
value={globalExcludes}
onChange={(event) => setGlobalExcludes(event.target.value)}
/>
</label>
<label className="field">
<span>.git/info/exclude</span>
<textarea
aria-label="Info excludes"
value={infoExcludes}
onChange={(event) => setInfoExcludes(event.target.value)}
/>
</label>
<label className="field">
<span>Nested .gitignore files (JSON path contents)</span>
<textarea
aria-label="Nested ignore files"
value={nestedIgnores}
onChange={(event) => setNestedIgnores(event.target.value)}
/>
</label>
<label className="field">
<span>Already tracked paths, one per line</span>
<textarea
aria-label="Tracked paths"
value={trackedPaths}
onChange={(event) => setTrackedPaths(event.target.value)}
/>
</label>
<label className="field">
<span>Paths, one per line; end directories with /</span>
<textarea
@@ -501,9 +762,9 @@ export function Workbench() {
{result.matched.length > 0 && (
<ol>
{result.matched.map((match) => (
<li key={match.line}>
Line {match.line}: <code>{match.source}</code> {" "}
{match.outcome}
<li key={`${match.origin ?? "root"}-${match.line}`}>
{match.origin ?? ".gitignore"}:{match.line}:{" "}
<code>{match.source}</code> {match.outcome}
</li>
))}
</ol>
@@ -512,14 +773,104 @@ export function Workbench() {
))}
</div>
<p className="muted">
This evaluates one repository-root ignore file. Nested .gitignore
files, global excludes, index state, and submodule boundaries are
not loaded.
Rules are evaluated from global excludes through .git/info/exclude
and parent-to-child .gitignore files. Explicitly tracked paths
remain included. Command-line excludes and submodule repository
boundaries are not modeled.
</p>
</section>
</div>
)}
{tab === "attributes" && (
<div className="split-layout">
<section className="panel workspace">
<div className="panel-heading">
<div>
<p className="eyebrow">Root .gitattributes</p>
<h2>Attribute rules and paths</h2>
</div>
<button
type="button"
className="primary"
onClick={testAttributes}
>
Evaluate attributes
</button>
</div>
<label className="field">
<span>.gitattributes contents</span>
<textarea
aria-label=".gitattributes source"
value={attributeSource}
onChange={(event) => setAttributeSource(event.target.value)}
spellCheck={false}
/>
</label>
<label className="field">
<span>Repository-relative paths, one per line</span>
<textarea
aria-label="Attribute paths"
value={attributePaths}
onChange={(event) => setAttributePaths(event.target.value)}
/>
</label>
<p className="muted">
Shows set, unset, value and unspecified states with the winning
source line. This bounded root-file tester does not expand custom
attribute macros.
</p>
</section>
<section className="panel workspace">
<div className="result-list">
{attributeResults.map((result) => (
<article key={result.path}>
<header>
<code>{result.path}</code>
<strong>{result.attributes.length} attributes</strong>
</header>
{result.attributes.length ? (
<table>
<thead>
<tr>
<th>Attribute</th>
<th>State</th>
<th>Winning line</th>
</tr>
</thead>
<tbody>
{result.attributes.map((attribute) => (
<tr key={attribute.name}>
<td>
<code>{attribute.name}</code>
</td>
<td>
{attribute.state.kind === "value"
? `=${attribute.state.value}`
: attribute.state.kind}
</td>
<td title={attribute.source}>{attribute.line}</td>
</tr>
))}
</tbody>
</table>
) : (
<p>No rule sets an attribute for this path.</p>
)}
{result.diagnostics.length > 0 && (
<ul className="diagnostics">
{result.diagnostics.map((diagnostic) => (
<li key={diagnostic}>{diagnostic}</li>
))}
</ul>
)}
</article>
))}
</div>
</section>
</div>
)}
{tab === "versions" && (
<div className="split-layout">
<section className="panel workspace">
@@ -704,6 +1055,119 @@ export function Workbench() {
</div>
)}
{tab === "release" && (
<div className="split-layout">
<section className="panel workspace">
<div className="panel-heading">
<div>
<p className="eyebrow">Conventional-commit evidence</p>
<h2>Release planner</h2>
</div>
<button
type="button"
className="primary"
onClick={buildReleasePlan}
>
Build plan
</button>
</div>
<div className="form-grid">
<label className="field">
<span>Current version</span>
<input
value={releaseVersion}
onChange={(event) => setReleaseVersion(event.target.value)}
/>
</label>
<label className="field">
<span>Release date</span>
<input
type="date"
value={releaseDate}
onChange={(event) => setReleaseDate(event.target.value)}
/>
</label>
<label className="field wide">
<span>Commit headers, one per line</span>
<textarea
value={releaseCommits}
onChange={(event) => setReleaseCommits(event.target.value)}
/>
</label>
</div>
<p className="muted">
Breaking changes select major, features select minor, and fixes or
performance changes select patch. The plan is advisory: no Git or
package command is executed.
</p>
</section>
<section className="panel workspace">
<div className="facts">
<div>
<span>Bump</span>
<strong>{releasePlan.bump}</strong>
</div>
<div>
<span>Next version</span>
<strong>{releasePlan.nextVersion}</strong>
</div>
<div>
<span>Tag</span>
<strong>{releasePlan.tag}</strong>
</div>
<div>
<span>Valid commits</span>
<strong>{releasePlan.validCommits}</strong>
</div>
</div>
<label className="field">
<span>Generated release notes</span>
<textarea readOnly value={releasePlan.releaseNotes} />
</label>
<div className="actions">
<button
type="button"
onClick={() =>
download(
releasePlan.releaseNotes,
`release-${releasePlan.nextVersion}.md`,
"text/markdown",
)
}
>
Download release notes
</button>
</div>
{releasePlan.commands.length > 0 && (
<div className="result">
<strong>Review-only command sequence</strong>
<ol className="command-list">
{releasePlan.commands.map((command) => (
<li key={command}>
<code>{command}</code>
</li>
))}
</ol>
</div>
)}
{releasePlan.ignoredCommits.length > 0 && (
<details>
<summary>
{releasePlan.ignoredCommits.length} invalid commit(s) ignored
</summary>
<ul className="diagnostics">
{releasePlan.ignoredCommits.map((commit) => (
<li key={commit.source}>
<code>{commit.source}</code>: {commit.reason}
</li>
))}
</ul>
</details>
)}
</section>
</div>
)}
{tab === "changelog" && (
<div className="split-layout">
<section className="panel workspace">
+607 -3
View File
@@ -1,3 +1,5 @@
import { strToU8, zipSync } from "fflate";
export interface PatchLine {
kind: "context" | "add" | "delete" | "meta";
text: string;
@@ -440,9 +442,217 @@ export function createUnifiedPatch(
return output.join("\n") + "\n";
}
export interface PatchApplicationResult {
files: Record<string, string>;
changed: Array<{
oldPath: string;
newPath: string;
status: PatchFile["status"];
bytes: number;
}>;
}
/**
* Applies a text-only patch to an explicit in-memory repository snapshot.
* Matching is exact and atomic: any mismatch rejects the whole operation.
*/
export function applyPatchToSnapshot(
patchInput: string | PatchDocument,
snapshot: Readonly<Record<string, string>>,
reverse = false,
): PatchApplicationResult {
const parsed =
typeof patchInput === "string" ? parseUnifiedPatch(patchInput) : patchInput;
const document = reverse ? reversePatchDocument(parsed) : parsed;
const entries = Object.entries(snapshot);
if (entries.length > 5_000)
throw new Error("Repository snapshot exceeds the 5,000-file limit.");
let totalBytes = 0;
const files: Record<string, string> = {};
for (const [rawPath, contents] of entries) {
const path = safeRepositoryPath(rawPath);
if (typeof contents !== "string")
throw new Error(`Snapshot entry ${path} must contain text.`);
totalBytes += new TextEncoder().encode(contents).byteLength;
if (totalBytes > 32 * 1024 * 1024)
throw new Error("Repository snapshot exceeds the 32 MiB text limit.");
files[path] = contents;
}
const changed: PatchApplicationResult["changed"] = [];
const touched = new Set<string>();
for (const file of document.files) {
if (file.binary)
throw new Error(
`${file.newPath}: binary patches cannot be applied safely.`,
);
if (file.hunks.some((hunk) => !hunk.validCounts))
throw new Error(`${file.newPath}: a hunk has inconsistent line counts.`);
const oldPath = safeRepositoryPath(file.oldPath);
const newPath = safeRepositoryPath(file.newPath);
if (touched.has(oldPath) || touched.has(newPath))
throw new Error(
`${newPath}: the snapshot path is patched more than once.`,
);
touched.add(oldPath);
touched.add(newPath);
const before = files[oldPath];
if (file.status === "added") {
if (files[newPath] !== undefined)
throw new Error(`${newPath}: cannot add a path that already exists.`);
} else if (before === undefined) {
throw new Error(`${oldPath}: source path is missing from the snapshot.`);
}
if (
file.status === "renamed" &&
oldPath !== newPath &&
files[newPath] !== undefined
)
throw new Error(`${newPath}: rename target already exists.`);
const output = applyTextFilePatch(before ?? "", file);
if (file.status === "deleted") delete files[oldPath];
else {
if (oldPath !== newPath) delete files[oldPath];
files[newPath] = output;
}
changed.push({
oldPath,
newPath,
status: file.status,
bytes:
file.status === "deleted"
? 0
: new TextEncoder().encode(output).byteLength,
});
}
return { files, changed };
}
function reversePatchDocument(document: PatchDocument): PatchDocument {
const files = document.files.map((file): PatchFile => ({
...file,
oldPath: file.newPath,
newPath: file.oldPath,
status:
file.status === "added"
? "deleted"
: file.status === "deleted"
? "added"
: file.status,
oldMode: file.newMode,
newMode: file.oldMode,
added: file.deleted,
deleted: file.added,
hunks: file.hunks.map((hunk) => ({
...hunk,
oldStart: hunk.newStart,
oldCount: hunk.newCount,
newStart: hunk.oldStart,
newCount: hunk.oldCount,
added: hunk.deleted,
deleted: hunk.added,
lines: hunk.lines.map((line) => ({
...line,
kind:
line.kind === "add"
? "delete"
: line.kind === "delete"
? "add"
: line.kind,
oldLine: line.newLine,
newLine: line.oldLine,
})),
})),
}));
return {
files,
added: document.deleted,
deleted: document.added,
diagnostics: [...document.diagnostics],
};
}
function applyTextFilePatch(source: string, file: PatchFile): string {
const input = sourceLines(source);
const output: string[] = [];
let cursor = 0;
let finalNewline = input.finalNewline;
for (const hunk of file.hunks) {
const start = hunk.oldStart === 0 ? 0 : hunk.oldStart - 1;
if (start < cursor || start > input.lines.length)
throw new Error(
`${file.oldPath}: hunk ${hunk.header} is out of order or range.`,
);
output.push(...input.lines.slice(cursor, start));
cursor = start;
let previous: PatchLine | undefined;
for (const line of hunk.lines) {
if (line.kind === "meta") {
if (previous?.kind === "add" || previous?.kind === "context")
finalNewline = false;
previous = line;
continue;
}
if (line.kind === "add") output.push(line.text);
else {
if (input.lines[cursor] !== line.text)
throw new Error(
`${file.oldPath}: exact hunk match failed at source line ${cursor + 1}.`,
);
if (line.kind === "context") output.push(line.text);
cursor += 1;
}
previous = line;
}
}
output.push(...input.lines.slice(cursor));
if (file.status === "deleted") {
if (output.length !== 0)
throw new Error(`${file.oldPath}: deletion patch leaves text behind.`);
return "";
}
return output.join("\n") + (finalNewline ? "\n" : "");
}
function safeRepositoryPath(path: string): string {
const candidate = path.trim().replaceAll("\\", "/");
const normalized = normalizeRepositoryPath(path);
if (
!normalized ||
candidate.startsWith("/") ||
/^[A-Za-z]:\//u.test(candidate) ||
normalized.split("/").some((part) => part === ".." || part === ".") ||
normalized.includes("\0")
)
throw new Error(`Unsafe repository-relative path: ${path}`);
return normalized;
}
export function createSnapshotZip(
snapshot: Readonly<Record<string, string>>,
): Uint8Array {
const entries: Record<string, [Uint8Array, { mtime: Date }]> = {};
let totalBytes = 0;
for (const [rawPath, contents] of Object.entries(snapshot).sort(
([left], [right]) => left.localeCompare(right),
)) {
const path = safeRepositoryPath(rawPath);
if (typeof contents !== "string")
throw new Error(`${path}: ZIP snapshots accept text only.`);
const bytes = strToU8(contents);
totalBytes += bytes.byteLength;
if (totalBytes > 32 * 1024 * 1024)
throw new Error("ZIP input exceeds the 32 MiB limit.");
entries[path] = [bytes, { mtime: new Date("1980-01-01T00:00:00Z") }];
}
return zipSync(entries, { level: 6 });
}
export interface IgnoreRule {
line: number;
source: string;
origin?: string;
directory?: string;
negated: boolean;
directoryOnly: boolean;
regex: RegExp;
@@ -450,7 +660,13 @@ export interface IgnoreRule {
export interface IgnoreResult {
path: string;
ignored: boolean;
matched: { line: number; source: string; outcome: "ignore" | "include" }[];
tracked?: boolean;
matched: {
line: number;
source: string;
outcome: "ignore" | "include";
origin?: string;
}[];
explanation: string;
}
function trimGitignoreLine(line: string): string {
@@ -480,10 +696,24 @@ function globRegex(pattern: string): string {
}
return output;
}
export function compileGitignore(source: string): IgnoreRule[] {
function normalizeRepositoryPath(path: string): string {
return path
.trim()
.replaceAll("\\", "/")
.replace(/^\.\//u, "")
.replace(/^\/+|\/+$/gu, "");
}
export function compileGitignore(
source: string,
options: { directory?: string; origin?: string } = {},
): IgnoreRule[] {
if (source.length > 512 * 1024)
throw new Error(".gitignore input exceeds 512 KiB.");
const rules: IgnoreRule[] = [];
const directory = normalizeRepositoryPath(options.directory ?? "");
if (directory.split("/").includes(".."))
throw new Error("Ignore-file directory must be repository-relative.");
for (const [index, original] of source
.replaceAll("\r\n", "\n")
.replaceAll("\r", "\n")
@@ -503,12 +733,20 @@ export function compileGitignore(source: string): IgnoreRule[] {
if (anchored) line = line.slice(1);
const hasSlash = line.includes("/");
const body = globRegex(line);
const prefix = anchored || hasSlash ? "^" : "(?:^|.*/)";
const base = directory ? `${globRegex(directory)}/` : "";
const prefix =
anchored || hasSlash
? `^${base}`
: directory
? `^${base}(?:.*/)?`
: "(?:^|.*/)";
const suffix = directoryOnly ? "(?:/.*)$" : "(?:/.*)?$";
try {
rules.push({
line: index + 1,
source: original,
...(options.origin ? { origin: options.origin } : {}),
...(directory ? { directory } : {}),
negated,
directoryOnly,
regex: new RegExp(prefix + body + suffix, "u"),
@@ -536,6 +774,7 @@ function evaluateIgnore(
line: rule.line,
source: rule.source,
outcome: ignored ? "ignore" : "include",
...(rule.origin ? { origin: rule.origin } : {}),
});
}
return { ignored, matched };
@@ -585,6 +824,245 @@ export function explainGitignore(
});
}
export interface IgnoreWorkspace {
root: string;
nested?: Readonly<Record<string, string>>;
infoExclude?: string;
globalExclude?: string;
tracked?: readonly string[];
}
/** Evaluates Git's low-to-high exclude precedence for a repository snapshot. */
export function explainGitignoreWorkspace(
workspace: IgnoreWorkspace,
paths: readonly string[],
): IgnoreResult[] {
if (paths.length > 10_000)
throw new Error("Path test is limited to 10,000 entries.");
const nested = Object.entries(workspace.nested ?? {})
.map(([name, source]) => {
const normalized = normalizeRepositoryPath(name);
if (!normalized.endsWith(".gitignore"))
throw new Error(`Nested ignore source must end in .gitignore: ${name}`);
const slash = normalized.lastIndexOf("/");
const directory = slash < 0 ? "" : normalized.slice(0, slash);
return { name: normalized, directory, source };
})
.sort(
(left, right) =>
left.directory.split("/").filter(Boolean).length -
right.directory.split("/").filter(Boolean).length ||
left.name.localeCompare(right.name),
);
const rules = [
...compileGitignore(workspace.globalExclude ?? "", {
origin: "global excludes",
}),
...compileGitignore(workspace.infoExclude ?? "", {
origin: ".git/info/exclude",
}),
...compileGitignore(workspace.root, { origin: ".gitignore" }),
...nested.flatMap((item) =>
compileGitignore(item.source, {
directory: item.directory,
origin: item.name,
}),
),
];
const tracked = new Set(
(workspace.tracked ?? []).map(normalizeRepositoryPath).filter(Boolean),
);
return paths
.filter((value) => value.trim())
.map((raw) => {
const directory = raw.trim().endsWith("/");
const path = normalizeRepositoryPath(raw);
if (!path || path.split("/").includes(".."))
return {
path: raw,
ignored: false,
matched: [],
explanation: "Invalid repository-relative path; no rule evaluated.",
};
if (tracked.has(path))
return {
path: raw,
ignored: false,
tracked: true,
matched: evaluateIgnore(path + (directory ? "/" : ""), rules).matched,
explanation:
"The path is explicitly tracked; exclude rules affect only untracked files.",
};
const own = evaluateIgnore(path + (directory ? "/" : ""), rules);
const segments = path.split("/");
let blockedParent = "";
for (let index = 1; index < segments.length; index += 1) {
const parent = `${segments.slice(0, index).join("/")}/`;
if (evaluateIgnore(parent, rules).ignored) blockedParent = parent;
}
const ignored = own.ignored || Boolean(blockedParent);
const winner = own.matched.at(-1);
return {
path: raw,
ignored,
matched: own.matched,
explanation:
blockedParent && !own.ignored
? `A negation cannot re-include the path while parent ${blockedParent} remains ignored.`
: winner
? `Winning rule ${winner.origin ?? ".gitignore"}:${winner.line} ${ignored ? "ignores" : "includes"} the path.`
: "No exclude source matches; Git tracks the path by default.",
};
});
}
export type GitAttributeState =
| { kind: "set" }
| { kind: "unset" }
| { kind: "unspecified" }
| { kind: "value"; value: string };
export interface GitAttributeResult {
path: string;
attributes: Array<{
name: string;
state: GitAttributeState;
line: number;
source: string;
}>;
diagnostics: string[];
}
interface GitAttributeRule {
line: number;
source: string;
regex: RegExp;
assignments: Array<{ name: string; state: GitAttributeState }>;
}
/** Evaluate a root .gitattributes snapshot with last-match-per-attribute semantics. */
export function evaluateGitAttributes(
source: string,
paths: readonly string[],
): GitAttributeResult[] {
if (source.length > 512 * 1024)
throw new Error(".gitattributes input exceeds 512 KiB.");
if (paths.length > 10_000)
throw new Error("Attribute testing is limited to 10,000 paths.");
const sharedDiagnostics: string[] = [];
const rules: GitAttributeRule[] = [];
for (const [index, original] of source
.replaceAll("\r\n", "\n")
.replaceAll("\r", "\n")
.split("\n")
.entries()) {
const tokens = tokenizeAttributeLine(original);
if (tokens.length === 0 || tokens[0]!.startsWith("#")) continue;
const pattern = tokens.shift()!;
if (pattern.startsWith("[attr]")) {
sharedDiagnostics.push(
`Line ${index + 1}: attribute macros are reported but not expanded by this bounded tester.`,
);
continue;
}
if (pattern.startsWith("!"))
throw new Error(
`Line ${index + 1}: negative attribute patterns are forbidden by Git.`,
);
if (pattern.endsWith("/"))
sharedDiagnostics.push(
`Line ${index + 1}: a trailing slash does not recursively match directory contents; use /** instead.`,
);
const assignments = tokens.map((token) =>
parseAttributeAssignment(token, index + 1),
);
if (assignments.length === 0) {
sharedDiagnostics.push(`Line ${index + 1}: pattern has no attributes.`);
continue;
}
const normalizedPattern = pattern.replace(/^\//u, "");
const body = globRegex(normalizedPattern);
const prefix = normalizedPattern.includes("/") ? "^" : "(?:^|.*/)";
try {
rules.push({
line: index + 1,
source: original,
regex: new RegExp(`${prefix}${body}$`, "u"),
assignments,
});
} catch {
throw new Error(`Line ${index + 1}: invalid attribute pattern.`);
}
if (rules.length > 10_000)
throw new Error(".gitattributes exceeds 10,000 active rules.");
}
return paths
.filter((path) => path.trim())
.map((rawPath): GitAttributeResult => {
const path = safeRepositoryPath(rawPath);
const attributes = new Map<
string,
GitAttributeResult["attributes"][number]
>();
for (const rule of rules) {
if (!rule.regex.test(path)) continue;
for (const assignment of rule.assignments)
attributes.set(assignment.name, {
...assignment,
line: rule.line,
source: rule.source,
});
}
return {
path,
attributes: [...attributes.values()].sort((left, right) =>
left.name.localeCompare(right.name),
),
diagnostics: [...sharedDiagnostics],
};
});
}
function tokenizeAttributeLine(line: string): string[] {
const output: string[] = [];
let token = "";
let escaped = false;
for (const character of line.trim()) {
if (escaped) {
token += character;
escaped = false;
} else if (character === "\\") escaped = true;
else if (/\s/u.test(character)) {
if (token) {
output.push(token);
token = "";
}
} else token += character;
}
if (escaped) token += "\\";
if (token) output.push(token);
return output;
}
function parseAttributeAssignment(
token: string,
line: number,
): { name: string; state: GitAttributeState } {
const prefix = token[0];
const body = prefix === "-" || prefix === "!" ? token.slice(1) : token;
const equals = body.indexOf("=");
const name = equals < 0 ? body : body.slice(0, equals);
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/u.test(name))
throw new Error(
`Line ${line}: invalid attribute name ${name || "(empty)"}.`,
);
if (prefix === "-") return { name, state: { kind: "unset" } };
if (prefix === "!") return { name, state: { kind: "unspecified" } };
if (equals >= 0)
return { name, state: { kind: "value", value: body.slice(equals + 1) } };
return { name, state: { kind: "set" } };
}
export interface SemVer {
major: number;
minor: number;
@@ -1000,3 +1478,129 @@ export function normalizeChangelog(source: string): {
diagnostics,
};
}
export interface ReleasePlan {
currentVersion: string;
nextVersion: string;
bump: "none" | "patch" | "minor" | "major";
tag: string;
validCommits: number;
ignoredCommits: Array<{ source: string; reason: string }>;
groups: Array<{ heading: string; entries: string[] }>;
releaseNotes: string;
commands: string[];
}
/** Build a deterministic, advisory release plan without accessing a repository. */
export function planRelease(input: {
currentVersion: string;
commits: readonly string[];
date?: string;
tagPrefix?: string;
}): ReleasePlan {
const current = parseSemVer(input.currentVersion);
if (input.commits.length > 10_000)
throw new Error("Release planning is limited to 10,000 commit headers.");
const ignoredCommits: ReleasePlan["ignoredCommits"] = [];
const parsed: ConventionalCommit[] = [];
for (const source of input.commits) {
const trimmed = source.trim();
if (!trimmed) continue;
const commit = lintConventionalCommit(trimmed);
if (!commit.valid) {
ignoredCommits.push({
source: trimmed.slice(0, 300),
reason: commit.issues
.filter((issue) => issue.severity === "error")
.map((issue) => issue.message)
.join(" "),
});
} else parsed.push(commit);
}
const bump: ReleasePlan["bump"] = parsed.some((commit) => commit.breaking)
? "major"
: parsed.some((commit) => commit.type === "feat")
? "minor"
: parsed.some((commit) => ["fix", "perf"].includes(commit.type))
? "patch"
: "none";
const next = {
major: current.major + (bump === "major" ? 1 : 0),
minor: bump === "major" ? 0 : current.minor + (bump === "minor" ? 1 : 0),
patch:
bump === "major" || bump === "minor"
? 0
: current.patch + (bump === "patch" ? 1 : 0),
};
const nextVersion = `${next.major}.${next.minor}.${next.patch}`;
const headings: Record<string, string> = {
feat: "Added",
fix: "Fixed",
perf: "Changed",
security: "Security",
docs: "Documentation",
refactor: "Changed",
test: "Tests",
build: "Build",
ci: "Build",
chore: "Maintenance",
revert: "Reverted",
style: "Changed",
};
const grouped = new Map<string, string[]>();
for (const commit of parsed) {
const heading = headings[commit.type] ?? "Other";
const entry = `${commit.subject}${commit.scope ? ` (${commit.scope})` : ""}${commit.breaking ? " — BREAKING" : ""}`;
const entries = grouped.get(heading) ?? [];
if (!entries.some((item) => item.toLowerCase() === entry.toLowerCase()))
entries.push(entry);
grouped.set(heading, entries);
}
const groups = [...grouped.entries()]
.map(([heading, entries]) => ({ heading, entries: entries.sort() }))
.sort((left, right) => {
const leftIndex = SECTION_ORDER.indexOf(left.heading);
const rightIndex = SECTION_ORDER.indexOf(right.heading);
return (
(leftIndex < 0 ? 99 : leftIndex) - (rightIndex < 0 ? 99 : rightIndex) ||
left.heading.localeCompare(right.heading)
);
});
const date = input.date?.trim();
if (date && !/^\d{4}-\d{2}-\d{2}$/u.test(date))
throw new Error("Release date must use YYYY-MM-DD.");
const releaseNotes =
[
`## [${nextVersion}]${date ? ` - ${date}` : ""}`,
...groups.flatMap((group) => [
"",
`### ${group.heading}`,
"",
...group.entries.map((entry) => `- ${entry}`),
]),
].join("\n") + "\n";
const prefix = input.tagPrefix ?? "v";
if (!/^[A-Za-z0-9._/-]{0,32}$/u.test(prefix) || prefix.includes(".."))
throw new Error("Tag prefix contains unsafe characters.");
const tag = `${prefix}${nextVersion}`;
return {
currentVersion: current.raw,
nextVersion,
bump,
tag,
validCommits: parsed.length,
ignoredCommits,
groups,
releaseNotes,
commands:
bump === "none"
? []
: [
`npm version ${nextVersion} --no-git-tag-version`,
`git add package.json package-lock.json CHANGELOG.md`,
`git commit -m "chore(release): ${nextVersion}"`,
`git tag -a ${tag} -m "${tag}"`,
`git push origin HEAD ${tag}`,
],
};
}
+1 -1
View File
@@ -161,7 +161,7 @@ textarea {
overflow-x: auto;
padding-bottom: 0.2rem;
}
.workspace-tabs button[aria-selected="true"] {
.workspace-tabs button[aria-pressed="true"] {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast);
+36 -2
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.git-tools",
"name": "Git Tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Inspect and author Git interchange formats locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
@@ -29,9 +29,43 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{
"mediaType": "text/x-diff",
"extensions": [".diff", ".patch"]
},
{
"mediaType": "text/plain",
"extensions": [".gitignore", ".gitattributes", ".txt"]
},
{
"mediaType": "application/json",
"extensions": [".json"]
}
],
"produces": [
{
"mediaType": "text/x-diff",
"extensions": [".patch"]
},
{
"mediaType": "application/json",
"extensions": [".json"]
},
{
"mediaType": "application/zip",
"extensions": [".zip"]
}
]
},
"capabilities": {
"required": [],
"optional": []
},
"privacy": {
"processing": "local",
"fileUploads": true,
"fileUploads": false,
"telemetry": false,
"label": "Inputs stay in this browser; nothing is uploaded."
},
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0";
export const APP_VERSION = "0.2.0";