Release ForgeFlow 0.8.9 Git Validator

This commit is contained in:
NuklearRabbit
2026-07-26 05:24:22 +02:00
parent e888bd2c1c
commit 24dde9a031
17 changed files with 1106 additions and 22 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog # Changelog
## 0.8.9 - 2026-07-26
- added a per-repository Git Validator with a weighted assurance score and evidence-backed checks;
- validates Gitea branch governance, repository identity, upstream tracking, effective author identity, safe synchronization defaults, README and gitignore hygiene, tracked secret-shaped files and oversized files;
- provides audited one-click repairs for origin alignment and repository-local safety configuration;
- offers confirmed repairs for default-branch protection and a reviewable uncommitted `.gitignore`;
- introduced a premium, theme-aware and container-responsive Validator workspace with safe-fix batching.
## 0.8.8 - 2026-07-26 ## 0.8.8 - 2026-07-26
- fixed binary update downloads on Gitea servers that require release-scoped attachment routes; - fixed binary update downloads on Gitea servers that require release-scoped attachment routes;
+17 -14
View File
@@ -1,4 +1,4 @@
ForgeFlow 0.8.8 source manifest ForgeFlow 0.8.9 source manifest
SHA-256 BYTES PATH SHA-256 BYTES PATH
(The manifest excludes itself, dependencies and generated release artifacts.) (The manifest excludes itself, dependencies and generated release artifacts.)
755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore 755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore
@@ -12,7 +12,7 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263
4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png 4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png
25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico 25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png
b5ebb381645c9a7e9de693ff30c563819b70182931057727dc6e48db551adf9f 10557 CHANGELOG.md 2038d46210c3c2ac582889f9ed5e352a9ff4f2b0bbd80d25adcc20aba7c3e09b 11200 CHANGELOG.md
21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md 21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md
a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md
30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md 30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md
@@ -46,6 +46,7 @@ b516db97a0353babc810c24a87a971d30d72a8021d809e6b833ff7ae0458f442 538
838d196f3fbbfeee8df375a0502107f56b6df28babca30aa09ef1c7aa5196d09 738 docs/RELEASE_NOTES_0.8.6.md 838d196f3fbbfeee8df375a0502107f56b6df28babca30aa09ef1c7aa5196d09 738 docs/RELEASE_NOTES_0.8.6.md
ef049adcfa204908e6dc3a059124b39ba0e2739cc54e38945ce73a57049df0d8 1185 docs/RELEASE_NOTES_0.8.7.md ef049adcfa204908e6dc3a059124b39ba0e2739cc54e38945ce73a57049df0d8 1185 docs/RELEASE_NOTES_0.8.7.md
7eedb25e1aae3b06a04bb9b2f4843bd6af614418737e600b4bdc9161edabd76a 632 docs/RELEASE_NOTES_0.8.8.md 7eedb25e1aae3b06a04bb9b2f4843bd6af614418737e600b4bdc9161edabd76a 632 docs/RELEASE_NOTES_0.8.8.md
35dcfda990946480d6d55bd2d2e6360c336260bcd05cdb51d92e07a4e8d76945 1046 docs/RELEASE_NOTES_0.8.9.md
2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md 2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md
1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png 1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png
b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png
@@ -67,11 +68,11 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532
106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf 106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf
2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json 2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json
4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE 4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE
0d9f0ac837b4d3b39476d9bcbb57fb3e12d6b7084bb1f7435401e416cff1854f 12920 main.cjs 3b16a087c73b600415394dff8b8e34e7f7519e48fde1cf443007b2e11ca77b27 13123 main.cjs
91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md
dc26585558f060c74a11bc7033cf1f22c626e91668a6349a06a928114ecf417d 130466 package-lock.json ab7c04a2df33ebe2861adc68c74d8f7b0eeb6a2d7c72410c26bc66b883d985c7 130466 package-lock.json
e3d60b3adde674e5aa80cc7776db3efbc0ced66d49e89490cb9fbb6786c0db70 3906 package.json 4256d59aa47ea13d9e305370c2602887930d6069f4db34dc91c1f738d4b79e97 3943 package.json
45eea5fe4fd58cc0e96497295223e81a4f3acc3778bf546d4c7c96af03da4d26 9496 preload.cjs b5dc2b79453afbcd74fa94ddacf078bf22c00807ebc4eb648d2451de49634329 9668 preload.cjs
b31c43d9355c13b5ae4efc0f3649d8cb8d509b2bb7ebb042ff546b7820fb7de8 8411 Publish-ForgeFlow-Release.ps1 b31c43d9355c13b5ae4efc0f3649d8cb8d509b2bb7ebb042ff546b7820fb7de8 8411 Publish-ForgeFlow-Release.ps1
a6d32a742412b7836606be00f17be0465f1b6f55d3911f6c73a14029787ba206 14037 README.md a6d32a742412b7836606be00f17be0465f1b6f55d3911f6c73a14029787ba206 14037 README.md
509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs 509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs
@@ -83,7 +84,7 @@ f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287
74433d8a6b24afe368197a469e2fe0c5050c239d7250b84c2f3f598c304778b0 4736 scripts/publish-binary-release.cjs 74433d8a6b24afe368197a469e2fe0c5050c239d7250b84c2f3f598c304778b0 4736 scripts/publish-binary-release.cjs
444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs 444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs
42203f9e0fd4aae517284d387f265cf1b0b180379bc253a092b5c3c5c4caef0a 2992 scripts/validate-installed-connections.cjs 42203f9e0fd4aae517284d387f265cf1b0b180379bc253a092b5c3c5c4caef0a 2992 scripts/validate-installed-connections.cjs
1a0931346c89d2d49b736a25aa58c5d29fbdf4acd593ea3614dba904ec462843 12374 scripts/verify.mjs c7942bac000c9850630dc086f260f2dbf4bf4b8b435dac3631e7fe5ca41d507f 12407 scripts/verify.mjs
0079701b5acbfef07b71a9623613d1940805ccd20649d77e3f34c37e79df7655 735 scripts/write-release-checksums.mjs 0079701b5acbfef07b71a9623613d1940805ccd20649d77e3f34c37e79df7655 735 scripts/write-release-checksums.mjs
619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1 619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1
dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs
@@ -93,8 +94,9 @@ a381848a296c28f6d14093c96f722967acf9c994ffb867d54dd92bf5ada2729b 23648
c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs
a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs
0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs 0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs
6ee0c645521c20043708595d1a9dd0fe7642fc300be1306effd7e1142eebcfb7 17201 src/main/gitea-service.cjs 857f270a2204b743421eea619a6a88595f749e4c24c1794cb092ace7987d25dc 12553 src/main/git-validator-service.cjs
13f6521bb82aa5a3399d6839ea7563459b19969c1e5af7525e4116cf771282e9 46526 src/main/ipc.cjs 1c0a1c1b7f20734c646c874e07c89550e601951351c04770874c1ed3496f0833 17837 src/main/gitea-service.cjs
dadf2fd2e3d148456da0b735a1d9af142196b230a02346a9ca29c7605d7e0b87 47704 src/main/ipc.cjs
62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs 62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs
958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs 958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs
3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs 3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs
@@ -103,14 +105,14 @@ e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508
b31a63bf8cb1807b3e838e2bf8a0e742738f119d13de8ca9f42e471f072217d3 8328 src/main/ssh-service.cjs b31a63bf8cb1807b3e838e2bf8a0e742738f119d13de8ca9f42e471f072217d3 8328 src/main/ssh-service.cjs
f4e221edace12e2120ec439e0f91274de110872c5bfbe968f2d7935eeb7193c4 78544 src/main/unraid-deployment-service.cjs f4e221edace12e2120ec439e0f91274de110872c5bfbe968f2d7935eeb7193c4 78544 src/main/unraid-deployment-service.cjs
45e65564e1e8b9db487dc6dda03a752c51130189f23ec3b1260dc62e3621a925 20806 src/main/update-service.cjs 45e65564e1e8b9db487dc6dda03a752c51130189f23ec3b1260dc62e3621a925 20806 src/main/update-service.cjs
00b5c44b7d04452498288d42a2c345e0e3922c8ddfabfee9eb04654f238811d5 197015 src/renderer/app.js 9729e1124a95899f0803e2cd5147bd6d3ae58010d659c408ddd69a31f5d1cb0e 202489 src/renderer/app.js
16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 src/renderer/assets/itworx-mark.png
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png
094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png 094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png
813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png
e1c463d6cda9f2b9b78c468845c0a7e8688f0362be5642074a1a5f7122dfe811 762 src/renderer/index.html e1c463d6cda9f2b9b78c468845c0a7e8688f0362be5642074a1a5f7122dfe811 762 src/renderer/index.html
2b68194e9ca0caab1ea2d1e5f7222a42f962ea16f3dd9517a7a13af811963969 51166 src/renderer/mock-bridge.js e56e81ec2f2c9743332de0e19aca75bf1aa585351a790d7b32ae1ccdb6a26b4c 54717 src/renderer/mock-bridge.js
e30ea37ee00f34110d0d252c79f9d8e0bb127c4db0bd1bf8b374609dea433361 72160 src/renderer/styles.css 607b2592c6aee37de91126a80704c1f7d8575cb2e5ff889305cd86a37e6170c4 76937 src/renderer/styles.css
0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs
5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs 5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs
029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs 029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs
@@ -132,13 +134,14 @@ fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800
dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs
e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs
5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs 5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs
c98cbe50a783e2a1cfecf9052f558aabe656add6a463899532dd59d743b720b2 3645 tests/git-validator.test.mjs
681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs 681ab7bcd02c4dd98d1d8d2092a3521c489d941131e7ffe5903971b940046474 2403 tests/git-workflows.test.mjs
e9c45a70635adea853a0a420caabd5ba465070296d2e73ffbb947d4ade52ed46 6165 tests/gitea-actions.test.mjs 8df055080e7166a52d36a6fdc0bab40c09b054d579c6848fcc076245de1573c4 6797 tests/gitea-actions.test.mjs
267d76b868d8d06ea031c14acd09a7715fb44668a25ade51a9e62e0170888bc8 1522 tests/ipc-contract.test.mjs 267d76b868d8d06ea031c14acd09a7715fb44668a25ade51a9e62e0170888bc8 1522 tests/ipc-contract.test.mjs
caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs
96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs 96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.test.mjs
c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs c0f8f5a3784835f19d9ff1015185ccb385840b6fa1c9ec19f233393a7d952b65 3718 tests/preflight.test.mjs
185e96ff8fd029f06b77155117f492bc16684e08818a160148e00737dd8c220b 8395 tests/renderer-workflow.test.mjs 9b2534196f2f6f61fd1c8c5dc44634375bb65ad3000096904d6b10c07d221b13 9158 tests/renderer-workflow.test.mjs
2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs 2b4956fa4df4624a04117737e57ba74020564330ff71303b5746d8ccc881e880 854 tests/repository-matching.test.mjs
f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs
75b5b83836c75675bb9a48fe4363fcb8a24fc425e6af6f822d7955c6f3c79eac 2265 tests/repository-service.test.mjs 75b5b83836c75675bb9a48fe4363fcb8a24fc425e6af6f822d7955c6f3c79eac 2265 tests/repository-service.test.mjs
+20
View File
@@ -0,0 +1,20 @@
# ForgeFlow 0.8.9
ForgeFlow 0.8.9 introduces Git Validator, a dedicated repository assurance
workspace that checks whether practical Git and Gitea best practices are being
followed.
The validator produces a weighted score with evidence for repository identity,
upstream tracking, working-tree state, effective commit identity, safe local
synchronization defaults, default-branch and force-push protection, README and
gitignore hygiene, tracked secret-shaped filenames and oversized tracked files.
Every repair is deliberately bounded. Origin alignment and repository-local
fetch/pull/autostash safeguards can be applied as safe fixes. Creating default
branch protection or a recommended `.gitignore` requires explicit confirmation.
The generated `.gitignore` remains uncommitted for review, and secret/history
findings are never modified automatically.
The new workspace includes grouped findings, an assurance score, safe-fix
batching, audit events, interactive project illustration and responsive premium
layouts for light and dark themes.
+7
View File
@@ -25,6 +25,7 @@ const {
UnraidDeploymentService, UnraidDeploymentService,
} = require("./src/main/unraid-deployment-service.cjs"); } = require("./src/main/unraid-deployment-service.cjs");
const { AuditService } = require("./src/main/audit-service.cjs"); const { AuditService } = require("./src/main/audit-service.cjs");
const { GitValidatorService } = require("./src/main/git-validator-service.cjs");
const { const {
ExternalToolsService, ExternalToolsService,
} = require("./src/main/external-tools-service.cjs"); } = require("./src/main/external-tools-service.cjs");
@@ -285,6 +286,11 @@ app
userDataPath, userDataPath,
secureStorageAvailable: () => safeStorage.isEncryptionAvailable(), secureStorageAvailable: () => safeStorage.isEncryptionAvailable(),
}); });
const gitValidator = new GitValidatorService({
git,
gitea,
diagnostics,
});
repositoryMonitor = new RepositoryMonitor({ repositoryMonitor = new RepositoryMonitor({
store, store,
git, git,
@@ -302,6 +308,7 @@ app
ssh, ssh,
updates, updates,
preflight, preflight,
gitValidator,
diagnostics, diagnostics,
audit, audit,
externalTools, externalTools,
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "forgeflow", "name": "forgeflow",
"version": "0.8.8", "version": "0.8.9",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "forgeflow", "name": "forgeflow",
"version": "0.8.8", "version": "0.8.9",
"dependencies": { "dependencies": {
"ssh2": "1.17.0" "ssh2": "1.17.0"
}, },
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "forgeflow", "name": "forgeflow",
"version": "0.8.8", "version": "0.8.9",
"private": true, "private": true,
"description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.", "description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.",
"main": "main.cjs", "main": "main.cjs",
@@ -81,6 +81,7 @@
"docs/RELEASE_NOTES_0.8.6.md", "docs/RELEASE_NOTES_0.8.6.md",
"docs/RELEASE_NOTES_0.8.7.md", "docs/RELEASE_NOTES_0.8.7.md",
"docs/RELEASE_NOTES_0.8.8.md", "docs/RELEASE_NOTES_0.8.8.md",
"docs/RELEASE_NOTES_0.8.9.md",
"docs/ACCEPTANCE.md" "docs/ACCEPTANCE.md"
], ],
"asarUnpack": [ "asarUnpack": [
+2
View File
@@ -127,6 +127,8 @@ contextBridge.exposeInMainWorld(
troubleshooterScan: (fullName = null) => invoke('troubleshooter:scan', { fullName }), troubleshooterScan: (fullName = null) => invoke('troubleshooter:scan', { fullName }),
troubleshooterRepair: (issue) => invoke('troubleshooter:repair', { issue }), troubleshooterRepair: (issue) => invoke('troubleshooter:repair', { issue }),
troubleshooterAutoRepair: (issues) => invoke('troubleshooter:auto-repair', { issues }), troubleshooterAutoRepair: (issues) => invoke('troubleshooter:auto-repair', { issues }),
gitValidatorScan: (fullName) => invoke('git-validator:scan', { fullName }),
gitValidatorRepair: (fullName, check) => invoke('git-validator:repair', { fullName, check }),
diagnosticsStatus: () => invoke('diagnostics:status'), diagnosticsStatus: () => invoke('diagnostics:status'),
clearDiagnostics: () => invoke('diagnostics:clear'), clearDiagnostics: () => invoke('diagnostics:clear'),
openDiagnosticsFolder: () => invoke('diagnostics:open-folder'), openDiagnosticsFolder: () => invoke('diagnostics:open-folder'),
+3 -2
View File
@@ -69,6 +69,7 @@ const required = [
"docs/RELEASE_NOTES_0.8.6.md", "docs/RELEASE_NOTES_0.8.6.md",
"docs/RELEASE_NOTES_0.8.7.md", "docs/RELEASE_NOTES_0.8.7.md",
"docs/RELEASE_NOTES_0.8.8.md", "docs/RELEASE_NOTES_0.8.8.md",
"docs/RELEASE_NOTES_0.8.9.md",
"docs/UPDATING.md", "docs/UPDATING.md",
"docs/DIAGNOSTICS.md", "docs/DIAGNOSTICS.md",
"docs/DEPLOYMENT_SETUP.md", "docs/DEPLOYMENT_SETUP.md",
@@ -107,9 +108,9 @@ for (const file of required) await access(path.join(root, file));
const packageJson = JSON.parse( const packageJson = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"), await readFile(path.join(root, "package.json"), "utf8"),
); );
if (packageJson.version !== "0.8.8") if (packageJson.version !== "0.8.9")
throw new Error( throw new Error(
`Expected package version 0.8.8, got ${packageJson.version}.`, `Expected package version 0.8.9, got ${packageJson.version}.`,
); );
const sourceManifest = await readFile( const sourceManifest = await readFile(
path.join(root, "SOURCE_MANIFEST.txt"), path.join(root, "SOURCE_MANIFEST.txt"),
+416
View File
@@ -0,0 +1,416 @@
"use strict";
const fs = require("node:fs/promises");
const path = require("node:path");
const { run } = require("./process-runner.cjs");
const { normalizeRemoteUrl } = require("../shared/repository-match.cjs");
const RECOMMENDED_GITIGNORE = `# Local configuration and secrets
.env
.env.*
!.env.example
!.env.sample
# Dependencies and generated output
node_modules/
dist/
build/
coverage/
# Editors and operating systems
.idea/
.vscode/
.DS_Store
Thumbs.db
`;
function sameRemote(left, right) {
const a = normalizeRemoteUrl(left);
const b = normalizeRemoteUrl(right);
return Boolean(a && b && a.host === b.host && a.path === b.path);
}
function result(id, category, title, status, detail, options = {}) {
return {
id,
category,
title,
status,
detail,
weight: options.weight || 5,
fixAction: options.fixAction || null,
safe: options.safe === true,
confirmation: options.confirmation || null,
};
}
function isSensitiveTrackedPath(filePath) {
const value = String(filePath || "")
.replace(/\\/g, "/")
.toLowerCase();
if (/\.env\.(example|sample|template)$/.test(value)) return false;
return (
/(^|\/)\.env($|\.)/.test(value) ||
/(^|\/)(id_rsa|id_ed25519)$/.test(value) ||
/\.(pem|p12|pfx|key)$/.test(value) ||
/(^|\/)(credentials|secrets?)(\.[^/]+)?\.(json|ya?ml)$/.test(value)
);
}
class GitValidatorService {
constructor({ git, gitea, diagnostics }) {
this.git = git;
this.gitea = gitea;
this.diagnostics = diagnostics;
}
async config(root, key, { local = true } = {}) {
const response = await run(
"git",
["config", ...(local ? ["--local"] : []), "--get", key],
{
cwd: root,
timeout: 10_000,
allowExitCodes: [1],
},
);
return response.stdout.trim();
}
async trackedFiles(root) {
const response = await run("git", ["ls-files", "-z"], {
cwd: root,
timeout: 30_000,
maxBuffer: 16 * 1024 * 1024,
});
return response.stdout.split("\0").filter(Boolean);
}
async scan(repository) {
const checks = [];
const defaultBranch = repository.defaultBranch || "main";
const owner = repository.owner?.login;
try {
const protection = await this.gitea.getBranchProtection(
owner,
repository.name,
defaultBranch,
);
checks.push(
result(
"default-branch-protection",
"Gitea governance",
"Default branch protection",
protection.protected ? "pass" : "warning",
protection.protected
? `${defaultBranch} is protected; force push is ${protection.enableForcePush ? "allowed" : "blocked"}.`
: `${defaultBranch} accepts unprotected direct changes.`,
{
weight: 18,
fixAction: protection.protected ? null : "protect-default-branch",
safe: false,
confirmation: `Protect ${defaultBranch} on Gitea and block direct and force pushes?`,
},
),
);
if (protection.protected)
checks.push(
result(
"force-push",
"Gitea governance",
"Force-push protection",
protection.enableForcePush ? "warning" : "pass",
protection.enableForcePush
? "Force pushes remain enabled on the protected branch."
: "Force pushes are blocked on the protected branch.",
{ weight: 8 },
),
);
} catch (error) {
checks.push(
result(
"branch-protection-unavailable",
"Gitea governance",
"Branch protection could not be verified",
"warning",
error.message,
{ weight: 18 },
),
);
}
if (!repository.localPath) {
checks.push(
result(
"local-link",
"Local repository",
"Local working tree",
"warning",
"Link or clone this repository to validate files and local Git configuration.",
{ weight: 35 },
),
);
return this.summarize(repository, checks);
}
const root = await this.git.ensureRepository(repository.localPath);
const status = await this.git.status(root);
const tracked = await this.trackedFiles(root);
const lowerFiles = tracked.map((file) => file.toLowerCase());
const desiredRemote =
repository.preferredCloneUrl || repository.cloneUrl || repository.sshUrl;
checks.push(
result(
"origin",
"Repository identity",
"Origin matches Gitea",
sameRemote(status.remoteUrl, desiredRemote) ? "pass" : "error",
sameRemote(status.remoteUrl, desiredRemote)
? status.remoteUrl
: `Current origin ${status.remoteUrl || "is missing"}; expected ${desiredRemote}.`,
{
weight: 15,
fixAction: sameRemote(status.remoteUrl, desiredRemote)
? null
: "align-origin",
safe: true,
},
),
);
checks.push(
result(
"upstream",
"Branch hygiene",
"Current branch has an upstream",
status.branch?.upstream ? "pass" : "warning",
status.branch?.upstream
? `${status.branch.head} tracks ${status.branch.upstream}.`
: `${status.branch?.head || "The current branch"} is not published or tracked.`,
{ weight: 8 },
),
);
checks.push(
result(
"working-tree",
"Branch hygiene",
"Working tree is intentional",
status.clean ? "pass" : "warning",
status.clean
? "No uncommitted changes."
: `${status.counts.changed} changed file(s) require review, commit or stash.`,
{ weight: 5 },
),
);
const [userName, userEmail, fetchPrune, pullFf, autoStash] =
await Promise.all([
this.config(root, "user.name", { local: false }),
this.config(root, "user.email", { local: false }),
this.config(root, "fetch.prune"),
this.config(root, "pull.ff"),
this.config(root, "rebase.autoStash"),
]);
checks.push(
result(
"identity",
"Commit integrity",
"Repository author identity",
userName && userEmail ? "pass" : "warning",
userName && userEmail
? `${userName} <${userEmail}>`
: "The effective Git user.name or user.email is missing.",
{ weight: 7 },
),
);
const safetyReady =
fetchPrune === "true" && pullFf === "only" && autoStash === "true";
checks.push(
result(
"local-safety",
"Local configuration",
"Safe synchronization defaults",
safetyReady ? "pass" : "warning",
safetyReady
? "Stale remotes are pruned, pulls are fast-forward-only and rebase autostash is enabled."
: "Recommended repository-local fetch, pull and autostash safeguards are incomplete.",
{
weight: 10,
fixAction: safetyReady ? null : "configure-local-safety",
safe: true,
},
),
);
const hasReadme = lowerFiles.some((file) =>
/(^|\/)readme(\.[^/]+)?$/.test(file),
);
checks.push(
result(
"readme",
"Repository documentation",
"README is versioned",
hasReadme ? "pass" : "warning",
hasReadme
? "Repository purpose and usage can be documented at the source."
: "No tracked README was found.",
{ weight: 7 },
),
);
const hasGitignore = lowerFiles.includes(".gitignore");
checks.push(
result(
"gitignore",
"Repository hygiene",
".gitignore is versioned",
hasGitignore ? "pass" : "warning",
hasGitignore
? "Generated and local-only files can be excluded centrally."
: "No tracked .gitignore was found.",
{
weight: 8,
fixAction: hasGitignore ? null : "add-gitignore",
safe: false,
confirmation:
"Create a recommended .gitignore in the working tree? It will remain uncommitted for review.",
},
),
);
const sensitive = tracked.filter(isSensitiveTrackedPath);
checks.push(
result(
"tracked-secrets",
"Security",
"No secret-shaped files are tracked",
sensitive.length ? "error" : "pass",
sensitive.length
? `Review immediately: ${sensitive.slice(0, 8).join(", ")}${sensitive.length > 8 ? "…" : ""}. Removing a file does not erase Git history.`
: "No tracked environment, private-key or credential filenames were detected.",
{ weight: 22 },
),
);
const large = [];
const candidates = tracked.slice(0, 5000);
for (
let index = 0;
index < candidates.length && large.length < 12;
index += 64
) {
const batch = candidates.slice(index, index + 64);
const stats = await Promise.all(
batch.map(async (file) => ({
file,
stat: await fs.stat(path.join(root, file)).catch(() => null),
})),
);
for (const item of stats) {
if (item.stat?.isFile() && item.stat.size > 10 * 1024 * 1024)
large.push({ file: item.file, size: item.stat.size });
if (large.length >= 12) break;
}
}
checks.push(
result(
"large-files",
"Repository performance",
"No oversized tracked files",
large.length ? "warning" : "pass",
large.length
? `${large.map((item) => `${item.file} (${Math.ceil(item.size / 1024 / 1024)} MB)`).join(", ")}. Consider Git LFS.`
: "No tracked files above 10 MB were found.",
{ weight: 7 },
),
);
return this.summarize(repository, checks);
}
summarize(repository, checks) {
const totalWeight = checks.reduce((sum, check) => sum + check.weight, 0);
const earned = checks.reduce(
(sum, check) =>
sum +
(check.status === "pass"
? check.weight
: check.status === "warning"
? check.weight * 0.45
: 0),
0,
);
const score = totalWeight ? Math.round((earned / totalWeight) * 100) : 0;
return {
repository: repository.fullName,
checkedAt: new Date().toISOString(),
score,
grade:
score >= 90
? "Excellent"
: score >= 75
? "Good"
: score >= 55
? "Needs attention"
: "High risk",
checks,
summary: {
passed: checks.filter((check) => check.status === "pass").length,
warnings: checks.filter((check) => check.status === "warning").length,
errors: checks.filter((check) => check.status === "error").length,
repairable: checks.filter((check) => check.fixAction).length,
},
};
}
async repair(repository, check) {
if (!check?.fixAction)
throw new Error("This validator check has no repair action.");
const root = repository.localPath
? await this.git.ensureRepository(repository.localPath)
: null;
if (check.fixAction === "align-origin") {
return this.git.setRemoteUrl(
root,
repository.preferredCloneUrl ||
repository.cloneUrl ||
repository.sshUrl,
);
}
if (check.fixAction === "configure-local-safety") {
for (const [key, value] of [
["fetch.prune", "true"],
["pull.ff", "only"],
["rebase.autoStash", "true"],
])
await run("git", ["config", "--local", key, value], {
cwd: root,
timeout: 10_000,
});
return { configured: true };
}
if (check.fixAction === "add-gitignore") {
const target = path.join(root, ".gitignore");
const exists = await fs.stat(target).catch(() => null);
if (exists)
throw new Error(".gitignore already exists; rescan before repairing.");
await fs.writeFile(target, RECOMMENDED_GITIGNORE, {
encoding: "utf8",
flag: "wx",
});
return { created: ".gitignore" };
}
if (check.fixAction === "protect-default-branch") {
return this.gitea.createBranchProtection(
repository.owner.login,
repository.name,
repository.defaultBranch || "main",
);
}
throw new Error("Unsupported Git Validator repair action.");
}
}
module.exports = {
GitValidatorService,
RECOMMENDED_GITIGNORE,
sameRemote,
isSensitiveTrackedPath,
};
+22
View File
@@ -204,6 +204,28 @@ class GiteaService {
}; };
} }
async createBranchProtection(owner, repo, branch) {
const target = assertBranchName(branch);
return (
await this.request(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branch_protections`,
{
method: "POST",
body: {
rule_name: target,
branch_name: target,
enable_push: false,
enable_force_push: false,
required_approvals: 0,
dismiss_stale_approvals: true,
block_on_rejected_reviews: true,
block_on_outdated_branch: true,
},
},
)
).data;
}
async listPullRequests({ owner, repo, state = "open", limit = 30 } = {}) { async listPullRequests({ owner, repo, state = "open", limit = 30 } = {}) {
const query = new URLSearchParams({ const query = new URLSearchParams({
state, state,
+35
View File
@@ -87,6 +87,7 @@ function registerIpc({
ssh, ssh,
updates, updates,
preflight, preflight,
gitValidator,
diagnostics, diagnostics,
audit, audit,
externalTools, externalTools,
@@ -841,6 +842,40 @@ function registerIpc({
return true; return true;
}); });
register("git-validator:scan", async ({ fullName }) => {
const repository = await resolveRepository({ fullName });
const report = await gitValidator.scan(repository);
await diagnostics.info("git-validator.scan.completed", {
repository: repository.fullName,
score: report.score,
summary: report.summary,
});
return report;
});
register("git-validator:repair", async ({ fullName, check }) => {
const repository = await resolveRepository({ fullName });
const allowed = new Set([
"align-origin",
"configure-local-safety",
"add-gitignore",
"protect-default-branch",
]);
if (!allowed.has(check?.fixAction))
throw new Error("Unsupported Git Validator repair request.");
const result = await gitValidator.repair(repository, check);
await audit.append("git-validator.repair", {
repository: repository.fullName,
checkId: check.id,
action: check.fixAction,
});
await diagnostics.info("git-validator.repair.completed", {
repository: repository.fullName,
checkId: check.id,
action: check.fixAction,
});
return result;
});
register("troubleshooter:scan", async ({ fullName = null }) => { register("troubleshooter:scan", async ({ fullName = null }) => {
const currentRepositories = await repositories.refresh(); const currentRepositories = await repositories.refresh();
const candidates = fullName const candidates = fullName
+100 -1
View File
@@ -180,6 +180,7 @@ const ui = {
servers: [], servers: [],
serverInspection: null, serverInspection: null,
gitRecovery: null, gitRecovery: null,
gitValidation: null,
diffHunks: null, diffHunks: null,
conflictState: null, conflictState: null,
branchProtection: null, branchProtection: null,
@@ -487,6 +488,7 @@ function selectRepository(id, shouldRender = true) {
ui.branches = []; ui.branches = [];
ui.stashes = []; ui.stashes = [];
ui.gitRecovery = null; ui.gitRecovery = null;
ui.gitValidation = null;
ui.branchProtection = null; ui.branchProtection = null;
const repository = selectedRepository(); const repository = selectedRepository();
ui.selectedProfileId = selectedProfile(repository)?.id || null; ui.selectedProfileId = selectedProfile(repository)?.id || null;
@@ -968,6 +970,34 @@ function renderRepositorySettings(repository) {
return `<div class="tab-page"><section class="settings-group"><h2>Repository identity</h2><div class="form-grid"><div class="field full"><label>Gitea repository</label><input class="input" value="${attr(repository.fullName)}" readonly/></div><div class="field full"><label>Local working tree</label><input class="input mono" value="${attr(repository.localPath || automaticTarget || "Not linked")}" readonly/></div><div class="field full"><label>Current origin</label><input class="input mono" value="${attr(currentOrigin)}" readonly/></div>${desiredOrigin ? `<div class="field full"><label>Current Gitea SSH origin</label><input class="input mono" value="${attr(desiredOrigin)}" readonly/></div>` : ""}</div><div class="card-actions"><button class="button" data-action="${repository.localPath ? "open-path" : "link-repo"}">${icon("folder")}${repository.localPath ? "Open project folder" : "Link local folder"}</button>${originNeedsRepair ? `<button class="button primary" data-action="repair-origin">${icon("link")}Use current Gitea origin</button>` : ""}${repository.localPath ? `<button class="button" data-action="scan-git-recovery">${icon("pulse")}Scan Git health</button><button class="button danger" data-action="unlink-repo">${icon("link")}Remove link</button>` : `<button class="button primary" data-action="clone-repo">${icon("cloud")}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button ghost" data-action="clone-repo-custom">Choose another location</button>`}</div></section><section class="settings-group"><div class="section-heading"><div><h2>Open pull requests</h2><span class="meta">Live from Gitea</span></div><button class="button" data-action="load-pull-requests">${icon("refresh")}Refresh</button></div>${pullRequests.length ? `<div class="tool-list">${pullRequests.map((pull) => `<div class="tool-row"><div><strong>#${pull.number} · ${escapeHtml(pull.title)}</strong><span>${escapeHtml(pull.head?.ref || pull.head?.label || "source")}${escapeHtml(pull.base?.ref || pull.base?.label || "target")} · ${formatDate(pull.updated_at || pull.created_at)}</span></div><button class="button" data-action="open-pull-request-url" data-url="${attr(pull.html_url || "")}">Open</button></div>`).join("")}</div>` : '<div class="empty-state compact"><p>No open pull requests.</p></div>'}</section><section class="settings-group"><h2>Repository behavior</h2><div class="notice">${icon("shield")}Origin repair changes only the Git remote URL. Git health scans the actual Git directory, repairs only proven stale lock files and never changes source files or commits.</div></section></div>`; return `<div class="tab-page"><section class="settings-group"><h2>Repository identity</h2><div class="form-grid"><div class="field full"><label>Gitea repository</label><input class="input" value="${attr(repository.fullName)}" readonly/></div><div class="field full"><label>Local working tree</label><input class="input mono" value="${attr(repository.localPath || automaticTarget || "Not linked")}" readonly/></div><div class="field full"><label>Current origin</label><input class="input mono" value="${attr(currentOrigin)}" readonly/></div>${desiredOrigin ? `<div class="field full"><label>Current Gitea SSH origin</label><input class="input mono" value="${attr(desiredOrigin)}" readonly/></div>` : ""}</div><div class="card-actions"><button class="button" data-action="${repository.localPath ? "open-path" : "link-repo"}">${icon("folder")}${repository.localPath ? "Open project folder" : "Link local folder"}</button>${originNeedsRepair ? `<button class="button primary" data-action="repair-origin">${icon("link")}Use current Gitea origin</button>` : ""}${repository.localPath ? `<button class="button" data-action="scan-git-recovery">${icon("pulse")}Scan Git health</button><button class="button danger" data-action="unlink-repo">${icon("link")}Remove link</button>` : `<button class="button primary" data-action="clone-repo">${icon("cloud")}${escapeHtml(clonePrimaryLabel(repository))}</button><button class="button ghost" data-action="clone-repo-custom">Choose another location</button>`}</div></section><section class="settings-group"><div class="section-heading"><div><h2>Open pull requests</h2><span class="meta">Live from Gitea</span></div><button class="button" data-action="load-pull-requests">${icon("refresh")}Refresh</button></div>${pullRequests.length ? `<div class="tool-list">${pullRequests.map((pull) => `<div class="tool-row"><div><strong>#${pull.number} · ${escapeHtml(pull.title)}</strong><span>${escapeHtml(pull.head?.ref || pull.head?.label || "source")}${escapeHtml(pull.base?.ref || pull.base?.label || "target")} · ${formatDate(pull.updated_at || pull.created_at)}</span></div><button class="button" data-action="open-pull-request-url" data-url="${attr(pull.html_url || "")}">Open</button></div>`).join("")}</div>` : '<div class="empty-state compact"><p>No open pull requests.</p></div>'}</section><section class="settings-group"><h2>Repository behavior</h2><div class="notice">${icon("shield")}Origin repair changes only the Git remote URL. Git health scans the actual Git directory, repairs only proven stale lock files and never changes source files or commits.</div></section></div>`;
} }
function renderGitValidator(repository) {
const report = ui.gitValidation;
if (!report)
return `<div class="validator-empty panel">${projectIllustration("diagnostics")}<div><div class="eyebrow">Repository assurance</div><h2>Validate Git best practices</h2><p>Inspect repository identity, branch governance, tracked secrets, file hygiene and safe local synchronization settings.</p><button class="button primary" data-action="git-validator-scan">${icon("shield")}Run Git Validator</button></div></div>`;
const tone =
report.score >= 90 ? "success" : report.score >= 70 ? "warning" : "danger";
const safeFixes = report.checks.filter(
(check) => check.fixAction && check.safe,
);
const groups = report.checks.reduce((grouped, check) => {
(grouped[check.category] ||= []).push(check);
return grouped;
}, {});
return `<div class="validator-page"><section class="validator-hero panel ${tone}"><div class="validator-score"><strong>${report.score}</strong><span>/ 100</span></div><div><div class="eyebrow">Git assurance score</div><h2>${escapeHtml(report.grade)}</h2><p>${report.summary.passed} passed · ${report.summary.warnings} recommendations · ${report.summary.errors} critical</p></div>${projectIllustration("diagnostics")}<div class="validator-actions"><button class="button" data-action="git-validator-scan">${icon("refresh")}Scan again</button>${safeFixes.length ? `<button class="button primary" data-action="git-validator-repair-safe">${icon("wrench")}Apply ${safeFixes.length} safe fix${safeFixes.length === 1 ? "" : "es"}</button>` : ""}</div></section><div class="validator-groups">${Object.entries(
groups,
)
.map(
([category, checks]) =>
`<section class="panel validator-group"><div class="panel-header"><h3>${escapeHtml(category)}</h3><span class="meta">${checks.filter((check) => check.status === "pass").length}/${checks.length} passed</span></div><div class="validator-checks">${checks
.map((check) => {
const checkIndex = report.checks.indexOf(check);
return `<article class="validator-check ${check.status}"><span class="validator-check-icon">${icon(check.status === "pass" ? "check" : check.status === "error" ? "error" : "warning")}</span><div><strong>${escapeHtml(check.title)}</strong><p>${escapeHtml(check.detail)}</p></div>${check.fixAction ? `<button class="button ${check.safe ? "" : "primary"}" data-action="git-validator-repair" data-check-index="${checkIndex}">${icon("wrench")}${check.safe ? "Fix safely" : "Review & fix"}</button>` : `<span class="status-pill ${check.status === "pass" ? "success" : check.status === "error" ? "danger" : "warning"}">${check.status === "pass" ? "Best practice" : "Review"}</span>`}</article>`;
})
.join("")}</div></section>`,
)
.join("")}</div></div>`;
}
function renderRepositoryWorkspace(repository) { function renderRepositoryWorkspace(repository) {
const status = repository.localStatus; const status = repository.localStatus;
const profile = selectedProfile(repository); const profile = selectedProfile(repository);
@@ -998,6 +1028,7 @@ function renderRepositoryWorkspace(repository) {
history: renderHistory, history: renderHistory,
deployments: renderRepositoryDeployments, deployments: renderRepositoryDeployments,
gittools: renderGitTools, gittools: renderGitTools,
validator: renderGitValidator,
settings: renderRepositorySettings, settings: renderRepositorySettings,
}[ui.repositoryTab] || renderChanges }[ui.repositoryTab] || renderChanges
)(repository); )(repository);
@@ -1009,6 +1040,7 @@ function renderRepositoryWorkspace(repository) {
["history", "History"], ["history", "History"],
["deployments", "Deployments"], ["deployments", "Deployments"],
["gittools", "Git tools"], ["gittools", "Git tools"],
["validator", "Git Validator"],
["settings", "Project settings"], ["settings", "Project settings"],
] ]
.map( .map(
@@ -1669,7 +1701,19 @@ app.addEventListener("click", async (event) => {
ui.repositoryTab = target.dataset.tab; ui.repositoryTab = target.dataset.tab;
if (ui.repositoryTab === "gittools" && !ui.branches.length) if (ui.repositoryTab === "gittools" && !ui.branches.length)
await loadGitTools(repository); await loadGitTools(repository);
else if (ui.repositoryTab === "settings") { else if (ui.repositoryTab === "validator" && !ui.gitValidation) {
setLoading(true, "Validating Git and Gitea best practices…");
try {
ui.gitValidation = await window.forgeflow.gitValidatorScan(
repository.fullName,
);
} catch (error) {
showToast("Git Validator failed", error.message, "error");
} finally {
setLoading(false);
}
render();
} else if (ui.repositoryTab === "settings") {
try { try {
ui.pullRequests = await window.forgeflow.pullRequests( ui.pullRequests = await window.forgeflow.pullRequests(
repository.fullName, repository.fullName,
@@ -1681,6 +1725,61 @@ app.addEventListener("click", async (event) => {
} }
render(); render();
} else render(); } else render();
} else if (action === "git-validator-scan") {
setLoading(true, "Validating Git and Gitea best practices…");
try {
ui.gitValidation = await window.forgeflow.gitValidatorScan(
repository.fullName,
);
showToast(
"Git validation complete",
`${ui.gitValidation.score}/100 · ${ui.gitValidation.grade}`,
ui.gitValidation.summary.errors ? "error" : "success",
);
} catch (error) {
showToast("Git Validator failed", error.message, "error");
} finally {
setLoading(false);
}
} else if (action === "git-validator-repair") {
const check = ui.gitValidation?.checks?.[Number(target.dataset.checkIndex)];
if (!check?.fixAction) return;
if (!check.safe && !confirm(check.confirmation || `Apply ${check.title}?`))
return;
setLoading(true, `Repairing ${check.title}`);
try {
await window.forgeflow.gitValidatorRepair(repository.fullName, check);
await refreshRepositories(false, true);
ui.gitValidation = await window.forgeflow.gitValidatorScan(
repository.fullName,
);
showToast("Git best practice repaired", check.title, "success");
} catch (error) {
showToast("Repair failed", error.message, "error");
} finally {
setLoading(false);
}
} else if (action === "git-validator-repair-safe") {
const checks = (ui.gitValidation?.checks || []).filter(
(check) => check.fixAction && check.safe,
);
setLoading(true, `Applying ${checks.length} safe Git fixes…`);
let repaired = 0;
try {
for (const check of checks) {
await window.forgeflow.gitValidatorRepair(repository.fullName, check);
repaired += 1;
}
await refreshRepositories(false, true);
ui.gitValidation = await window.forgeflow.gitValidatorScan(
repository.fullName,
);
showToast("Safe Git fixes applied", `${repaired} repaired.`, "success");
} catch (error) {
showToast("Safe repair stopped", error.message, "error");
} finally {
setLoading(false);
}
} else if (action === "toggle-favorite") { } else if (action === "toggle-favorite") {
ui.boot.state = await window.forgeflow.favoriteRepository( ui.boot.state = await window.forgeflow.favoriteRepository(
repository.fullName, repository.fullName,
+112 -1
View File
@@ -564,7 +564,7 @@
await wait(80); await wait(80);
snapshot(); snapshot();
return { return {
appVersion: "0.8.8-demo", appVersion: "0.8.9-demo",
platform: "win32", platform: "win32",
state: clone(state), state: clone(state),
git: { available: true, version: "git version 2.47.3" }, git: { available: true, version: "git version 2.47.3" },
@@ -1461,6 +1461,117 @@
state.operations.find((item) => item.id === operationId) || null, state.operations.find((item) => item.id === operationId) || null,
); );
}, },
async gitValidatorScan(fullName) {
await wait(260);
return {
repository: fullName,
checkedAt: iso(),
score: 78,
grade: "Good",
summary: { passed: 7, warnings: 3, errors: 0, repairable: 2 },
checks: [
{
id: "origin",
category: "Repository identity",
title: "Origin matches Gitea",
status: "pass",
detail: "The local origin resolves to this Gitea repository.",
weight: 15,
},
{
id: "default-branch-protection",
category: "Gitea governance",
title: "Default branch protection",
status: "warning",
detail: "main accepts unprotected direct changes.",
weight: 18,
fixAction: "protect-default-branch",
safe: false,
confirmation:
"Protect main on Gitea and block direct and force pushes?",
},
{
id: "force-push",
category: "Gitea governance",
title: "Force-push protection",
status: "pass",
detail: "Force pushes are blocked.",
weight: 8,
},
{
id: "upstream",
category: "Branch hygiene",
title: "Current branch has an upstream",
status: "pass",
detail: "main tracks origin/main.",
weight: 8,
},
{
id: "working-tree",
category: "Branch hygiene",
title: "Working tree is intentional",
status: "warning",
detail: "3 changed files require review, commit or stash.",
weight: 5,
},
{
id: "identity",
category: "Commit integrity",
title: "Repository author identity",
status: "pass",
detail: "Jens <jens@example.test>",
weight: 7,
},
{
id: "local-safety",
category: "Local configuration",
title: "Safe synchronization defaults",
status: "warning",
detail: "Recommended repository-local safeguards are incomplete.",
weight: 10,
fixAction: "configure-local-safety",
safe: true,
},
{
id: "readme",
category: "Repository documentation",
title: "README is versioned",
status: "pass",
detail: "Repository documentation is tracked.",
weight: 7,
},
{
id: "gitignore",
category: "Repository hygiene",
title: ".gitignore is versioned",
status: "pass",
detail: "Generated files are excluded centrally.",
weight: 8,
},
{
id: "tracked-secrets",
category: "Security",
title: "No secret-shaped files are tracked",
status: "pass",
detail:
"No tracked environment, key or credential filenames detected.",
weight: 22,
},
{
id: "large-files",
category: "Repository performance",
title: "No oversized tracked files",
status: "pass",
detail: "No tracked files above 10 MB were found.",
weight: 7,
},
],
};
},
async gitValidatorRepair() {
await wait(180);
return { repaired: true };
},
async diagnosticsStatus() { async diagnosticsStatus() {
return { return {
enabled: state.preferences.diagnosticsEnabled !== false, enabled: state.preferences.diagnosticsEnabled !== false,
+221
View File
@@ -1280,6 +1280,227 @@ html[data-theme="light"] .diff-line.remove {
color: #caa7ff; color: #caa7ff;
background: rgba(148, 97, 214, 0.08); background: rgba(148, 97, 214, 0.08);
} }
.validator-page {
container-type: inline-size;
padding: 18px;
display: grid;
gap: 14px;
overflow: auto;
}
.validator-empty {
min-height: 360px;
margin: 18px;
padding: 38px;
display: flex;
align-items: center;
justify-content: center;
gap: 36px;
text-align: left;
overflow: hidden;
}
.validator-empty > div:last-child {
max-width: 520px;
}
.validator-empty h2 {
margin: 5px 0 8px;
font-size: 24px;
}
.validator-empty p {
margin: 0 0 18px;
color: var(--text-muted);
line-height: 1.65;
}
.validator-hero {
position: relative;
min-height: 160px;
padding: 24px;
display: grid;
grid-template-columns: auto minmax(220px, 1fr) minmax(180px, 260px) auto;
align-items: center;
gap: 22px;
overflow: hidden;
background:
radial-gradient(
circle at 68% 16%,
color-mix(in srgb, var(--primary) 16%, transparent),
transparent 28%
),
linear-gradient(
120deg,
var(--surface-1),
color-mix(in srgb, var(--surface-2) 84%, var(--primary-soft))
);
}
.validator-hero.success {
--validator-accent: var(--success);
}
.validator-hero.warning {
--validator-accent: var(--warning);
}
.validator-hero.danger {
--validator-accent: var(--danger);
}
.validator-score {
width: 116px;
height: 116px;
border-radius: 32px;
display: grid;
place-content: center;
text-align: center;
background: color-mix(in srgb, var(--validator-accent) 10%, var(--surface-2));
border: 1px solid color-mix(in srgb, var(--validator-accent) 42%, var(--line));
box-shadow:
inset 0 0 34px color-mix(in srgb, var(--validator-accent) 10%, transparent),
0 18px 38px rgba(0, 0, 0, 0.15);
}
.validator-score strong {
color: var(--validator-accent);
font-size: 42px;
line-height: 0.9;
letter-spacing: -0.05em;
}
.validator-score span {
margin-top: 7px;
color: var(--text-muted);
font: 700 10px/1 var(--font-mono);
}
.validator-hero h2 {
margin: 4px 0 6px;
font-size: 24px;
}
.validator-hero p {
margin: 0;
color: var(--text-muted);
}
.validator-hero .project-illustration {
width: 220px;
opacity: 0.82;
}
.validator-actions {
display: grid;
gap: 8px;
min-width: 150px;
}
.validator-groups {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
align-items: start;
}
.validator-group {
overflow: hidden;
}
.validator-checks {
display: grid;
}
.validator-check {
min-height: 78px;
padding: 13px 14px;
display: grid;
grid-template-columns: 34px minmax(0, 1fr) auto;
gap: 11px;
align-items: center;
border-top: 1px solid var(--line-soft);
transition:
background 160ms ease,
transform 160ms ease;
}
.validator-check:hover {
background: var(--surface-hover);
}
.validator-check-icon {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 9px;
color: var(--text-muted);
background: var(--surface-2);
}
.validator-check.pass .validator-check-icon {
color: var(--success);
background: color-mix(in srgb, var(--success) 12%, transparent);
}
.validator-check.warning .validator-check-icon {
color: var(--warning);
background: color-mix(in srgb, var(--warning) 12%, transparent);
}
.validator-check.error .validator-check-icon {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 12%, transparent);
}
.validator-check strong {
display: block;
font-size: 12px;
}
.validator-check p {
margin: 4px 0 0;
color: var(--text-muted);
font-size: 11px;
line-height: 1.45;
overflow-wrap: anywhere;
}
html[data-theme="light"] .validator-hero {
background:
radial-gradient(
circle at 68% 16%,
rgba(66, 91, 220, 0.18),
transparent 30%
),
linear-gradient(
120deg,
rgba(255, 255, 255, 0.98),
rgba(236, 243, 255, 0.96)
);
}
@media (max-width: 1180px) {
.validator-hero {
grid-template-columns: auto 1fr auto;
}
.validator-hero .project-illustration {
display: none;
}
.validator-groups {
grid-template-columns: 1fr;
}
}
@container (max-width: 900px) {
.validator-hero {
grid-template-columns: auto minmax(0, 1fr);
}
.validator-hero .project-illustration {
display: none;
}
.validator-actions {
grid-column: 1 / -1;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.validator-groups {
grid-template-columns: 1fr;
}
}
@container (max-width: 520px) {
.validator-hero {
grid-template-columns: 1fr;
}
.validator-score {
width: 92px;
height: 92px;
border-radius: 25px;
}
.validator-actions {
grid-template-columns: 1fr;
}
.validator-check {
grid-template-columns: 30px minmax(0, 1fr);
}
.validator-check > .button,
.validator-check > .status-pill {
grid-column: 2;
justify-self: start;
}
}
.empty-state { .empty-state {
height: 100%; height: 100%;
min-height: 260px; min-height: 260px;
+101
View File
@@ -0,0 +1,101 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { createRequire } from "node:module";
const exec = promisify(execFile);
const require = createRequire(import.meta.url);
const { GitService } = require("../src/main/git-service.cjs");
const {
GitValidatorService,
isSensitiveTrackedPath,
sameRemote,
} = require("../src/main/git-validator-service.cjs");
async function git(args, cwd) {
return exec("git", args, { cwd, encoding: "utf8" });
}
test("Git Validator scores repository hygiene and offers bounded safe repairs", async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), "forgeflow-validator-"));
t.after(() => rm(root, { recursive: true, force: true }));
await git(["init", "-b", "main"], root);
await git(["config", "user.name", "ForgeFlow Test"], root);
await git(["config", "user.email", "forgeflow@example.invalid"], root);
await git(
["remote", "add", "origin", "https://gitea.example.test/jens/app.git"],
root,
);
await writeFile(path.join(root, "README.md"), "# App\n", "utf8");
await writeFile(path.join(root, ".gitignore"), ".env\n", "utf8");
await git(["add", "."], root);
await git(["commit", "-m", "Initial"], root);
const validator = new GitValidatorService({
git: new GitService(),
gitea: {
getBranchProtection: async () => ({
protected: false,
enableForcePush: false,
}),
},
});
const repository = {
fullName: "jens/app",
name: "app",
owner: { login: "jens" },
defaultBranch: "main",
localPath: root,
cloneUrl: "https://gitea.example.test/jens/app.git",
sshUrl: "git@gitea.example.test:jens/app.git",
};
const report = await validator.scan(repository);
assert.ok(report.score > 60);
assert.equal(
report.checks.find((check) => check.id === "origin").status,
"pass",
);
assert.equal(
report.checks.find((check) => check.id === "default-branch-protection")
.fixAction,
"protect-default-branch",
);
const safety = report.checks.find((check) => check.id === "local-safety");
assert.equal(safety.safe, true);
await validator.repair(repository, safety);
const rescanned = await validator.scan(repository);
assert.equal(
rescanned.checks.find((check) => check.id === "local-safety").status,
"pass",
);
});
test("Git Validator creates a reviewable gitignore without committing it", async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), "forgeflow-ignore-"));
t.after(() => rm(root, { recursive: true, force: true }));
await git(["init", "-b", "main"], root);
const validator = new GitValidatorService({ git: new GitService() });
const repository = { localPath: root };
await validator.repair(repository, { fixAction: "add-gitignore" });
const content = await readFile(path.join(root, ".gitignore"), "utf8");
assert.match(content, /\.env/);
const status = await git(["status", "--short"], root);
assert.match(status.stdout, /\?\? \.gitignore/);
});
test("Git Validator recognizes remote aliases and secret-shaped tracked paths", () => {
assert.equal(
sameRemote(
"git@gitea.example.test:jens/app.git",
"https://gitea.example.test/jens/app",
),
true,
);
assert.equal(isSensitiveTrackedPath(".env.production"), true);
assert.equal(isSensitiveTrackedPath("config/private.pem"), true);
assert.equal(isSensitiveTrackedPath(".env.example"), false);
});
+15
View File
@@ -126,3 +126,18 @@ test('downloads release assets through the release-scoped Gitea endpoint', async
/invalid release ID/, /invalid release ID/,
); );
}); });
test('creates conservative default branch protection rules', async () => {
const service = new GiteaService(makeStore());
let request = null;
service.request = async (pathname, options) => {
request = { pathname, options };
return { data: { rule_name: 'main' } };
};
const result = await service.createBranchProtection('jens', 'app', 'main');
assert.equal(result.rule_name, 'main');
assert.equal(request.options.method, 'POST');
assert.equal(request.options.body.enable_push, false);
assert.equal(request.options.body.enable_force_push, false);
assert.equal(request.options.body.rule_name, 'main');
});
+22
View File
@@ -243,3 +243,25 @@ test("the diff canvas uses a contextual and motion-safe code illustration", asyn
assert.match(styles, /@keyframes code-packet-travel/); assert.match(styles, /@keyframes code-packet-travel/);
assert.match(styles, /prefers-reduced-motion/); assert.match(styles, /prefers-reduced-motion/);
}); });
test("Git Validator exposes scored best-practice checks and bounded repairs", async () => {
const renderer = await readFile(
new URL("../src/renderer/app.js", import.meta.url),
"utf8",
);
const styles = await readFile(
new URL("../src/renderer/styles.css", import.meta.url),
"utf8",
);
const preload = await readFile(
new URL("../preload.cjs", import.meta.url),
"utf8",
);
assert.match(renderer, /function renderGitValidator/);
assert.match(renderer, /git-validator-repair-safe/);
assert.match(renderer, /check\.safe/);
assert.match(styles, /\.validator-score/);
assert.match(styles, /@container \(max-width: 900px\)/);
assert.match(preload, /gitValidatorScan/);
assert.match(preload, /gitValidatorRepair/);
});