485 lines
15 KiB
JavaScript
485 lines
15 KiB
JavaScript
"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,
|
|
};
|