Files
ForgeFlow/src/main/git-validator-service.cjs
T
NuklearRabbit 258f0b1324
ForgeFlow quality gate / quality (push) Canceled after 0s
fix: restore scrolling and validator enforcement
2026-08-01 12:44:31 +02:00

600 lines
26 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 { applyPolicy, buildTrend, exportReport, normalizePolicy, validateSuppression } = require("./git-validator-policy.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,
evidence: options.evidence || 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, store }) {
this.git = git;
this.gitea = gitea;
this.diagnostics = diagnostics;
this.store = store;
}
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.finalize(repository, checks, null);
}
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 },
),
);
await this.addAssuranceChecks(root, tracked, lowerFiles, checks);
return this.finalize(repository, checks, status);
}
async addAssuranceChecks(root, tracked, lowerFiles, checks) {
const has = (...patterns) => lowerFiles.some((file) => patterns.some((pattern) => pattern.test(file)));
const fileCheck = (id, category, title, patterns, detail, weight = 5) => {
const present = has(...patterns);
checks.push(result(id, category, title, present ? "pass" : "warning", present ? `${title} is versioned.` : detail, { weight }));
};
fileCheck("security-policy", "Security", "Security policy", [/(^|\/)security\.md$/], "Add SECURITY.md with supported versions and private disclosure instructions.", 8);
fileCheck("codeowners", "Governance", "Code ownership", [/(^|\/)codeowners$/], "Add CODEOWNERS for security-sensitive and release paths.", 6);
fileCheck("license", "Governance", "Repository license", [/(^|\/)(license|copying)(\.[^/]+)?$/], "Document the repository license or private-use terms.", 5);
fileCheck("changelog", "Release readiness", "Changelog", [/(^|\/)changelog(\.[^/]+)?$/], "Add a changelog that maps releases to user-visible changes.", 7);
fileCheck("contributing", "Collaboration", "Contribution guide", [/(^|\/)contributing(\.[^/]+)?$/], "Add contribution, test and review instructions.", 4);
fileCheck("issue-templates", "Collaboration", "Issue templates", [/^\.gitea\/issue_template\//, /^\.github\/issue_template\//], "Add structured issue templates.", 3);
fileCheck("pull-request-template", "Collaboration", "Pull request template", [/(^|\/)pull_request_template\.md$/], "Add a pull request checklist for tests, risk and rollback.", 4);
fileCheck("runtime-pinning", "Reproducibility", "Runtime version pinning", [/(^|\/)(\.nvmrc|\.node-version|\.tool-versions|mise\.toml)$/], "Pin the runtime version used by developers and CI.", 7);
fileCheck("build-instructions", "Reproducibility", "Build instructions", [/(^|\/)(readme|building|build)(\.[^/]+)?$/], "Document a clean, reproducible build command.", 6);
const generated = tracked.filter((file) => /(^|\/)(dist|build|coverage|\.cache)\//i.test(file));
checks.push(result("generated-artifacts", "Performance and hygiene", "Generated output is not tracked", generated.length ? "warning" : "pass", generated.length ? `${generated.length} generated-path file(s) are tracked; review ${generated.slice(0, 5).join(", ")}.` : "No common generated output directories are tracked.", { weight: 8, evidence: generated.slice(0, 20) }));
const executables = tracked.filter((file) => /\.(exe|dll|msi|scr|com|bat|cmd|ps1)$/i.test(file));
checks.push(result("executable-artifacts", "Security", "Executable artifacts are intentional", executables.length ? "warning" : "pass", executables.length ? `Review executable content: ${executables.slice(0, 8).join(", ")}.` : "No executable-shaped artifacts are tracked.", { weight: 8, evidence: executables.slice(0, 20) }));
const workflowFiles = tracked.filter((file) => /^\.(gitea|github)\/workflows\/[^/]+\.ya?ml$/i.test(file));
const workflowText = (await Promise.all(workflowFiles.slice(0, 40).map((file) => fs.readFile(path.join(root, file), "utf8").catch(() => "")))).join("\n");
const unpinned = [...workflowText.matchAll(/uses:\s*[^\s@]+@([^\s#]+)/g)].map((match) => match[1]).filter((ref) => !/^[0-9a-f]{40}$/i.test(ref));
checks.push(result("pinned-actions", "Security", "External CI actions are commit-pinned", unpinned.length ? "warning" : "pass", unpinned.length ? `${unpinned.length} action reference(s) use mutable tags or branches.` : "External actions are commit-pinned or no external actions are used.", { weight: 9, evidence: unpinned.slice(0, 20) }));
const broadPermissions = /permissions:\s*(write-all|write)/i.test(workflowText) || /contents:\s*write/i.test(workflowText);
checks.push(result("workflow-permissions", "Security", "Workflow permissions use least privilege", broadPermissions ? "error" : "pass", broadPermissions ? "A workflow requests broad write permissions; scope permissions per job and capability." : "No broad workflow write permission was detected.", { weight: 12 }));
const [commitSignature, tagSignature, recentSubjects] = await Promise.all([
run("git", ["log", "-1", "--format=%G?"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim()).catch(() => "N"),
run("git", ["tag", "--points-at", "HEAD", "--format=%(contents:signature)"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim()).catch(() => ""),
run("git", ["log", "-20", "--format=%s"], { cwd: root, timeout: 10_000, allowExitCodes: [128] }).then((value) => value.stdout.trim().split(/\r?\n/).filter(Boolean)).catch(() => []),
]);
checks.push(result("signed-commits", "Governance", "Latest commit is signed", /[GUYX]/.test(commitSignature) ? "pass" : "warning", /[GUYX]/.test(commitSignature) ? "Git reports a cryptographic signature on HEAD." : "HEAD has no verifiable Git signature.", { weight: 6 }));
checks.push(result("signed-tags", "Governance", "Release tags are signed", tagSignature ? "pass" : "warning", tagSignature ? "HEAD has a signed tag." : "HEAD has no signed release tag.", { weight: 5 }));
const conventional = recentSubjects.length > 0 && recentSubjects.every((subject) => /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?:\s.+/i.test(subject));
checks.push(result("conventional-commits", "Governance", "Recent commits follow a convention", conventional ? "pass" : "warning", conventional ? `${recentSubjects.length} recent commit subject(s) follow Conventional Commits.` : "Some recent commit subjects do not follow Conventional Commits.", { weight: 4 }));
const releaseFiles = {
"release-checksums": /(^|\/)(sha256sums|checksums?)(\.[^/]+)?$/,
"release-provenance": /(^|\/)(provenance|attestation)(\.[^/]+)?$/,
"release-sbom": /(^|\/)(sbom)(\.[^/]+)?$/,
};
for (const [id, pattern] of Object.entries(releaseFiles)) fileCheck(id, "Release readiness", id.replace(/^release-/, "Release "), [pattern], `No ${id.replace(/^release-/, "")} artifact is versioned or generated in the repository.`, 4);
checks.push(result("signing-readiness", "Release readiness", "Signing policy is documented", has(/(^|\/)(security|release|signing)(\.[^/]+)?$/) ? "pass" : "warning", has(/(^|\/)(security|release|signing)(\.[^/]+)?$/) ? "Signing guidance is present." : "Document signing identity, verification and timestamp requirements.", { weight: 6 }));
}
async finalize(repository, checks, status) {
const repositoryState = this.store?.getGitValidatorState?.(repository.fullName) || { policy: { id: "standard" }, suppressions: [], trends: [] };
const { policy, checks: governedChecks } = applyPolicy(checks, repositoryState.policy, repositoryState.suppressions);
const report = this.summarize(repository, governedChecks);
report.policy = policy;
report.commitSha = status?.head || status?.branch?.oid || null;
report.categories = Object.fromEntries([...new Set(governedChecks.map((check) => check.category))].map((category) => {
const categoryChecks = governedChecks.filter((check) => check.category === category);
return [category, Math.round(categoryChecks.filter((check) => check.status === "pass" || check.suppressed).length / categoryChecks.length * 100)];
}));
report.ready = report.score >= policy.requiredScore && !governedChecks.some((check) => check.blocking);
report.expiredSuppressions = governedChecks.filter((check) => check.expiredSuppression).map((check) => check.id);
report.trend = buildTrend(repositoryState.trends.at(-1), report);
if (this.store?.appendGitValidatorTrend) await this.store.appendGitValidatorTrend(repository.fullName, report.trend);
return report;
}
async setPolicy(repository, policyInput) {
const policy = normalizePolicy(policyInput);
if (!this.store?.setGitValidatorPolicy) throw new Error("Git Validator policy persistence is unavailable.");
await this.store.setGitValidatorPolicy(repository.fullName, policy);
return policy;
}
async suppress(repository, input) {
const state = this.store?.getGitValidatorState?.(repository.fullName) || { policy: { id: "standard" } };
const suppression = validateSuppression(input, normalizePolicy(state.policy));
await this.store.addGitValidatorSuppression(repository.fullName, suppression);
return suppression;
}
export(report, format) { return exportReport(report, format); }
async previewRepair(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;
const fileDefinitions = {
"add-gitignore": [".gitignore", RECOMMENDED_GITIGNORE],
"add-gitattributes": [".gitattributes", RECOMMENDED_GITATTRIBUTES],
"add-editorconfig": [".editorconfig", RECOMMENDED_EDITORCONFIG],
};
if (fileDefinitions[check.fixAction]) {
const [name, content] = fileDefinitions[check.fixAction];
if (await fs.stat(path.join(root, name)).catch(() => null)) throw new Error(`${name} already exists; rescan before repairing.`);
return { checkId: check.id, action: check.fixAction, files: [name], diff: `diff --git a/${name} b/${name}\nnew file mode 100644\n--- /dev/null\n+++ b/${name}\n${content.split("\n").filter((line, index, lines) => index < lines.length - 1).map((line) => `+${line}`).join("\n")}\n`, remoteMutation: false };
}
if (check.fixAction === "configure-local-safety") return { checkId: check.id, action: check.fixAction, files: [".git/config"], diff: "+ fetch.prune = true\n+ pull.ff = only\n+ rebase.autoStash = true\n", remoteMutation: false };
if (check.fixAction === "align-origin") return { checkId: check.id, action: check.fixAction, files: [".git/config"], diff: `- origin = current\n+ origin = ${repository.preferredCloneUrl || repository.cloneUrl || repository.sshUrl}\n`, remoteMutation: false };
if (check.fixAction === "protect-default-branch") return { checkId: check.id, action: check.fixAction, files: [], diff: `Gitea policy change:\n+ protect ${repository.defaultBranch || "main"}\n+ block force pushes\n+ require pull request review\n`, remoteMutation: true };
throw new Error("Unsupported Git Validator repair action.");
}
async resolveRepairCheck(repository, candidate) {
const checkId = String(candidate?.id || candidate?.checkId || "").trim();
if (!checkId) throw new Error("A current Git Validator check ID is required.");
const report = await this.scan(repository);
const current = report.checks.find((check) => check.id === checkId);
if (!current?.fixAction)
throw new Error("This finding is resolved, suppressed or no longer repairable. Scan again before repairing.");
if (candidate?.fixAction && candidate.fixAction !== current.fixAction)
throw new Error("The Git Validator repair request is stale. Scan again before repairing.");
return current;
}
summarize(repository, checks) {
const totalWeight = checks.reduce((sum, check) => sum + check.weight, 0);
const earned = checks.reduce(
(sum, check) =>
sum +
(check.status === "pass" || check.suppressed
? 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" && !check.suppressed).length,
errors: checks.filter((check) => check.status === "error" && !check.suppressed).length,
suppressed: checks.filter((check) => check.suppressed).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,
};