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
+39
View File
@@ -0,0 +1,39 @@
name: Verify
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
CI: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Select declared npm version
run: npm install --global npm@11.17.0
- name: Install dependencies
run: npm ci
- name: Audit runtime dependencies
run: npm audit --omit=dev --audit-level=moderate
- name: Check, test, and build
run: npm run check
- name: Install browser engines
run: npx playwright install --with-deps chromium firefox webkit
- name: Browser tests
run: npm run test:browser
+8
View File
@@ -1,5 +1,13 @@
# Changelog
## 0.2.0 - 2026-09-02
- Added exact atomic text-patch application/reversal against explicit local
snapshots with deterministic JSON/ZIP export.
- Added layered ignore sources and tracked-state explanations, root
`.gitattributes` evaluation, and advisory release planning from Conventional
Commit headers.
## 0.1.0 - 2026-09-01
- Added the initial local-first Git Tools workbench.
+6 -3
View File
@@ -8,12 +8,15 @@ Git Tools is a standalone local-first application in the [add·ideas Toolbox](ht
- Unified and `git diff` patch parsing with file status, modes, renames, hunk counts, line numbers, statistics, binary markers, and diagnostics
- Unified patch authoring from before/after text with bounded LCS, context selection, path safety, and missing-final-newline markers
- Root `.gitignore` matching for comments, escapes, globstars, character classes, directory rules, negation, ordered explanations, and ignored-parent diagnostics
- Exact, atomic text-patch application and reversal against an explicit JSON repository snapshot, with downloadable JSON or deterministic ZIP output and no working-tree access
- Layered `.gitignore` matching for comments, escapes, globstars, character classes, directory rules, negation, global excludes, `.git/info/exclude`, parent-to-child nested files, explicit tracked state, winning-rule explanations, and ignored-parent diagnostics
- Root `.gitattributes` evaluation with set/unset/value/unspecified states, last-match provenance, and directory-pattern diagnostics
- Strict SemVer 2.0 precedence plus common exact, comparator, partial, wildcard, hyphen, tilde, caret, AND, and OR ranges
- Conventional Commit builder and structural lint with breaking-change/footer diagnostics
- Keep-a-Changelog heading/section normalization, stable ordering, and duplicate-entry removal
- Advisory release planning from Conventional Commit headers: SemVer bump, grouped notes, tag name, invalid-input evidence, and review-only command sequence
Patch and before/after inputs are capped at 4 MiB with additional file, line, hunk, and diff-matrix bounds. `.gitignore` evaluates one repository-root file only; it does not read nested ignore files, global excludes, index state, or submodules. No workspace or `.git` directory is accessed. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
Patch and before/after inputs are capped at 4 MiB with additional file, line, hunk, and diff-matrix bounds. Applied snapshots are text-only, exact-match, atomic, limited to 5,000 files/32 MiB, and never touch the working tree. Ignore inputs are an explicit local snapshot; command-line exclude rules and submodule repository boundaries are not modeled. Attribute macros and nested attribute files are not expanded. No workspace or `.git` directory is accessed. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
## Development
@@ -27,7 +30,7 @@ npm run test:browser
## Release
`npm run release:artifact` creates a deterministic `release/git-tools-0.1.0.zip` and checksum sidecar.
`npm run release:artifact` creates a deterministic `release/git-tools-0.2.0.zip` and checksum sidecar.
## Licence
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source
The corresponding source for Git Tools 0.1.0 is available at:
The corresponding source for Git Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/git-tools/src/tag/v0.1.0
https://git.add-ideas.de/lotobo/git-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+4 -4
View File
@@ -1,12 +1,12 @@
# Third-party notices
Git Tools 0.1.0 directly depends on these runtime packages:
Git Tools 0.2.0 directly depends on these runtime packages:
| Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `react` | 19.2.8 | MIT |
| `react-dom` | 19.2.8 | MIT |
+29 -23
View File
@@ -1,22 +1,23 @@
{
"name": "git-tools",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "git-tools",
"version": "0.1.0",
"version": "0.2.0",
"license": "GPL-3.0-or-later",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-helpers": "0.1.0",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.3.0",
"fflate": "0.8.2",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
@@ -42,24 +43,24 @@
}
},
"node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3",
"license": "Apache-2.0",
"engines": {
"node": ">=20"
}
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"license": "Apache-2.0"
},
"node_modules/@add-ideas/toolbox-helpers": {
"version": "0.1.0",
"license": "GPL-3.0-or-later",
"engines": {
"node": ">=22"
}
"version": "0.2.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz",
"integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
"license": "GPL-3.0-or-later"
},
"node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3"
"@add-ideas/toolbox-contract": "0.3.0"
},
"peerDependencies": {
"react": ">=18 <20",
@@ -67,17 +68,16 @@
}
},
"node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3"
"@add-ideas/toolbox-contract": "0.3.0"
},
"bin": {
"toolbox-check": "dist/cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@adobe/css-tools": {
@@ -2009,6 +2009,12 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"dev": true,
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "git-tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Inspect and author Git interchange formats locally in the browser.",
"license": "GPL-3.0-or-later",
"author": "Albrecht Degering",
@@ -39,14 +39,15 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
},
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-helpers": "0.1.0",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.3.0",
"fflate": "0.8.2",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{
name: "chromium",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Safari"] },
},
{
name: "mobile-chromium",
testMatch: /responsive\.spec\.ts/,
use: { ...devices["Pixel 5"] },
},
],
});
+8
View File
@@ -1,5 +1,13 @@
# Changelog
## 0.2.0 - 2026-09-02
- Added exact atomic text-patch application/reversal against explicit local
snapshots with deterministic JSON/ZIP export.
- Added layered ignore sources and tracked-state explanations, root
`.gitattributes` evaluation, and advisory release planning from Conventional
Commit headers.
## 0.1.0 - 2026-09-01
- Added the initial local-first Git Tools workbench.
+30 -3
View File
@@ -1,5 +1,5 @@
==============================================================================
@add-ideas/toolbox-contract@0.2.3
@add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
==============================================================================
@add-ideas/toolbox-helpers@0.1.0
@add-ideas/toolbox-helpers@0.2.0
Declared licence: GPL-3.0-or-later
==============================================================================
--- LICENSE ---
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
==============================================================================
@add-ideas/toolbox-shell-react@0.2.3
@add-ideas/toolbox-shell-react@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
@@ -1077,6 +1077,33 @@ Declared licence: Apache-2.0
limitations under the License.
==============================================================================
fflate@0.8.2
Declared licence: MIT
==============================================================================
--- LICENSE ---
MIT License
Copyright (c) 2023 Arjun Barrett
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
==============================================================================
react@19.2.8
Declared licence: MIT
+6 -3
View File
@@ -8,12 +8,15 @@ Git Tools is a standalone local-first application in the [add·ideas Toolbox](ht
- Unified and `git diff` patch parsing with file status, modes, renames, hunk counts, line numbers, statistics, binary markers, and diagnostics
- Unified patch authoring from before/after text with bounded LCS, context selection, path safety, and missing-final-newline markers
- Root `.gitignore` matching for comments, escapes, globstars, character classes, directory rules, negation, ordered explanations, and ignored-parent diagnostics
- Exact, atomic text-patch application and reversal against an explicit JSON repository snapshot, with downloadable JSON or deterministic ZIP output and no working-tree access
- Layered `.gitignore` matching for comments, escapes, globstars, character classes, directory rules, negation, global excludes, `.git/info/exclude`, parent-to-child nested files, explicit tracked state, winning-rule explanations, and ignored-parent diagnostics
- Root `.gitattributes` evaluation with set/unset/value/unspecified states, last-match provenance, and directory-pattern diagnostics
- Strict SemVer 2.0 precedence plus common exact, comparator, partial, wildcard, hyphen, tilde, caret, AND, and OR ranges
- Conventional Commit builder and structural lint with breaking-change/footer diagnostics
- Keep-a-Changelog heading/section normalization, stable ordering, and duplicate-entry removal
- Advisory release planning from Conventional Commit headers: SemVer bump, grouped notes, tag name, invalid-input evidence, and review-only command sequence
Patch and before/after inputs are capped at 4 MiB with additional file, line, hunk, and diff-matrix bounds. `.gitignore` evaluates one repository-root file only; it does not read nested ignore files, global excludes, index state, or submodules. No workspace or `.git` directory is accessed. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
Patch and before/after inputs are capped at 4 MiB with additional file, line, hunk, and diff-matrix bounds. Applied snapshots are text-only, exact-match, atomic, limited to 5,000 files/32 MiB, and never touch the working tree. Ignore inputs are an explicit local snapshot; command-line exclude rules and submodule repository boundaries are not modeled. Attribute macros and nested attribute files are not expanded. No workspace or `.git` directory is accessed. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
## Development
@@ -27,7 +30,7 @@ npm run test:browser
## Release
`npm run release:artifact` creates a deterministic `release/git-tools-0.1.0.zip` and checksum sidecar.
`npm run release:artifact` creates a deterministic `release/git-tools-0.2.0.zip` and checksum sidecar.
## Licence
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source
The corresponding source for Git Tools 0.1.0 is available at:
The corresponding source for Git Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/git-tools/src/tag/v0.1.0
https://git.add-ideas.de/lotobo/git-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+4 -4
View File
@@ -1,12 +1,12 @@
# Third-party notices
Git Tools 0.1.0 directly depends on these runtime packages:
Git Tools 0.2.0 directly depends on these runtime packages:
| Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `react` | 19.2.8 | MIT |
| `react-dom` | 19.2.8 | MIT |
+1 -1
View File
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "git-tools-shell-";
const CACHE_NAME = CACHE_PREFIX + "0.1.0";
const CACHE_NAME = CACHE_PREFIX + "0.2.0";
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
self.addEventListener("install", (event) => {
event.waitUntil(
+18 -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,25 @@
"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."
},
+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";
+2 -2
View File
@@ -33,7 +33,7 @@ test("authors and re-inspects a patch without flashing state", async ({
}) => {
const external = await localOnly(page);
await page.goto("/deep/nested/git/");
await page.getByRole("tab", { name: "Author" }).click();
await page.getByRole("button", { name: "Author" }).click();
await page.getByLabel("After text").fill("alpha\nbeta\n");
await page.getByRole("button", { name: "Generate patch" }).click();
await expect(page.getByLabel("Generated patch")).toContainText("+alpha");
@@ -52,7 +52,7 @@ test("serves release identity and hardened headers", async ({ request }) => {
const manifest = await request.get("/deep/nested/git/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.git-tools",
version: "0.1.0",
version: "0.2.0",
entry: "./",
privacy: { processing: "local", telemetry: false },
});
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("keeps the primary workspace inside a narrow viewport", async ({
page,
}) => {
await page.goto("/deep/nested/git/");
await expect(page.locator("main").first()).toBeVisible();
await expect(
page.locator("main .loading, main .workbench-loading"),
).toHaveCount(0);
const widths = await page.evaluate(() => ({
content: document.documentElement.scrollWidth,
viewport: document.documentElement.clientWidth,
}));
expect(widths.viewport).toBeLessThanOrEqual(430);
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
});
+1 -1
View File
@@ -14,7 +14,7 @@ describe("Git Tools", () => {
await screen.findByRole("heading", { name: "Git Tools" }),
).toBeVisible();
await userEvent.click(
await screen.findByRole("tab", { name: ".gitignore" }),
await screen.findByRole("button", { name: ".gitignore" }),
);
expect(screen.getAllByText(/Last matching rule/u).length).toBeGreaterThan(
0,
+124
View File
@@ -1,13 +1,18 @@
import { describe, expect, it } from "vitest";
import {
applyPatchToSnapshot,
buildConventionalCommit,
compareSemVer,
createSnapshotZip,
createUnifiedPatch,
evaluateGitAttributes,
explainGitignore,
explainGitignoreWorkspace,
lintConventionalCommit,
normalizeChangelog,
parseSemVer,
parseUnifiedPatch,
planRelease,
satisfiesSemVer,
} from "../../src/git/tools";
@@ -47,6 +52,33 @@ describe("Git interchange tools", () => {
expect(parsed).toMatchObject({ added: 2, deleted: 1 });
});
it("applies and reverses exact text patches atomically", () => {
const before = "a\nb\nc\n";
const after = "a\nB\nc\nd\n";
const patch = createUnifiedPatch(before, after, "src/file.txt", 1);
const applied = applyPatchToSnapshot(patch, {
"src/file.txt": before,
"README.md": "keep\n",
});
expect(applied.files).toEqual({
"src/file.txt": after,
"README.md": "keep\n",
});
expect(
applyPatchToSnapshot(patch, applied.files, true).files["src/file.txt"],
).toBe(before);
expect(() =>
applyPatchToSnapshot(patch, { "src/file.txt": "different\n" }),
).toThrow(/exact hunk match/u);
});
it("builds a deterministic text snapshot ZIP", () => {
const one = createSnapshotZip({ "b.txt": "B\n", "a.txt": "A\n" });
const two = createSnapshotZip({ "a.txt": "A\n", "b.txt": "B\n" });
expect([...one.slice(0, 4)]).toEqual([0x50, 0x4b, 0x03, 0x04]);
expect(one).toEqual(two);
});
it("explains ordered ignore rules and blocked parents", () => {
const source = `dist/\n*.log\n!keep.log\ncache/\n!cache/readme.md\n`;
const results = explainGitignore(source, [
@@ -83,6 +115,76 @@ describe("Git interchange tools", () => {
]);
});
it("evaluates global, info, nested, and tracked precedence with winning source", () => {
const results = explainGitignoreWorkspace(
{
globalExclude: "*.tmp\n*.log\n",
infoExclude: "private/\n",
root: "!keep.log\nsrc/*.tmp\n",
nested: {
"src/.gitignore": "!generated.tmp\n*.cache\n",
"src/deep/.gitignore": "!keep.cache\n",
},
tracked: ["private/tracked.txt"],
},
[
"debug.log",
"keep.log",
"src/generated.tmp",
"src/other.tmp",
"src/deep/drop.cache",
"src/deep/keep.cache",
"private/tracked.txt",
],
);
expect(results.map((item) => item.ignored)).toEqual([
true,
false,
false,
true,
true,
false,
false,
]);
expect(results[2]?.explanation).toContain("src/.gitignore:1");
expect(results[5]?.explanation).toContain("src/deep/.gitignore:1");
expect(results[6]).toMatchObject({ tracked: true });
});
it("evaluates .gitattributes states with per-attribute last matches", () => {
const [script, secret, image] = evaluateGitAttributes(
`* text=auto\n*.sh text eol=lf\nsecret.sh -text !eol\n*.png -text binary\n`,
["scripts/build.sh", "secret.sh", "logo.png"],
);
expect(script?.attributes).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "eol",
state: { kind: "value", value: "lf" },
}),
expect.objectContaining({ name: "text", state: { kind: "set" } }),
]),
);
expect(image?.attributes).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "binary", state: { kind: "set" } }),
expect.objectContaining({ name: "text", state: { kind: "unset" } }),
]),
);
expect(secret?.attributes).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "eol",
state: { kind: "unspecified" },
}),
]),
);
expect(
evaluateGitAttributes("docs/ text\n", ["docs/readme.md"])[0]
?.diagnostics[0],
).toContain("trailing slash");
});
it("implements SemVer precedence and basic ranges", () => {
expect(compareSemVer("1.0.0-alpha.1", "1.0.0-alpha.beta")).toBeLessThan(0);
expect(compareSemVer("1.0.0+one", "1.0.0+two")).toBe(0);
@@ -123,4 +225,26 @@ describe("Git interchange tools", () => {
);
expect(result.output).toContain("## [1.0.0] - 2026-01-01");
});
it("plans a release from valid conventional commit evidence", () => {
const plan = planRelease({
currentVersion: "1.4.2",
date: "2026-09-01",
commits: [
"fix(api): retain response headers",
"feat(ui): add timeline",
"bad commit",
],
});
expect(plan).toMatchObject({
bump: "minor",
nextVersion: "1.5.0",
tag: "v1.5.0",
validCommits: 2,
});
expect(plan.releaseNotes).toContain("### Added");
expect(plan.releaseNotes).toContain("### Fixed");
expect(plan.ignoredCommits).toHaveLength(1);
expect(plan.commands.at(-1)).toContain("v1.5.0");
});
});