"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 `; const RECOMMENDED_GITATTRIBUTES = `* text=auto eol=lf *.bat text eol=crlf *.cmd text eol=crlf *.ps1 text eol=crlf *.png binary *.jpg binary *.jpeg binary *.gif binary *.ico binary *.zip binary `; const RECOMMENDED_EDITORCONFIG = `root = true [*] charset = utf-8 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true indent_style = space indent_size = 2 [*.{bat,cmd,ps1}] end_of_line = crlf `; 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.", }, ), ); for (const [id, title, filename, action] of [ ["gitattributes", ".gitattributes normalizes text and binary files", ".gitattributes", "add-gitattributes"], ["editorconfig", ".editorconfig keeps editors consistent", ".editorconfig", "add-editorconfig"], ]) { const present = lowerFiles.includes(filename); checks.push(result(id, "Repository hygiene", title, present ? "pass" : "warning", present ? `${filename} is versioned.` : `No tracked ${filename} was found.`, { weight: 5, fixAction: present ? null : action, safe: false, confirmation: `Create a recommended ${filename} in the working tree for review?`, })); } const packageManagers = [ { manifests: ["package.json"], locks: ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"] }, { manifests: ["pyproject.toml", "requirements.in", "pipfile"], locks: ["uv.lock", "poetry.lock", "requirements.txt", "pipfile.lock"] }, { manifests: ["composer.json"], locks: ["composer.lock"] }, { manifests: ["gemfile"], locks: ["gemfile.lock"] }, ]; const lockCheck = packageManagers.find((entry) => entry.manifests.some((name) => lowerFiles.includes(name))); if (lockCheck) { const lockfile = lockCheck.locks.find((name) => lowerFiles.includes(name)); checks.push(result("dependency-lock", "Supply chain", "Dependencies are reproducibly locked", lockfile ? "pass" : "warning", lockfile ? `${lockfile} is versioned.` : "A dependency manifest exists without a recognized lockfile.", { weight: 9 })); } const hasCi = lowerFiles.some((file) => /^\.gitea\/workflows\/[^/]+\.ya?ml$/.test(file)); checks.push(result("continuous-integration", "Gitea governance", "Automated checks run on Gitea", hasCi ? "pass" : "warning", hasCi ? "At least one Gitea Actions workflow is versioned." : "No .gitea/workflows YAML file was found.", { weight: 8 })); 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 (["add-gitattributes", "add-editorconfig"].includes(check.fixAction)) { const definition = check.fixAction === "add-gitattributes" ? { name: ".gitattributes", content: RECOMMENDED_GITATTRIBUTES } : { name: ".editorconfig", content: RECOMMENDED_EDITORCONFIG }; const target = path.join(root, definition.name); if (await fs.stat(target).catch(() => null)) throw new Error(`${definition.name} already exists; rescan before repairing.`); await fs.writeFile(target, definition.content, { encoding: "utf8", flag: "wx" }); return { created: definition.name }; } 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, RECOMMENDED_GITATTRIBUTES, RECOMMENDED_EDITORCONFIG, sameRemote, isSensitiveTrackedPath, };