e8da1a30ff
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 2s
Generic: Repo Health / Release configuration (push) Successful in 10s
Generic: Repo Health / Scripts governance (push) Successful in 9s
Generic: Repo Health / Repository health (push) Successful in 17s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 8s
Universal: PR Check / Validate PR (pull_request) Successful in 8s
Generic: Repo Health / Release configuration (pull_request) Successful in 6s
Generic: Repo Health / Scripts governance (pull_request) Successful in 8s
Universal: PR Check / Build RC Package (pull_request) Successful in 5s
Generic: Repo Health / Repository health (pull_request) Successful in 19s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 1m16s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 1m29s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Successful in 41s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 6s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 1m38s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Successful in 1m40s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Successful in 1m41s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 1m40s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 1m20s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Successful in 1m24s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 1m24s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Successful in 1m26s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Successful in 1m30s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 12s
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
Removed 13 write-only properties and unused code. Remaining 41 baselined items are defensive patterns (null coalesce on API responses, boolean safety checks) that are intentional. PHPStan level 4: 0 errors with baseline. Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
518 lines
21 KiB
PHP
518 lines
21 KiB
PHP
#!/usr/bin/env php
|
|
<?php
|
|
|
|
/**
|
|
* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
*
|
|
* This file is part of a Moko Consulting project.
|
|
*
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
*
|
|
* FILE INFORMATION
|
|
* DEFGROUP: MokoStandards.Automation
|
|
* INGROUP: MokoStandards.Scripts
|
|
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
|
* PATH: /automation/repo_cleanup.php
|
|
* BRIEF: Enterprise repository cleanup — branches, PRs, issues, workflows, labels, logs
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/../vendor/autoload.php';
|
|
require_once __DIR__ . '/../lib/Enterprise/CliFramework.php';
|
|
|
|
use MokoEnterprise\{ApiClient, AuditLogger, CliFramework, Config, GitPlatformAdapter, MetricsCollector, PlatformAdapterFactory};
|
|
|
|
/**
|
|
* Enterprise Repository Cleanup
|
|
*
|
|
* Comprehensive maintenance tool for governed repositories:
|
|
* 1. Delete stale sync branches (keeps current versioned branch)
|
|
* 2. Close superseded PRs on deleted branches
|
|
* 3. Close/lock resolved tracking issues where linked PR is merged
|
|
* 4. Delete retired workflow files from repos
|
|
* 5. Clean cancelled/stale workflow runs
|
|
* 6. Delete workflow run logs older than N days
|
|
* 7. Verify and provision standard labels
|
|
* 8. Version drift detection
|
|
*/
|
|
class RepoCleanup extends CliFramework
|
|
{
|
|
private const VERSION = '04.06.00';
|
|
private const SYNC_PREFIX = 'chore/sync-mokostandards-';
|
|
private const CURRENT_BRANCH = 'chore/sync-mokostandards-v04.02.00';
|
|
|
|
/** Workflow files that have been retired and should be deleted from governed repos. */
|
|
private const RETIRED_WORKFLOWS = [
|
|
'build.yml', 'code-quality.yml', 'release-cycle.yml', 'release-pipeline.yml',
|
|
'branch-cleanup.yml', 'auto-update-changelog.yml', 'enterprise-issue-manager.yml',
|
|
'flush-actions-cache.yml', 'mokostandards-script-runner.yml', 'unified-ci.yml',
|
|
'unified-platform-testing.yml', 'reusable-build.yml', 'reusable-ci-validation.yml',
|
|
'reusable-deploy.yml', 'reusable-php-quality.yml', 'reusable-platform-testing.yml',
|
|
'reusable-project-detector.yml', 'reusable-release.yml', 'reusable-script-executor.yml',
|
|
'rebuild-docs-indexes.yml', 'setup-project-v2.yml', 'sync-docs-to-project.yml',
|
|
'release.yml', 'sync-changelogs.yml', 'version_branch.yml',
|
|
'publish-to-mokodolibarr.yml', 'ci.yml',
|
|
'deploy-rs.yml',
|
|
];
|
|
|
|
private ApiClient $api;
|
|
private GitPlatformAdapter $adapter;
|
|
protected bool $dryRun = false;
|
|
private float $startTime;
|
|
|
|
protected function configure(): void
|
|
{
|
|
$this->setDescription('Enterprise repository cleanup');
|
|
$this->addArgument('--org', 'GitHub organization', 'MokoConsulting');
|
|
$this->addArgument('--repos', 'Specific repos (space-separated)', '');
|
|
$this->addArgument('--skip-archived', 'Skip archived repos', false);
|
|
$this->addArgument('--close-issues', 'Close resolved tracking issues', false);
|
|
$this->addArgument('--lock-old-issues', 'Lock issues closed >30 days', false);
|
|
$this->addArgument('--clean-workflows', 'Delete stale workflow runs', false);
|
|
$this->addArgument('--clean-logs', 'Delete old workflow logs', false);
|
|
$this->addArgument('--log-days', 'Days to keep logs', '30');
|
|
$this->addArgument('--delete-retired', 'Delete retired workflows', false);
|
|
$this->addArgument('--check-labels', 'Verify labels exist', false);
|
|
$this->addArgument('--check-drift', 'Check version drift', false);
|
|
$this->addArgument('--all', 'Run all operations', false);
|
|
$this->addArgument('--yes', 'Auto-confirm', false);
|
|
$this->addArgument('--json', 'Output as JSON', false);
|
|
}
|
|
|
|
protected function run(): int
|
|
{
|
|
$this->startTime = microtime(true);
|
|
$org = $this->getArgument('--org', 'MokoConsulting');
|
|
$this->dryRun = (bool) $this->getArgument('--dry-run', false);
|
|
$runAll = (bool) $this->getArgument('--all', false);
|
|
|
|
$config = Config::load();
|
|
|
|
try {
|
|
$this->adapter = PlatformAdapterFactory::create($config);
|
|
$this->api = $this->adapter->getApiClient();
|
|
} catch (\Exception $e) {
|
|
$this->errorMsg('Failed to initialize platform adapter: ' . $e->getMessage());
|
|
return 1;
|
|
}
|
|
|
|
|
|
$this->logMsg("🧹 MokoStandards Repository Cleanup v" . self::VERSION);
|
|
$this->logMsg("Organization: {$org}");
|
|
$this->logMsg("Current sync branch: " . self::CURRENT_BRANCH);
|
|
if ($this->dryRun) {
|
|
$this->logMsg("⚠️ DRY RUN — no changes will be made");
|
|
}
|
|
$this->logMsg('');
|
|
|
|
$repos = $this->fetchRepositories($org);
|
|
$this->logMsg("Found " . count($repos) . " repositories");
|
|
$this->logMsg('');
|
|
|
|
$results = [
|
|
'repos_processed' => 0,
|
|
'repos_cleaned' => 0,
|
|
'branches_deleted' => 0,
|
|
'prs_closed' => 0,
|
|
'issues_closed' => 0,
|
|
'issues_locked' => 0,
|
|
'workflows_deleted' => 0,
|
|
'runs_deleted' => 0,
|
|
'logs_deleted' => 0,
|
|
'labels_missing' => 0,
|
|
'version_drift' => 0,
|
|
'retired_files' => 0,
|
|
'errors' => 0,
|
|
];
|
|
|
|
foreach ($repos as $i => $repo) {
|
|
$name = $repo['name'];
|
|
$num = $i + 1;
|
|
$total = count($repos);
|
|
$this->logMsg("[{$num}/{$total}] {$name}");
|
|
$results['repos_processed']++;
|
|
|
|
try {
|
|
$this->api->resetCircuitBreaker();
|
|
$cleaned = false;
|
|
|
|
// Always: delete old sync branches + close their PRs
|
|
$cleaned = $this->cleanBranches($org, $name, $results) || $cleaned;
|
|
|
|
// Optional: close resolved issues
|
|
if ($runAll || $this->getArgument('--close-issues', false)) {
|
|
$cleaned = $this->closeResolvedIssues($org, $name, $results) || $cleaned;
|
|
}
|
|
|
|
// Optional: lock old closed issues
|
|
if ($runAll || $this->getArgument('--lock-old-issues', false)) {
|
|
$cleaned = $this->lockOldIssues($org, $name, $results) || $cleaned;
|
|
}
|
|
|
|
// Optional: delete retired workflow files
|
|
if ($runAll || $this->getArgument('--delete-retired', false)) {
|
|
$cleaned = $this->deleteRetiredWorkflows($org, $name, $results) || $cleaned;
|
|
}
|
|
|
|
// Optional: clean workflow runs
|
|
if ($runAll || $this->getArgument('--clean-workflows', false)) {
|
|
$cleaned = $this->cleanWorkflowRuns($org, $name, $results) || $cleaned;
|
|
}
|
|
|
|
// Optional: clean old logs
|
|
if ($runAll || $this->getArgument('--clean-logs', false)) {
|
|
$cleaned = $this->cleanOldLogs($org, $name, $results) || $cleaned;
|
|
}
|
|
|
|
// Optional: check labels
|
|
if ($runAll || $this->getArgument('--check-labels', false)) {
|
|
$this->checkLabels($org, $name, $results);
|
|
}
|
|
|
|
// Optional: check version drift
|
|
if ($runAll || $this->getArgument('--check-drift', false)) {
|
|
$this->checkVersionDrift($org, $name, $results);
|
|
}
|
|
|
|
if ($cleaned) {
|
|
$results['repos_cleaned']++;
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->errorMsg(" ✗ {$name}: " . $e->getMessage());
|
|
$results['errors']++;
|
|
}
|
|
}
|
|
|
|
$duration = round(microtime(true) - $this->startTime, 1);
|
|
|
|
$this->logMsg('');
|
|
$this->logMsg('============================================================');
|
|
$this->logMsg("🧹 Cleanup Complete ({$duration}s)");
|
|
$this->logMsg('============================================================');
|
|
$this->logMsg("Repos processed: {$results['repos_processed']}");
|
|
$this->logMsg("Repos with changes: {$results['repos_cleaned']}");
|
|
$this->logMsg("Branches deleted: {$results['branches_deleted']}");
|
|
$this->logMsg("PRs closed: {$results['prs_closed']}");
|
|
$this->logMsg("Issues closed: {$results['issues_closed']}");
|
|
$this->logMsg("Issues locked: {$results['issues_locked']}");
|
|
$this->logMsg("Retired files: {$results['retired_files']}");
|
|
$this->logMsg("Workflow runs: {$results['runs_deleted']}");
|
|
$this->logMsg("Logs cleaned: {$results['logs_deleted']}");
|
|
$this->logMsg("Labels missing: {$results['labels_missing']}");
|
|
$this->logMsg("Version drift: {$results['version_drift']}");
|
|
$this->logMsg("Errors: {$results['errors']}");
|
|
$this->logMsg('============================================================');
|
|
|
|
if ($this->getArgument('--json', false)) {
|
|
$results['duration_seconds'] = $duration;
|
|
echo json_encode($results, JSON_PRETTY_PRINT) . "\n";
|
|
}
|
|
|
|
return $results['errors'] > 0 ? 1 : 0;
|
|
}
|
|
|
|
// ─── Repository fetching ─────────────────────────────────────────────
|
|
|
|
private function fetchRepositories(string $org): array
|
|
{
|
|
$specificRepos = trim((string) $this->getArgument('--repos', ''));
|
|
$skipArchived = (bool) $this->getArgument('--skip-archived', false);
|
|
|
|
if (!empty($specificRepos)) {
|
|
$names = preg_split('/[\s,]+/', $specificRepos);
|
|
return array_map(fn($n) => ['name' => trim($n), 'archived' => false], $names);
|
|
}
|
|
|
|
$allRepos = $this->adapter->listOrgRepos($org, $skipArchived);
|
|
return array_filter($allRepos, fn($r) => !in_array($r['name'], ['MokoStandards', '.github-private'], true));
|
|
}
|
|
|
|
// ─── Cleanup operations ──────────────────────────────────────────────
|
|
|
|
private function cleanBranches(string $org, string $repo, array &$results): bool
|
|
{
|
|
$changed = false;
|
|
try {
|
|
$branches = $this->api->get("/repos/{$org}/{$repo}/branches", ['per_page' => 100]);
|
|
} catch (\Exception $e) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($branches as $branch) {
|
|
$name = $branch['name'] ?? '';
|
|
if (!str_starts_with($name, self::SYNC_PREFIX) || $name === self::CURRENT_BRANCH) {
|
|
continue;
|
|
}
|
|
|
|
// Close open PRs on this branch
|
|
try {
|
|
$prs = $this->api->get("/repos/{$org}/{$repo}/pulls", [
|
|
'state' => 'open', 'head' => "{$org}:{$name}", 'per_page' => 10,
|
|
]);
|
|
foreach ($prs as $pr) {
|
|
if (($pr['number'] ?? 0) > 0 && !$this->dryRun) {
|
|
$this->api->patch("/repos/{$org}/{$repo}/pulls/{$pr['number']}", ['state' => 'closed']);
|
|
}
|
|
$this->logMsg(" 🔒 Closed PR #{$pr['number']} ({$name})");
|
|
$results['prs_closed']++;
|
|
$changed = true;
|
|
}
|
|
} catch (\Exception $e) {
|
|
/* non-fatal */
|
|
}
|
|
|
|
if (!$this->dryRun) {
|
|
try {
|
|
$this->api->delete("/repos/{$org}/{$repo}/git/refs/heads/{$name}");
|
|
} catch (\Exception $e) {
|
|
continue;
|
|
}
|
|
}
|
|
$this->logMsg(" 🗑️ Deleted branch: {$name}");
|
|
$results['branches_deleted']++;
|
|
$changed = true;
|
|
}
|
|
|
|
return $changed;
|
|
}
|
|
|
|
private function closeResolvedIssues(string $org, string $repo, array &$results): bool
|
|
{
|
|
$changed = false;
|
|
foreach (['standards-update', 'standards-drift'] as $label) {
|
|
try {
|
|
$issues = $this->api->get("/repos/{$org}/{$repo}/issues", [
|
|
'labels' => $label, 'state' => 'open', 'per_page' => 10,
|
|
]);
|
|
} catch (\Exception $e) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($issues as $issue) {
|
|
$num = $issue['number'] ?? 0;
|
|
$body = $issue['body'] ?? '';
|
|
if (preg_match('/\[#(\d+)\]/', $body, $m)) {
|
|
$prNum = (int) $m[1];
|
|
try {
|
|
$pr = $this->api->get("/repos/{$org}/{$repo}/pulls/{$prNum}");
|
|
if (!empty($pr['merged_at'])) {
|
|
if (!$this->dryRun) {
|
|
$this->api->patch("/repos/{$org}/{$repo}/issues/{$num}", [
|
|
'state' => 'closed', 'state_reason' => 'completed',
|
|
]);
|
|
}
|
|
$this->logMsg(" ✅ Closed issue #{$num} (PR #{$prNum} merged)");
|
|
$results['issues_closed']++;
|
|
$changed = true;
|
|
}
|
|
} catch (\Exception $e) {
|
|
/* non-fatal */
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return $changed;
|
|
}
|
|
|
|
private function lockOldIssues(string $org, string $repo, array &$results): bool
|
|
{
|
|
$changed = false;
|
|
$cutoff = date('Y-m-d\TH:i:s\Z', strtotime('-30 days'));
|
|
|
|
try {
|
|
$issues = $this->api->get("/repos/{$org}/{$repo}/issues", [
|
|
'state' => 'closed', 'per_page' => 50, 'sort' => 'updated', 'direction' => 'asc',
|
|
]);
|
|
} catch (\Exception $e) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($issues as $issue) {
|
|
$closedAt = $issue['closed_at'] ?? '';
|
|
$locked = $issue['locked'] ?? false;
|
|
$num = $issue['number'] ?? 0;
|
|
|
|
if ($locked || $closedAt > $cutoff || $num === 0) {
|
|
continue;
|
|
}
|
|
|
|
if (!$this->dryRun) {
|
|
try {
|
|
$this->api->put("/repos/{$org}/{$repo}/issues/{$num}/lock", [
|
|
'lock_reason' => 'resolved',
|
|
]);
|
|
} catch (\Exception $e) {
|
|
continue;
|
|
}
|
|
}
|
|
$results['issues_locked']++;
|
|
$changed = true;
|
|
}
|
|
|
|
if ($results['issues_locked'] > 0) {
|
|
$this->logMsg(" 🔒 Locked {$results['issues_locked']} old closed issue(s)");
|
|
}
|
|
return $changed;
|
|
}
|
|
|
|
private function deleteRetiredWorkflows(string $org, string $repo, array &$results): bool
|
|
{
|
|
$changed = false;
|
|
$defaultBranch = 'main';
|
|
try {
|
|
$repoInfo = $this->api->get("/repos/{$org}/{$repo}");
|
|
$defaultBranch = $repoInfo['default_branch'] ?? 'main';
|
|
} catch (\Exception $e) {
|
|
/* fallback to main */
|
|
}
|
|
|
|
// Check both workflow directories for retired workflows (supports dual-platform repos)
|
|
$wfDirs = array_unique(['.github/workflows', '.mokogitea/workflows', $this->adapter->getWorkflowDir()]);
|
|
foreach (self::RETIRED_WORKFLOWS as $wf) {
|
|
foreach ($wfDirs as $wfDir) {
|
|
$path = "{$wfDir}/{$wf}";
|
|
try {
|
|
$file = $this->api->get("/repos/{$org}/{$repo}/contents/{$path}");
|
|
$sha = $file['sha'] ?? '';
|
|
if (empty($sha)) {
|
|
continue;
|
|
}
|
|
|
|
if (!$this->dryRun) {
|
|
$this->api->delete("/repos/{$org}/{$repo}/contents/{$path}", [
|
|
'message' => "chore: delete retired workflow {$wf}",
|
|
'sha' => $sha,
|
|
'branch' => $defaultBranch,
|
|
]);
|
|
}
|
|
$this->logMsg(" Deleted retired: {$wf} (from {$wfDir})");
|
|
$results['retired_files']++;
|
|
$changed = true;
|
|
} catch (\Exception $e) {
|
|
// File doesn't exist in this dir — skip
|
|
$this->api->resetCircuitBreaker();
|
|
}
|
|
}
|
|
}
|
|
return $changed;
|
|
}
|
|
|
|
private function cleanWorkflowRuns(string $org, string $repo, array &$results): bool
|
|
{
|
|
$changed = false;
|
|
foreach (['cancelled', 'stale'] as $status) {
|
|
try {
|
|
$runs = $this->api->get("/repos/{$org}/{$repo}/actions/runs", [
|
|
'status' => $status, 'per_page' => 100,
|
|
]);
|
|
foreach (($runs['workflow_runs'] ?? []) as $run) {
|
|
$id = $run['id'] ?? 0;
|
|
if ($id > 0 && !$this->dryRun) {
|
|
try {
|
|
$this->api->delete("/repos/{$org}/{$repo}/actions/runs/{$id}");
|
|
$results['runs_deleted']++;
|
|
$changed = true;
|
|
} catch (\Exception $e) {
|
|
$this->api->resetCircuitBreaker();
|
|
}
|
|
}
|
|
}
|
|
} catch (\Exception $e) {
|
|
/* non-fatal */
|
|
}
|
|
}
|
|
if ($results['runs_deleted'] > 0) {
|
|
$this->logMsg(" 🔄 Cleaned {$results['runs_deleted']} workflow run(s)");
|
|
}
|
|
return $changed;
|
|
}
|
|
|
|
private function cleanOldLogs(string $org, string $repo, array &$results): bool
|
|
{
|
|
$changed = false;
|
|
$days = (int) $this->getArgument('--log-days', '30');
|
|
$cutoff = date('Y-m-d\TH:i:s\Z', strtotime("-{$days} days"));
|
|
|
|
try {
|
|
$runs = $this->api->get("/repos/{$org}/{$repo}/actions/runs", [
|
|
'created' => "<{$cutoff}", 'per_page' => 100,
|
|
]);
|
|
foreach (($runs['workflow_runs'] ?? []) as $run) {
|
|
$id = $run['id'] ?? 0;
|
|
if ($id > 0 && !$this->dryRun) {
|
|
try {
|
|
$this->api->delete("/repos/{$org}/{$repo}/actions/runs/{$id}/logs");
|
|
$results['logs_deleted']++;
|
|
$changed = true;
|
|
} catch (\Exception $e) {
|
|
$this->api->resetCircuitBreaker();
|
|
}
|
|
}
|
|
}
|
|
} catch (\Exception $e) {
|
|
/* non-fatal */
|
|
}
|
|
|
|
if ($results['logs_deleted'] > 0) {
|
|
$this->logMsg(" 📋 Cleaned {$results['logs_deleted']} old log(s)");
|
|
}
|
|
return $changed;
|
|
}
|
|
|
|
private function checkLabels(string $org, string $repo, array &$results): void
|
|
{
|
|
try {
|
|
$this->api->get("/repos/{$org}/{$repo}/labels/mokostandards");
|
|
} catch (\Exception $e) {
|
|
$this->logMsg(" ⚠️ Missing 'mokostandards' label");
|
|
$results['labels_missing']++;
|
|
$this->api->resetCircuitBreaker();
|
|
}
|
|
}
|
|
|
|
private function checkVersionDrift(string $org, string $repo, array &$results): void
|
|
{
|
|
try {
|
|
$file = $this->api->get("/repos/{$org}/{$repo}/contents/README.md");
|
|
$content = base64_decode($file['content'] ?? '');
|
|
if (preg_match('/^\s*VERSION:\s*(\d{2}\.\d{2}\.\d{2})/m', $content, $m)) {
|
|
$version = $m[1];
|
|
|
|
// Check .mokostandards for the tracked MokoStandards version
|
|
try {
|
|
$mokoFile = $this->api->get("/repos/{$org}/{$repo}/contents/.mokostandards");
|
|
$mokoContent = base64_decode($mokoFile['content'] ?? '');
|
|
if (preg_match('/standards_version:\s*(\d{2}\.\d{2}\.\d{2})/m', $mokoContent, $vm)) {
|
|
if ($vm[1] !== self::VERSION) {
|
|
$this->logMsg(" ⚠️ Standards drift: {$vm[1]} (expected " . self::VERSION . ")");
|
|
$results['version_drift']++;
|
|
}
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->api->resetCircuitBreaker();
|
|
}
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->api->resetCircuitBreaker();
|
|
}
|
|
}
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
|
|
private function logMsg(string $message): void
|
|
{
|
|
if (!$this->quiet) {
|
|
echo $message . "\n";
|
|
}
|
|
}
|
|
|
|
private function errorMsg(string $message): void
|
|
{
|
|
fwrite(STDERR, $message . "\n");
|
|
}
|
|
}
|
|
|
|
$app = new RepoCleanup();
|
|
exit($app->execute());
|