Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da7518be54 | ||
|
|
3a8377f799 | ||
|
|
ecdcb671de | ||
|
|
6c912f5e18 | ||
|
|
015f5bcfd3 | ||
|
|
1de57bcf48 | ||
|
|
091538eaa5 |
@@ -0,0 +1,17 @@
|
|||||||
|
gitea_branch_sync:
|
||||||
|
purpose: keep Gitea branch index aligned with Git refs
|
||||||
|
mode: reconcile_report_first
|
||||||
|
automatic_database_write: false
|
||||||
|
checks:
|
||||||
|
- compare_git_refs_with_branch_index
|
||||||
|
- verify_post_receive_hook
|
||||||
|
- verify_push_path
|
||||||
|
preferred_write_path:
|
||||||
|
method: gitea_ssh_or_http_push
|
||||||
|
reason: trigger_native_gitea_hooks
|
||||||
|
fallback:
|
||||||
|
method: approved_operator_reconciliation
|
||||||
|
requires_owner_approval: true
|
||||||
|
security:
|
||||||
|
secrets_in_git: false
|
||||||
|
direct_main_push: false
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Gitea branch index reconciliation helper.
|
||||||
|
|
||||||
|
Purpose:
|
||||||
|
- compare Git refs in a bare repository with Gitea visible branch index
|
||||||
|
- generate a reconciliation report
|
||||||
|
- provide safe operator guidance
|
||||||
|
|
||||||
|
This tool defaults to dry-run. It does not store credentials, push main, or
|
||||||
|
modify production databases automatically.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class BranchRef:
|
||||||
|
name: str
|
||||||
|
commit_id: str
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BranchIndexReport:
|
||||||
|
git_branch_count: int
|
||||||
|
indexed_branch_count: int
|
||||||
|
missing_branches: list[BranchRef] = field(default_factory=list)
|
||||||
|
generated_at: str = field(default_factory=utc_now)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"git_branch_count": self.git_branch_count,
|
||||||
|
"indexed_branch_count": self.indexed_branch_count,
|
||||||
|
"missing_branches": [b.to_dict() for b in self.missing_branches],
|
||||||
|
"generated_at": self.generated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class GitBranchReader:
|
||||||
|
def __init__(self, bare_repo: str):
|
||||||
|
self.bare_repo = Path(bare_repo)
|
||||||
|
|
||||||
|
def read(self) -> list[BranchRef]:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
f"--git-dir={self.bare_repo}",
|
||||||
|
"for-each-ref",
|
||||||
|
"--format=%(refname:short)|%(objectname)",
|
||||||
|
"refs/heads",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
branches = []
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if "|" not in line:
|
||||||
|
continue
|
||||||
|
name, commit_id = line.split("|", 1)
|
||||||
|
branches.append(BranchRef(name, commit_id))
|
||||||
|
return branches
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaBranchIndexReconciler:
|
||||||
|
def __init__(self, git_reader: GitBranchReader):
|
||||||
|
self.git_reader = git_reader
|
||||||
|
|
||||||
|
def compare(self, indexed_branches: dict[str, str]) -> BranchIndexReport:
|
||||||
|
refs = self.git_reader.read()
|
||||||
|
missing = [branch for branch in refs if branch.name not in indexed_branches]
|
||||||
|
return BranchIndexReport(
|
||||||
|
git_branch_count=len(refs),
|
||||||
|
indexed_branch_count=len(indexed_branches),
|
||||||
|
missing_branches=missing,
|
||||||
|
)
|
||||||
|
|
||||||
|
def generate_operator_plan(self, report: BranchIndexReport) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"action": "reconcile_gitea_branch_index",
|
||||||
|
"mode": "dry_run",
|
||||||
|
"missing": [branch.to_dict() for branch in report.missing_branches],
|
||||||
|
"recommendation": [
|
||||||
|
"verify Gitea post-receive hook execution",
|
||||||
|
"prefer Gitea SSH/HTTP push path for future writes",
|
||||||
|
"apply DB/index repair only through approved operator procedure",
|
||||||
|
],
|
||||||
|
"created_at": utc_now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["BranchRef", "BranchIndexReport", "GitBranchReader", "GiteaBranchIndexReconciler"]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Supervisor for Gitea branch index drift detection."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .gitea_branch_index_sync import BranchIndexReport
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BranchSyncState:
|
||||||
|
checks: int = 0
|
||||||
|
drift_detected: int = 0
|
||||||
|
last_status: str = "idle"
|
||||||
|
last_run: str | None = None
|
||||||
|
alerts: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaBranchSyncSupervisor:
|
||||||
|
def __init__(self):
|
||||||
|
self.state = BranchSyncState()
|
||||||
|
|
||||||
|
def evaluate(self, report: BranchIndexReport) -> dict[str, Any]:
|
||||||
|
self.state.checks += 1
|
||||||
|
self.state.last_run = utc_now()
|
||||||
|
if report.missing_branches:
|
||||||
|
self.state.drift_detected += 1
|
||||||
|
self.state.last_status = "drift_detected"
|
||||||
|
self.state.alerts.append(
|
||||||
|
f"{len(report.missing_branches)} git refs missing from Gitea branch index"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.state.last_status = "healthy"
|
||||||
|
return {
|
||||||
|
"status": self.state.last_status,
|
||||||
|
"state": self.state.to_dict(),
|
||||||
|
"report": report.to_dict(),
|
||||||
|
"action": "repair_required" if report.missing_branches else "none",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["BranchSyncState", "GiteaBranchSyncSupervisor"]
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Gitea push adapter plan for GitOps writes.
|
||||||
|
|
||||||
|
The adapter does not execute network pushes by itself. It builds a safe push plan
|
||||||
|
that routes future GitOps writes through Gitea SSH/HTTP instead of direct bare
|
||||||
|
repo writes, so Gitea native hooks can update branch indexes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GiteaPushPlan:
|
||||||
|
remote_url: str
|
||||||
|
branch: str
|
||||||
|
refspec: str
|
||||||
|
push_method: str
|
||||||
|
direct_bare_write: bool
|
||||||
|
expected_hook: str
|
||||||
|
owner_approval_required: bool
|
||||||
|
created_at: str = field(default_factory=utc_now)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaPushAdapter:
|
||||||
|
"""Builds governance-safe plans for future GitOps push execution."""
|
||||||
|
|
||||||
|
ALLOWED_METHODS = {"ssh", "https"}
|
||||||
|
|
||||||
|
def __init__(self, remote_url: str, push_method: str = "ssh"):
|
||||||
|
if push_method not in self.ALLOWED_METHODS:
|
||||||
|
raise ValueError("push_method must be ssh or https")
|
||||||
|
self.remote_url = remote_url
|
||||||
|
self.push_method = push_method
|
||||||
|
|
||||||
|
def build_plan(self, branch: str, owner_approval_required: bool = True) -> GiteaPushPlan:
|
||||||
|
if not branch.startswith(("patch/", "request/", "test/")):
|
||||||
|
raise ValueError("only patch/request/test branches may be pushed by GitOps")
|
||||||
|
return GiteaPushPlan(
|
||||||
|
remote_url=self.remote_url,
|
||||||
|
branch=branch,
|
||||||
|
refspec=f"HEAD:refs/heads/{branch}",
|
||||||
|
push_method=self.push_method,
|
||||||
|
direct_bare_write=False,
|
||||||
|
expected_hook="post-receive",
|
||||||
|
owner_approval_required=owner_approval_required,
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_plan(self, plan: GiteaPushPlan) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"valid": (
|
||||||
|
plan.direct_bare_write is False
|
||||||
|
and plan.push_method in self.ALLOWED_METHODS
|
||||||
|
and plan.branch.startswith(("patch/", "request/", "test/"))
|
||||||
|
),
|
||||||
|
"remote_url": plan.remote_url,
|
||||||
|
"branch": plan.branch,
|
||||||
|
"expected_hook": plan.expected_hook,
|
||||||
|
"direct_bare_write": plan.direct_bare_write,
|
||||||
|
"owner_approval_required": plan.owner_approval_required,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["GiteaPushPlan", "GiteaPushAdapter"]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Gitea Branch Index Repair Runbook
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Repair Gitea Web branch visibility when Git refs exist in the bare repository but
|
||||||
|
Gitea's branch index/table is stale.
|
||||||
|
|
||||||
|
## Confirmed root cause
|
||||||
|
|
||||||
|
- Gitea repository ROOT matches the GitOps bare repo path.
|
||||||
|
- Branch refs exist in the same bare repo.
|
||||||
|
- Gitea Web does not show some branches because the Gitea branch index/table is stale.
|
||||||
|
- Older GitOps writes likely bypassed Gitea hooks; newer writes through the Gitea HTTP gateway are visible.
|
||||||
|
|
||||||
|
## Important warning
|
||||||
|
|
||||||
|
A no-op push of an existing ref at the same commit may not trigger Gitea
|
||||||
|
post-receive hooks because Git can return `Everything up-to-date`. Do not rely on
|
||||||
|
a no-op push as the only repair mechanism.
|
||||||
|
|
||||||
|
## Preferred repair order
|
||||||
|
|
||||||
|
1. Back up the Gitea database.
|
||||||
|
2. Try Gitea official repository sync/rebuild/admin command if available.
|
||||||
|
3. If no official command is available, reconcile branch index records from Git refs:
|
||||||
|
- insert missing branch records only;
|
||||||
|
- do not delete existing records;
|
||||||
|
- do not change main;
|
||||||
|
- do not create tags;
|
||||||
|
- preserve current branch commit IDs.
|
||||||
|
4. Verify target branches are visible in Gitea Web.
|
||||||
|
5. Verify main HEAD remains unchanged.
|
||||||
|
|
||||||
|
## Target branches to verify
|
||||||
|
|
||||||
|
- `patch/user-portal-rbac-ops-dashboard`
|
||||||
|
- `request/tag-V1R3C0-self-loop-autonomous-engine`
|
||||||
|
|
||||||
|
## Long-term fix
|
||||||
|
|
||||||
|
Future GitOps writes must use the Gitea SSH/HTTP push path so Gitea native hooks
|
||||||
|
update branch indexes automatically. Direct bare repository writes should be
|
||||||
|
restricted to emergency/operator procedures.
|
||||||
|
|
||||||
|
## Forbidden actions
|
||||||
|
|
||||||
|
- Do not push main.
|
||||||
|
- Do not create release tags during this repair.
|
||||||
|
- Do not store secrets in Git.
|
||||||
|
- Do not rewrite branch history unless owner explicitly approves a separate plan.
|
||||||
|
- Do not change authentication/SSO configuration as part of this repair.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"type": "branch",
|
||||||
|
"branch": "patch/gitea-branch-index-sync",
|
||||||
|
"base": "main",
|
||||||
|
"created_by": "chatgpt-gitops-bot",
|
||||||
|
"created_at": "2026-07-08T05:19:40Z",
|
||||||
|
"direct_main_push": false,
|
||||||
|
"owner_approval_required": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from ops.gitea_branch_sync_supervisor import GiteaBranchSyncSupervisor
|
||||||
|
from ops.gitea_branch_index_sync import BranchIndexReport, BranchRef
|
||||||
|
from ops.gitea_push_adapter import GiteaPushAdapter
|
||||||
|
|
||||||
|
|
||||||
|
def test_branch_drift_detection():
|
||||||
|
report = BranchIndexReport(
|
||||||
|
git_branch_count=49,
|
||||||
|
indexed_branch_count=11,
|
||||||
|
missing_branches=[BranchRef("patch/user-portal-rbac-ops-dashboard", "ac5518588f98")],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = GiteaBranchSyncSupervisor().evaluate(report)
|
||||||
|
assert result["status"] == "drift_detected"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gitea_push_plan_uses_hooks():
|
||||||
|
plan = GiteaPushAdapter("ssh://g.cnnetmkt.top/aios/aios-v23.git").build_plan(
|
||||||
|
"patch/test-sync"
|
||||||
|
)
|
||||||
|
assert plan.direct_bare_write is False
|
||||||
|
assert plan.expected_hook == "post-receive"
|
||||||
Reference in New Issue
Block a user