diff --git a/CHANGELOG.md b/CHANGELOG.md index 132f0d7..d082c64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 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 - fixed binary update downloads on Gitea servers that require release-scoped attachment routes; diff --git a/SOURCE_MANIFEST.txt b/SOURCE_MANIFEST.txt index 88013d2..7b488ce 100644 --- a/SOURCE_MANIFEST.txt +++ b/SOURCE_MANIFEST.txt @@ -1,4 +1,4 @@ -ForgeFlow 0.8.8 source manifest +ForgeFlow 0.8.9 source manifest SHA-256 BYTES PATH (The manifest excludes itself, dependencies and generated release artifacts.) 755f4db7d76bfec0963ef051748a82810c0d58acd4ffd823aa6928a5167fceb4 58 .gitignore @@ -12,7 +12,7 @@ ca32a76e708d565c4af659f0f4d2615fc32114c3f75aec1454862a3ed1e72c41 2263 4633990a4b055bb3d00fef915ee29e85be5ee8413f809334728ad9688973c183 3364 build/icon-64.png 25048ed854e8ce8fece115e555c98d25507b002f8019b6ae717b54604c868c50 46223 build/icon.ico 16efd2fca83004f781eae40ae0f706a004ce0bddf338dd087b8adf7eb10c1d84 85704 build/icon.png -b5ebb381645c9a7e9de693ff30c563819b70182931057727dc6e48db551adf9f 10557 CHANGELOG.md +2038d46210c3c2ac582889f9ed5e352a9ff4f2b0bbd80d25adcc20aba7c3e09b 11200 CHANGELOG.md 21cb96e7afe71b1dc791c818dedd244d92f9a6ed4d9ffbb3022ccb187e1bdf0f 852 docs/ACCEPTANCE.md a17f95d96d3c9fbc69d870874e6fbb7472091adefc454b24f835db1279511d72 8296 docs/ARCHITECTURE.md 30a92bcf5daadb019efa2f82cb820ea302490dd1d68fb772674dc3faccd3e594 2045 docs/DEPLOYMENT_SETUP.md @@ -46,6 +46,7 @@ b516db97a0353babc810c24a87a971d30d72a8021d809e6b833ff7ae0458f442 538 838d196f3fbbfeee8df375a0502107f56b6df28babca30aa09ef1c7aa5196d09 738 docs/RELEASE_NOTES_0.8.6.md ef049adcfa204908e6dc3a059124b39ba0e2739cc54e38945ce73a57049df0d8 1185 docs/RELEASE_NOTES_0.8.7.md 7eedb25e1aae3b06a04bb9b2f4843bd6af614418737e600b4bdc9161edabd76a 632 docs/RELEASE_NOTES_0.8.8.md +35dcfda990946480d6d55bd2d2e6360c336260bcd05cdb51d92e07a4e8d76945 1046 docs/RELEASE_NOTES_0.8.9.md 2b631b9d6d973bdd70869d84886ff339da351e29e17598970b3b27915674661d 4175 docs/ROADMAP.md 1ccde232c060395d7aedce27e89a7647b77afe28ab71de0a5a3efeded57369d3 140415 docs/screenshots/deploy-confirmation.png b39506254ffa2c73c389fb4795b3a745368bbeb7d8514cc47a636316d6d9a6aa 107166 docs/screenshots/deployment-run.png @@ -67,11 +68,11 @@ c230b931abf2293d2d44b7a69b94c35f1142c093cc46b88739a0de5cbd6d1896 1532 106538d4a14a5a7b13419f9520c582b19809e8fafe2cb8c7dce2bc3e600dd10a 397 examples/server/nginx-forgeflow-status.conf 2dff25fb39ce8fc7844026a50524b23f241bec5b614eb05371c7f908a080f69a 398 examples/server/status-example.json 4a561ead5ba7cdfaf4efce91842a4308c5f2a77980205879d83835efb8a579db 1067 LICENSE -0d9f0ac837b4d3b39476d9bcbb57fb3e12d6b7084bb1f7435401e416cff1854f 12920 main.cjs +3b16a087c73b600415394dff8b8e34e7f7519e48fde1cf443007b2e11ca77b27 13123 main.cjs 91a984a89dd57a084b9a2331763cacdb061582fb590f13df379d92c1a77a2ee1 352 OVERLAY-INSTRUCTIONS.md -dc26585558f060c74a11bc7033cf1f22c626e91668a6349a06a928114ecf417d 130466 package-lock.json -e3d60b3adde674e5aa80cc7776db3efbc0ced66d49e89490cb9fbb6786c0db70 3906 package.json -45eea5fe4fd58cc0e96497295223e81a4f3acc3778bf546d4c7c96af03da4d26 9496 preload.cjs +ab7c04a2df33ebe2861adc68c74d8f7b0eeb6a2d7c72410c26bc66b883d985c7 130466 package-lock.json +4256d59aa47ea13d9e305370c2602887930d6069f4db34dc91c1f738d4b79e97 3943 package.json +b5dc2b79453afbcd74fa94ddacf078bf22c00807ebc4eb648d2451de49634329 9668 preload.cjs b31c43d9355c13b5ae4efc0f3649d8cb8d509b2bb7ebb042ff546b7820fb7de8 8411 Publish-ForgeFlow-Release.ps1 a6d32a742412b7836606be00f17be0465f1b6f55d3911f6c73a14029787ba206 14037 README.md 509c7bcff5280349bd9f45ed6151f70372bad7010a9ea582c13e2ccab91fe0cd 6272 scripts/acceptance.mjs @@ -83,7 +84,7 @@ f8359a69d20deb2dfe10042d1bec7b12a95e76e58e36bc5f265f073c3111d056 10287 74433d8a6b24afe368197a469e2fe0c5050c239d7250b84c2f3f598c304778b0 4736 scripts/publish-binary-release.cjs 444b397d515d65a7ee59d3088cba869cbb812d2b8cc18fc5d255105e3edb58c2 1468 scripts/serve-demo.mjs 42203f9e0fd4aae517284d387f265cf1b0b180379bc253a092b5c3c5c4caef0a 2992 scripts/validate-installed-connections.cjs -1a0931346c89d2d49b736a25aa58c5d29fbdf4acd593ea3614dba904ec462843 12374 scripts/verify.mjs +c7942bac000c9850630dc086f260f2dbf4bf4b8b435dac3631e7fe5ca41d507f 12407 scripts/verify.mjs 0079701b5acbfef07b71a9623613d1940805ccd20649d77e3f34c37e79df7655 735 scripts/write-release-checksums.mjs 619515f524cb89960370ffcbd3fafd3c0e178b95f69c5868b1dd44777f23ec1e 2081 setup-windows.ps1 dd613d04b366f2cd071a1685a414016a5fb008082ed1b4cb8b24b79c100f640a 2412 src/main/audit-service.cjs @@ -93,8 +94,9 @@ a381848a296c28f6d14093c96f722967acf9c994ffb867d54dd92bf5ada2729b 23648 c157640e76d558906a9aa9881eda811196623ef1c65fa3467f32f0f84b0ddd0c 15095 src/main/diagnostics-service.cjs a2ef47d5330095b92c2bd22fcc39962091881f9cb60d02e261eb1dd1bd693170 1974 src/main/external-tools-service.cjs 0b7476c2cfe1872601978c20a466c20fe58be35e81b2303e38a753fea62bbc27 32548 src/main/git-service.cjs -6ee0c645521c20043708595d1a9dd0fe7642fc300be1306effd7e1142eebcfb7 17201 src/main/gitea-service.cjs -13f6521bb82aa5a3399d6839ea7563459b19969c1e5af7525e4116cf771282e9 46526 src/main/ipc.cjs +857f270a2204b743421eea619a6a88595f749e4c24c1794cb092ace7987d25dc 12553 src/main/git-validator-service.cjs +1c0a1c1b7f20734c646c874e07c89550e601951351c04770874c1ed3496f0833 17837 src/main/gitea-service.cjs +dadf2fd2e3d148456da0b735a1d9af142196b230a02346a9ca29c7605d7e0b87 47704 src/main/ipc.cjs 62f2c80c8210e19370b8556b1f296cbae50dae6b758a39e209f8fb461691fd4c 4235 src/main/log-redaction.cjs 958595a99fb242c127f475f3d8622bdba4c07b2d658703f69fe3992227a9107e 12909 src/main/preflight-service.cjs 3096b4181566cb93a27e56e248c92105d4f4df5aee39d73c6c7d8ae8c2231bc0 1570 src/main/process-runner.cjs @@ -103,14 +105,14 @@ e89b54e7e3174b4b0a1dcd9058d8344e29431f9d16d0e6bb8d11559b691440a0 2508 b31a63bf8cb1807b3e838e2bf8a0e742738f119d13de8ca9f42e471f072217d3 8328 src/main/ssh-service.cjs f4e221edace12e2120ec439e0f91274de110872c5bfbe968f2d7935eeb7193c4 78544 src/main/unraid-deployment-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 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark-dark.png 094c1b71cc2482a9db250ac175f45f3de68f53277dfbde371a03e61923d00988 75240 src/renderer/assets/itworx-wordmark-light.png 813b8cdeecac43794166f3db9d3c5d2c441e0292f9ab7bd465ba136d6201e95d 82476 src/renderer/assets/itworx-wordmark.png e1c463d6cda9f2b9b78c468845c0a7e8688f0362be5642074a1a5f7122dfe811 762 src/renderer/index.html -2b68194e9ca0caab1ea2d1e5f7222a42f962ea16f3dd9517a7a13af811963969 51166 src/renderer/mock-bridge.js -e30ea37ee00f34110d0d252c79f9d8e0bb127c4db0bd1bf8b374609dea433361 72160 src/renderer/styles.css +e56e81ec2f2c9743332de0e19aca75bf1aa585351a790d7b32ae1ccdb6a26b4c 54717 src/renderer/mock-bridge.js +607b2592c6aee37de91126a80704c1f7d8575cb2e5ff889305cd86a37e6170c4 76937 src/renderer/styles.css 0a1e9d9d6cd4d190eb7f85dbc6668d80600b1cf2749cc0c2c51cc428f506f20d 1121 src/shared/clone-target.cjs 5d425d5c2f939d0f6beebee7ebb0c77146cb7e318535ba7286ec7081a4dc2269 2497 src/shared/deployment-policy.cjs 029e600229714d033c28e2dcb77817aa8269847001782ae0012960e83ffd183f 3057 src/shared/git-status.cjs @@ -132,13 +134,14 @@ fae3634bae871abade4d487b94b4741b50e787804dbd6135249f634fdd83c6d0 3800 dd121d96ca265a027cd415a52064500a4541b2f8a662f4f4b25f2f996d52b5da 762 tests/external-tools.test.mjs e7aebcc0d484a6a59d463d5cb26c11b3ad56e28f6535e7c38a0fe166a41565ea 13690 tests/git-integration.test.mjs 5ea94c6b241a02060d531fad94e449eecd3772eed2137581d4e2babfb09e56db 1239 tests/git-status.test.mjs +c98cbe50a783e2a1cfecf9052f558aabe656add6a463899532dd59d743b720b2 3645 tests/git-validator.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 caf98cbd9de9b119dae610ee53fa333a7a11214f34762247452fbb85e8bbf725 2392 tests/log-redaction.test.mjs 96432a97d313f331694900bf0a2c21e38c20eac96d59147977aeed9055a9e3ad 2287 tests/partial-staging.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 f679072548554a64974f0452337ce5e7b0c567343c287223770cc0974b905348 1068 tests/repository-monitor.test.mjs 75b5b83836c75675bb9a48fe4363fcb8a24fc425e6af6f822d7955c6f3c79eac 2265 tests/repository-service.test.mjs diff --git a/docs/RELEASE_NOTES_0.8.9.md b/docs/RELEASE_NOTES_0.8.9.md new file mode 100644 index 0000000..a328d5d --- /dev/null +++ b/docs/RELEASE_NOTES_0.8.9.md @@ -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. diff --git a/main.cjs b/main.cjs index 8c63720..855cd32 100644 --- a/main.cjs +++ b/main.cjs @@ -25,6 +25,7 @@ const { UnraidDeploymentService, } = require("./src/main/unraid-deployment-service.cjs"); const { AuditService } = require("./src/main/audit-service.cjs"); +const { GitValidatorService } = require("./src/main/git-validator-service.cjs"); const { ExternalToolsService, } = require("./src/main/external-tools-service.cjs"); @@ -285,6 +286,11 @@ app userDataPath, secureStorageAvailable: () => safeStorage.isEncryptionAvailable(), }); + const gitValidator = new GitValidatorService({ + git, + gitea, + diagnostics, + }); repositoryMonitor = new RepositoryMonitor({ store, git, @@ -302,6 +308,7 @@ app ssh, updates, preflight, + gitValidator, diagnostics, audit, externalTools, diff --git a/package-lock.json b/package-lock.json index 5b524ae..a2fd102 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "forgeflow", - "version": "0.8.8", + "version": "0.8.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "forgeflow", - "version": "0.8.8", + "version": "0.8.9", "dependencies": { "ssh2": "1.17.0" }, diff --git a/package.json b/package.json index ad24d02..272ef7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "forgeflow", - "version": "0.8.8", + "version": "0.8.9", "private": true, "description": "Desktop release cockpit for local Git, Gitea Actions and controlled exact-commit deployments.", "main": "main.cjs", @@ -81,6 +81,7 @@ "docs/RELEASE_NOTES_0.8.6.md", "docs/RELEASE_NOTES_0.8.7.md", "docs/RELEASE_NOTES_0.8.8.md", + "docs/RELEASE_NOTES_0.8.9.md", "docs/ACCEPTANCE.md" ], "asarUnpack": [ diff --git a/preload.cjs b/preload.cjs index a635d6f..d0d7de2 100644 --- a/preload.cjs +++ b/preload.cjs @@ -126,7 +126,9 @@ contextBridge.exposeInMainWorld( getOperation: (operationId) => invoke('operations:get', { operationId }), troubleshooterScan: (fullName = null) => invoke('troubleshooter:scan', { fullName }), 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'), clearDiagnostics: () => invoke('diagnostics:clear'), openDiagnosticsFolder: () => invoke('diagnostics:open-folder'), diff --git a/scripts/verify.mjs b/scripts/verify.mjs index a9d0004..edd57b7 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -69,6 +69,7 @@ const required = [ "docs/RELEASE_NOTES_0.8.6.md", "docs/RELEASE_NOTES_0.8.7.md", "docs/RELEASE_NOTES_0.8.8.md", + "docs/RELEASE_NOTES_0.8.9.md", "docs/UPDATING.md", "docs/DIAGNOSTICS.md", "docs/DEPLOYMENT_SETUP.md", @@ -107,9 +108,9 @@ for (const file of required) await access(path.join(root, file)); const packageJson = JSON.parse( await readFile(path.join(root, "package.json"), "utf8"), ); -if (packageJson.version !== "0.8.8") +if (packageJson.version !== "0.8.9") 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( path.join(root, "SOURCE_MANIFEST.txt"), diff --git a/src/main/git-validator-service.cjs b/src/main/git-validator-service.cjs new file mode 100644 index 0000000..4d1442e --- /dev/null +++ b/src/main/git-validator-service.cjs @@ -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, +}; diff --git a/src/main/gitea-service.cjs b/src/main/gitea-service.cjs index 17423c4..ea0a159 100644 --- a/src/main/gitea-service.cjs +++ b/src/main/gitea-service.cjs @@ -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 } = {}) { const query = new URLSearchParams({ state, diff --git a/src/main/ipc.cjs b/src/main/ipc.cjs index d5e2843..d5033ff 100644 --- a/src/main/ipc.cjs +++ b/src/main/ipc.cjs @@ -87,6 +87,7 @@ function registerIpc({ ssh, updates, preflight, + gitValidator, diagnostics, audit, externalTools, @@ -841,6 +842,40 @@ function registerIpc({ 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 }) => { const currentRepositories = await repositories.refresh(); const candidates = fullName diff --git a/src/renderer/app.js b/src/renderer/app.js index 5b1bf4f..685d729 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -180,6 +180,7 @@ const ui = { servers: [], serverInspection: null, gitRecovery: null, + gitValidation: null, diffHunks: null, conflictState: null, branchProtection: null, @@ -487,6 +488,7 @@ function selectRepository(id, shouldRender = true) { ui.branches = []; ui.stashes = []; ui.gitRecovery = null; + ui.gitValidation = null; ui.branchProtection = null; const repository = selectedRepository(); ui.selectedProfileId = selectedProfile(repository)?.id || null; @@ -968,6 +970,34 @@ function renderRepositorySettings(repository) { return `

Repository identity

${desiredOrigin ? `
` : ""}
${originNeedsRepair ? `` : ""}${repository.localPath ? `` : ``}

Open pull requests

Live from Gitea
${pullRequests.length ? `
${pullRequests.map((pull) => `
#${pull.number} · ${escapeHtml(pull.title)}${escapeHtml(pull.head?.ref || pull.head?.label || "source")} → ${escapeHtml(pull.base?.ref || pull.base?.label || "target")} · ${formatDate(pull.updated_at || pull.created_at)}
`).join("")}
` : '

No open pull requests.

'}

Repository behavior

${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.
`; } +function renderGitValidator(repository) { + const report = ui.gitValidation; + if (!report) + return `
${projectIllustration("diagnostics")}
Repository assurance

Validate Git best practices

Inspect repository identity, branch governance, tracked secrets, file hygiene and safe local synchronization settings.

`; + 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 `
${report.score}/ 100
Git assurance score

${escapeHtml(report.grade)}

${report.summary.passed} passed · ${report.summary.warnings} recommendations · ${report.summary.errors} critical

${projectIllustration("diagnostics")}
${safeFixes.length ? `` : ""}
${Object.entries( + groups, + ) + .map( + ([category, checks]) => + `

${escapeHtml(category)}

${checks.filter((check) => check.status === "pass").length}/${checks.length} passed
${checks + .map((check) => { + const checkIndex = report.checks.indexOf(check); + return `
${icon(check.status === "pass" ? "check" : check.status === "error" ? "error" : "warning")}
${escapeHtml(check.title)}

${escapeHtml(check.detail)}

${check.fixAction ? `` : `${check.status === "pass" ? "Best practice" : "Review"}`}
`; + }) + .join("")}
`, + ) + .join("")}
`; +} + function renderRepositoryWorkspace(repository) { const status = repository.localStatus; const profile = selectedProfile(repository); @@ -998,6 +1028,7 @@ function renderRepositoryWorkspace(repository) { history: renderHistory, deployments: renderRepositoryDeployments, gittools: renderGitTools, + validator: renderGitValidator, settings: renderRepositorySettings, }[ui.repositoryTab] || renderChanges )(repository); @@ -1009,6 +1040,7 @@ function renderRepositoryWorkspace(repository) { ["history", "History"], ["deployments", "Deployments"], ["gittools", "Git tools"], + ["validator", "Git Validator"], ["settings", "Project settings"], ] .map( @@ -1669,7 +1701,19 @@ app.addEventListener("click", async (event) => { ui.repositoryTab = target.dataset.tab; if (ui.repositoryTab === "gittools" && !ui.branches.length) 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 { ui.pullRequests = await window.forgeflow.pullRequests( repository.fullName, @@ -1681,6 +1725,61 @@ app.addEventListener("click", async (event) => { } 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") { ui.boot.state = await window.forgeflow.favoriteRepository( repository.fullName, diff --git a/src/renderer/mock-bridge.js b/src/renderer/mock-bridge.js index 3aa323a..8f43284 100644 --- a/src/renderer/mock-bridge.js +++ b/src/renderer/mock-bridge.js @@ -564,7 +564,7 @@ await wait(80); snapshot(); return { - appVersion: "0.8.8-demo", + appVersion: "0.8.9-demo", platform: "win32", state: clone(state), git: { available: true, version: "git version 2.47.3" }, @@ -1461,6 +1461,117 @@ 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 ", + 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() { return { enabled: state.preferences.diagnosticsEnabled !== false, diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 82f7e9b..bef654c 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -1280,6 +1280,227 @@ html[data-theme="light"] .diff-line.remove { color: #caa7ff; 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 { height: 100%; min-height: 260px; diff --git a/tests/git-validator.test.mjs b/tests/git-validator.test.mjs new file mode 100644 index 0000000..24fc9e8 --- /dev/null +++ b/tests/git-validator.test.mjs @@ -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); +}); diff --git a/tests/gitea-actions.test.mjs b/tests/gitea-actions.test.mjs index 91e0c5a..fb5076d 100644 --- a/tests/gitea-actions.test.mjs +++ b/tests/gitea-actions.test.mjs @@ -126,3 +126,18 @@ test('downloads release assets through the release-scoped Gitea endpoint', async /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'); +}); diff --git a/tests/renderer-workflow.test.mjs b/tests/renderer-workflow.test.mjs index 6a4116a..2511728 100644 --- a/tests/renderer-workflow.test.mjs +++ b/tests/renderer-workflow.test.mjs @@ -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, /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/); +});