@@ -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
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# 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
|
## 0.1.0 - 2026-09-01
|
||||||
|
|
||||||
- Added the initial local-first Git Tools workbench.
|
- Added the initial local-first Git Tools workbench.
|
||||||
|
|||||||
@@ -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 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
|
- 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
|
- 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
|
- Conventional Commit builder and structural lint with breaking-change/footer diagnostics
|
||||||
- Keep-a-Changelog heading/section normalization, stable ordering, and duplicate-entry removal
|
- 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
|
## Development
|
||||||
|
|
||||||
@@ -27,7 +30,7 @@ npm run test:browser
|
|||||||
|
|
||||||
## Release
|
## 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
|
## Licence
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Corresponding source
|
# 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`.
|
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Third-party notices
|
# 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 |
|
| Package | Pinned version | Declared licence |
|
||||||
| -------------------------------- | -------------: | ---------------- |
|
| -------------------------------- | -------------: | ---------------- |
|
||||||
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
|
| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
|
||||||
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
|
| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
|
||||||
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
|
| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
|
||||||
| `react` | 19.2.8 | MIT |
|
| `react` | 19.2.8 | MIT |
|
||||||
| `react-dom` | 19.2.8 | MIT |
|
| `react-dom` | 19.2.8 | MIT |
|
||||||
|
|
||||||
|
|||||||
Generated
+29
-23
@@ -1,22 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "git-tools",
|
"name": "git-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "git-tools",
|
"name": "git-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3",
|
"@add-ideas/toolbox-contract": "0.3.0",
|
||||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||||
|
"fflate": "0.8.2",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.62.1",
|
||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
@@ -42,24 +43,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-contract": {
|
"node_modules/@add-ideas/toolbox-contract": {
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"license": "Apache-2.0",
|
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
|
||||||
"engines": {
|
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
|
||||||
"node": ">=20"
|
"license": "Apache-2.0"
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-helpers": {
|
"node_modules/@add-ideas/toolbox-helpers": {
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz",
|
||||||
"engines": {
|
"integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
|
||||||
"node": ">=22"
|
"license": "GPL-3.0-or-later"
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-shell-react": {
|
"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",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3"
|
"@add-ideas/toolbox-contract": "0.3.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=18 <20",
|
"react": ">=18 <20",
|
||||||
@@ -67,17 +68,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-testkit": {
|
"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,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3"
|
"@add-ideas/toolbox-contract": "0.3.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"toolbox-check": "dist/cli.js"
|
"toolbox-check": "dist/cli.js"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@adobe/css-tools": {
|
"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": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
|||||||
+6
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "git-tools",
|
"name": "git-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Inspect and author Git interchange formats locally in the browser.",
|
"description": "Inspect and author Git interchange formats locally in the browser.",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"author": "Albrecht Degering",
|
"author": "Albrecht Degering",
|
||||||
@@ -39,14 +39,15 @@
|
|||||||
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3",
|
"@add-ideas/toolbox-contract": "0.3.0",
|
||||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||||
|
"fflate": "0.8.2",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.62.1",
|
||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
|
|||||||
+20
-2
@@ -15,7 +15,25 @@ export default defineConfig({
|
|||||||
timeout: 180_000,
|
timeout: 180_000,
|
||||||
},
|
},
|
||||||
projects: [
|
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"] },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# 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
|
## 0.1.0 - 2026-09-01
|
||||||
|
|
||||||
- Added the initial local-first Git Tools workbench.
|
- Added the initial local-first Git Tools workbench.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
==============================================================================
|
==============================================================================
|
||||||
@add-ideas/toolbox-contract@0.2.3
|
@add-ideas/toolbox-contract@0.3.0
|
||||||
Declared licence: Apache-2.0
|
Declared licence: Apache-2.0
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- 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
|
Declared licence: GPL-3.0-or-later
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- 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
|
Declared licence: Apache-2.0
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- LICENSE ---
|
||||||
@@ -1077,6 +1077,33 @@ Declared licence: Apache-2.0
|
|||||||
limitations under the License.
|
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
|
react@19.2.8
|
||||||
Declared licence: MIT
|
Declared licence: MIT
|
||||||
|
|||||||
+6
-3
@@ -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 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
|
- 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
|
- 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
|
- Conventional Commit builder and structural lint with breaking-change/footer diagnostics
|
||||||
- Keep-a-Changelog heading/section normalization, stable ordering, and duplicate-entry removal
|
- 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
|
## Development
|
||||||
|
|
||||||
@@ -27,7 +30,7 @@ npm run test:browser
|
|||||||
|
|
||||||
## Release
|
## 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
|
## Licence
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
# Corresponding source
|
# 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`.
|
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Third-party notices
|
# 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 |
|
| Package | Pinned version | Declared licence |
|
||||||
| -------------------------------- | -------------: | ---------------- |
|
| -------------------------------- | -------------: | ---------------- |
|
||||||
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
|
| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
|
||||||
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
|
| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
|
||||||
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
|
| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
|
||||||
| `react` | 19.2.8 | MIT |
|
| `react` | 19.2.8 | MIT |
|
||||||
| `react-dom` | 19.2.8 | MIT |
|
| `react-dom` | 19.2.8 | MIT |
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
const CACHE_PREFIX = "git-tools-shell-";
|
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"];
|
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
|
|||||||
+18
-2
@@ -3,7 +3,7 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.git-tools",
|
"id": "de.add-ideas.git-tools",
|
||||||
"name": "Git Tools",
|
"name": "Git Tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Inspect and author Git interchange formats locally in the browser.",
|
"description": "Inspect and author Git interchange formats locally in the browser.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
@@ -29,9 +29,25 @@
|
|||||||
"crossOriginIsolated": false,
|
"crossOriginIsolated": false,
|
||||||
"topLevelContext": 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": {
|
"privacy": {
|
||||||
"processing": "local",
|
"processing": "local",
|
||||||
"fileUploads": true,
|
"fileUploads": false,
|
||||||
"telemetry": false,
|
"telemetry": false,
|
||||||
"label": "Inputs stay in this browser; nothing is uploaded."
|
"label": "Inputs stay in this browser; nothing is uploaded."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ export function HelpDialog({
|
|||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
<p>
|
||||||
All processing is performed in this browser. Imported data is treated as
|
All processing is performed in this browser. Imported data is treated as
|
||||||
untrusted and bounded before parsing.
|
untrusted and bounded before parsing.
|
||||||
|
|||||||
+475
-11
@@ -4,15 +4,23 @@ import {
|
|||||||
CONVENTIONAL_TYPES,
|
CONVENTIONAL_TYPES,
|
||||||
buildConventionalCommit,
|
buildConventionalCommit,
|
||||||
compareSemVer,
|
compareSemVer,
|
||||||
|
applyPatchToSnapshot,
|
||||||
|
createSnapshotZip,
|
||||||
createUnifiedPatch,
|
createUnifiedPatch,
|
||||||
|
evaluateGitAttributes,
|
||||||
explainGitignore,
|
explainGitignore,
|
||||||
|
explainGitignoreWorkspace,
|
||||||
lintConventionalCommit,
|
lintConventionalCommit,
|
||||||
normalizeChangelog,
|
normalizeChangelog,
|
||||||
parseSemVer,
|
parseSemVer,
|
||||||
parseUnifiedPatch,
|
parseUnifiedPatch,
|
||||||
|
planRelease,
|
||||||
satisfiesSemVer,
|
satisfiesSemVer,
|
||||||
|
type GitAttributeResult,
|
||||||
type IgnoreResult,
|
type IgnoreResult,
|
||||||
|
type PatchApplicationResult,
|
||||||
type PatchDocument,
|
type PatchDocument,
|
||||||
|
type ReleasePlan,
|
||||||
} from "../git/tools";
|
} from "../git/tools";
|
||||||
|
|
||||||
const patchSample = `diff --git a/src/example.ts b/src/example.ts
|
const patchSample = `diff --git a/src/example.ts b/src/example.ts
|
||||||
@@ -62,7 +70,25 @@ const changelogSample = `# Changelog
|
|||||||
|
|
||||||
- Improve diagnostics
|
- 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") {
|
function download(value: string, filename: string, type = "text/plain") {
|
||||||
triggerBlobDownload(
|
triggerBlobDownload(
|
||||||
@@ -78,6 +104,13 @@ export function Workbench() {
|
|||||||
const [patch, setPatch] = useState<PatchDocument>(() =>
|
const [patch, setPatch] = useState<PatchDocument>(() =>
|
||||||
parseUnifiedPatch(patchSample),
|
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 [before, setBefore] = useState(beforeSample);
|
||||||
const [after, setAfter] = useState(afterSample);
|
const [after, setAfter] = useState(afterSample);
|
||||||
const [path, setPath] = useState("src/example.ts");
|
const [path, setPath] = useState("src/example.ts");
|
||||||
@@ -86,12 +119,25 @@ export function Workbench() {
|
|||||||
createUnifiedPatch(beforeSample, afterSample, "src/example.ts", 3),
|
createUnifiedPatch(beforeSample, afterSample, "src/example.ts", 3),
|
||||||
);
|
);
|
||||||
const [ignoreSource, setIgnoreSource] = useState(ignoreSample);
|
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(
|
const [ignorePaths, setIgnorePaths] = useState(
|
||||||
"dist/app.js\nnotes.log\n.keep.log\ncache/README.md\nsrc/main.ts",
|
"dist/app.js\nnotes.log\n.keep.log\ncache/README.md\nsrc/main.ts",
|
||||||
);
|
);
|
||||||
const [ignoreResults, setIgnoreResults] = useState<IgnoreResult[]>(() =>
|
const [ignoreResults, setIgnoreResults] = useState<IgnoreResult[]>(() =>
|
||||||
explainGitignore(ignoreSample, ignorePaths.split("\n")),
|
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(
|
const [versions, setVersions] = useState(
|
||||||
"1.2.3\n1.3.0-beta.1\n1.3.0\n2.0.0\n0.9.8",
|
"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(() =>
|
const [normalized, setNormalized] = useState(() =>
|
||||||
normalizeChangelog(changelogSample),
|
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 linted = useMemo(() => lintConventionalCommit(message), [message]);
|
||||||
const versionRows = useMemo(
|
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 = () => {
|
const testIgnore = () => {
|
||||||
try {
|
try {
|
||||||
setIgnoreResults(
|
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("");
|
setError("");
|
||||||
} catch (reason) {
|
} 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 = () => {
|
const buildCommit = () => {
|
||||||
try {
|
try {
|
||||||
setMessage(
|
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 (
|
return (
|
||||||
<main className="workbench">
|
<main className="workbench">
|
||||||
@@ -233,18 +348,20 @@ export function Workbench() {
|
|||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
"patch",
|
"patch",
|
||||||
|
"apply",
|
||||||
"author",
|
"author",
|
||||||
"ignore",
|
"ignore",
|
||||||
|
"attributes",
|
||||||
"versions",
|
"versions",
|
||||||
"commit",
|
"commit",
|
||||||
|
"release",
|
||||||
"changelog",
|
"changelog",
|
||||||
] as const
|
] as const
|
||||||
).map((value) => (
|
).map((value) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
aria-pressed={tab === value}
|
||||||
aria-selected={tab === value}
|
|
||||||
onClick={() => setTab(value)}
|
onClick={() => setTab(value)}
|
||||||
>
|
>
|
||||||
{value === "ignore"
|
{value === "ignore"
|
||||||
@@ -284,7 +401,8 @@ export function Workbench() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
The last valid inspection remains visible when later input is
|
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>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<section className="panel workspace">
|
<section className="panel workspace">
|
||||||
@@ -376,6 +494,117 @@ export function Workbench() {
|
|||||||
</div>
|
</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" && (
|
{tab === "author" && (
|
||||||
<section className="panel workspace">
|
<section className="panel workspace">
|
||||||
<div className="panel-heading">
|
<div className="panel-heading">
|
||||||
@@ -477,6 +706,38 @@ export function Workbench() {
|
|||||||
onChange={(event) => setIgnoreSource(event.target.value)}
|
onChange={(event) => setIgnoreSource(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<label className="field">
|
||||||
<span>Paths, one per line; end directories with /</span>
|
<span>Paths, one per line; end directories with /</span>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -501,9 +762,9 @@ export function Workbench() {
|
|||||||
{result.matched.length > 0 && (
|
{result.matched.length > 0 && (
|
||||||
<ol>
|
<ol>
|
||||||
{result.matched.map((match) => (
|
{result.matched.map((match) => (
|
||||||
<li key={match.line}>
|
<li key={`${match.origin ?? "root"}-${match.line}`}>
|
||||||
Line {match.line}: <code>{match.source}</code> →{" "}
|
{match.origin ?? ".gitignore"}:{match.line}:{" "}
|
||||||
{match.outcome}
|
<code>{match.source}</code> → {match.outcome}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</ol>
|
||||||
@@ -512,14 +773,104 @@ export function Workbench() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
This evaluates one repository-root ignore file. Nested .gitignore
|
Rules are evaluated from global excludes through .git/info/exclude
|
||||||
files, global excludes, index state, and submodule boundaries are
|
and parent-to-child .gitignore files. Explicitly tracked paths
|
||||||
not loaded.
|
remain included. Command-line excludes and submodule repository
|
||||||
|
boundaries are not modeled.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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" && (
|
{tab === "versions" && (
|
||||||
<div className="split-layout">
|
<div className="split-layout">
|
||||||
<section className="panel workspace">
|
<section className="panel workspace">
|
||||||
@@ -704,6 +1055,119 @@ export function Workbench() {
|
|||||||
</div>
|
</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" && (
|
{tab === "changelog" && (
|
||||||
<div className="split-layout">
|
<div className="split-layout">
|
||||||
<section className="panel workspace">
|
<section className="panel workspace">
|
||||||
|
|||||||
+607
-3
@@ -1,3 +1,5 @@
|
|||||||
|
import { strToU8, zipSync } from "fflate";
|
||||||
|
|
||||||
export interface PatchLine {
|
export interface PatchLine {
|
||||||
kind: "context" | "add" | "delete" | "meta";
|
kind: "context" | "add" | "delete" | "meta";
|
||||||
text: string;
|
text: string;
|
||||||
@@ -440,9 +442,217 @@ export function createUnifiedPatch(
|
|||||||
return output.join("\n") + "\n";
|
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 {
|
export interface IgnoreRule {
|
||||||
line: number;
|
line: number;
|
||||||
source: string;
|
source: string;
|
||||||
|
origin?: string;
|
||||||
|
directory?: string;
|
||||||
negated: boolean;
|
negated: boolean;
|
||||||
directoryOnly: boolean;
|
directoryOnly: boolean;
|
||||||
regex: RegExp;
|
regex: RegExp;
|
||||||
@@ -450,7 +660,13 @@ export interface IgnoreRule {
|
|||||||
export interface IgnoreResult {
|
export interface IgnoreResult {
|
||||||
path: string;
|
path: string;
|
||||||
ignored: boolean;
|
ignored: boolean;
|
||||||
matched: { line: number; source: string; outcome: "ignore" | "include" }[];
|
tracked?: boolean;
|
||||||
|
matched: {
|
||||||
|
line: number;
|
||||||
|
source: string;
|
||||||
|
outcome: "ignore" | "include";
|
||||||
|
origin?: string;
|
||||||
|
}[];
|
||||||
explanation: string;
|
explanation: string;
|
||||||
}
|
}
|
||||||
function trimGitignoreLine(line: string): string {
|
function trimGitignoreLine(line: string): string {
|
||||||
@@ -480,10 +696,24 @@ function globRegex(pattern: string): string {
|
|||||||
}
|
}
|
||||||
return output;
|
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)
|
if (source.length > 512 * 1024)
|
||||||
throw new Error(".gitignore input exceeds 512 KiB.");
|
throw new Error(".gitignore input exceeds 512 KiB.");
|
||||||
const rules: IgnoreRule[] = [];
|
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
|
for (const [index, original] of source
|
||||||
.replaceAll("\r\n", "\n")
|
.replaceAll("\r\n", "\n")
|
||||||
.replaceAll("\r", "\n")
|
.replaceAll("\r", "\n")
|
||||||
@@ -503,12 +733,20 @@ export function compileGitignore(source: string): IgnoreRule[] {
|
|||||||
if (anchored) line = line.slice(1);
|
if (anchored) line = line.slice(1);
|
||||||
const hasSlash = line.includes("/");
|
const hasSlash = line.includes("/");
|
||||||
const body = globRegex(line);
|
const body = globRegex(line);
|
||||||
const prefix = anchored || hasSlash ? "^" : "(?:^|.*/)";
|
const base = directory ? `${globRegex(directory)}/` : "";
|
||||||
|
const prefix =
|
||||||
|
anchored || hasSlash
|
||||||
|
? `^${base}`
|
||||||
|
: directory
|
||||||
|
? `^${base}(?:.*/)?`
|
||||||
|
: "(?:^|.*/)";
|
||||||
const suffix = directoryOnly ? "(?:/.*)$" : "(?:/.*)?$";
|
const suffix = directoryOnly ? "(?:/.*)$" : "(?:/.*)?$";
|
||||||
try {
|
try {
|
||||||
rules.push({
|
rules.push({
|
||||||
line: index + 1,
|
line: index + 1,
|
||||||
source: original,
|
source: original,
|
||||||
|
...(options.origin ? { origin: options.origin } : {}),
|
||||||
|
...(directory ? { directory } : {}),
|
||||||
negated,
|
negated,
|
||||||
directoryOnly,
|
directoryOnly,
|
||||||
regex: new RegExp(prefix + body + suffix, "u"),
|
regex: new RegExp(prefix + body + suffix, "u"),
|
||||||
@@ -536,6 +774,7 @@ function evaluateIgnore(
|
|||||||
line: rule.line,
|
line: rule.line,
|
||||||
source: rule.source,
|
source: rule.source,
|
||||||
outcome: ignored ? "ignore" : "include",
|
outcome: ignored ? "ignore" : "include",
|
||||||
|
...(rule.origin ? { origin: rule.origin } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { ignored, matched };
|
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 {
|
export interface SemVer {
|
||||||
major: number;
|
major: number;
|
||||||
minor: number;
|
minor: number;
|
||||||
@@ -1000,3 +1478,129 @@ export function normalizeChangelog(source: string): {
|
|||||||
diagnostics,
|
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
@@ -161,7 +161,7 @@ textarea {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding-bottom: 0.2rem;
|
padding-bottom: 0.2rem;
|
||||||
}
|
}
|
||||||
.workspace-tabs button[aria-selected="true"] {
|
.workspace-tabs button[aria-pressed="true"] {
|
||||||
border-color: var(--toolbox-accent);
|
border-color: var(--toolbox-accent);
|
||||||
background: var(--toolbox-accent);
|
background: var(--toolbox-accent);
|
||||||
color: var(--toolbox-accent-contrast);
|
color: var(--toolbox-accent-contrast);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.git-tools",
|
"id": "de.add-ideas.git-tools",
|
||||||
"name": "Git Tools",
|
"name": "Git Tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Inspect and author Git interchange formats locally in the browser.",
|
"description": "Inspect and author Git interchange formats locally in the browser.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
@@ -29,9 +29,43 @@
|
|||||||
"crossOriginIsolated": false,
|
"crossOriginIsolated": false,
|
||||||
"topLevelContext": 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": {
|
"privacy": {
|
||||||
"processing": "local",
|
"processing": "local",
|
||||||
"fileUploads": true,
|
"fileUploads": false,
|
||||||
"telemetry": false,
|
"telemetry": false,
|
||||||
"label": "Inputs stay in this browser; nothing is uploaded."
|
"label": "Inputs stay in this browser; nothing is uploaded."
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = "0.1.0";
|
export const APP_VERSION = "0.2.0";
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ test("authors and re-inspects a patch without flashing state", async ({
|
|||||||
}) => {
|
}) => {
|
||||||
const external = await localOnly(page);
|
const external = await localOnly(page);
|
||||||
await page.goto("/deep/nested/git/");
|
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.getByLabel("After text").fill("alpha\nbeta\n");
|
||||||
await page.getByRole("button", { name: "Generate patch" }).click();
|
await page.getByRole("button", { name: "Generate patch" }).click();
|
||||||
await expect(page.getByLabel("Generated patch")).toContainText("+alpha");
|
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");
|
const manifest = await request.get("/deep/nested/git/toolbox-app.json");
|
||||||
await expect(manifest.json()).resolves.toMatchObject({
|
await expect(manifest.json()).resolves.toMatchObject({
|
||||||
id: "de.add-ideas.git-tools",
|
id: "de.add-ideas.git-tools",
|
||||||
version: "0.1.0",
|
version: "0.2.0",
|
||||||
entry: "./",
|
entry: "./",
|
||||||
privacy: { processing: "local", telemetry: false },
|
privacy: { processing: "local", telemetry: false },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -14,7 +14,7 @@ describe("Git Tools", () => {
|
|||||||
await screen.findByRole("heading", { name: "Git Tools" }),
|
await screen.findByRole("heading", { name: "Git Tools" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await userEvent.click(
|
await userEvent.click(
|
||||||
await screen.findByRole("tab", { name: ".gitignore" }),
|
await screen.findByRole("button", { name: ".gitignore" }),
|
||||||
);
|
);
|
||||||
expect(screen.getAllByText(/Last matching rule/u).length).toBeGreaterThan(
|
expect(screen.getAllByText(/Last matching rule/u).length).toBeGreaterThan(
|
||||||
0,
|
0,
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
applyPatchToSnapshot,
|
||||||
buildConventionalCommit,
|
buildConventionalCommit,
|
||||||
compareSemVer,
|
compareSemVer,
|
||||||
|
createSnapshotZip,
|
||||||
createUnifiedPatch,
|
createUnifiedPatch,
|
||||||
|
evaluateGitAttributes,
|
||||||
explainGitignore,
|
explainGitignore,
|
||||||
|
explainGitignoreWorkspace,
|
||||||
lintConventionalCommit,
|
lintConventionalCommit,
|
||||||
normalizeChangelog,
|
normalizeChangelog,
|
||||||
parseSemVer,
|
parseSemVer,
|
||||||
parseUnifiedPatch,
|
parseUnifiedPatch,
|
||||||
|
planRelease,
|
||||||
satisfiesSemVer,
|
satisfiesSemVer,
|
||||||
} from "../../src/git/tools";
|
} from "../../src/git/tools";
|
||||||
|
|
||||||
@@ -47,6 +52,33 @@ describe("Git interchange tools", () => {
|
|||||||
expect(parsed).toMatchObject({ added: 2, deleted: 1 });
|
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", () => {
|
it("explains ordered ignore rules and blocked parents", () => {
|
||||||
const source = `dist/\n*.log\n!keep.log\ncache/\n!cache/readme.md\n`;
|
const source = `dist/\n*.log\n!keep.log\ncache/\n!cache/readme.md\n`;
|
||||||
const results = explainGitignore(source, [
|
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", () => {
|
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-alpha.1", "1.0.0-alpha.beta")).toBeLessThan(0);
|
||||||
expect(compareSemVer("1.0.0+one", "1.0.0+two")).toBe(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");
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user