From 36c50290d19501f5b3f86d3a33bc717e26bada4c Mon Sep 17 00:00:00 2001 From: Vladimir Eremin Date: Fri, 6 Nov 2026 00:17:54 +0300 Subject: [PATCH] fix: synchronize node main directly without capture CI [skip ci] --- actions/sync/repository.py | 59 +++++++-------- tests/test_sync_repository.py | 133 ++++++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 32 deletions(-) create mode 100644 tests/test_sync_repository.py diff --git a/actions/sync/repository.py b/actions/sync/repository.py index b454146..d656def 100644 --- a/actions/sync/repository.py +++ b/actions/sync/repository.py @@ -1,64 +1,59 @@ -# actions/sync/repository.py -"""Port of sync/repository — capture-up commit + rebase onto origin/main + force-push.""" +"""Commit node configuration and synchronize main directly with its remote.""" import os from datetime import datetime from engine.descriptor import Meta, Arg from engine.identity import read_env -METADATA = Meta(summary="Capture-up: commit node changes, rebase onto origin/main, force-push sync/.", - args=[Arg("telegram_bot_url", "Telegram Bot URL for notifications")]) -TITLE = "Annual Remote Sync" +METADATA = Meta(summary="Commit node changes, rebase onto origin/main, and push main.", + args=[Arg("telegram_bot_url", "Optional notification URL", required=False)]) +TITLE = "Repository Sync" + def run(ctx, args): - log, node, url = ctx.log, ctx.node.name, args.telegram_bot_url + log, node = ctx.log, ctx.node.name + url = args.telegram_bot_url repo, commons = ctx.paths.repo_root, ctx.paths.commons - ne = read_env(os.path.join(repo, "node.env")) # per-node bot identity (generic defaults) + ne = read_env(os.path.join(repo, "node.env")) author = ne.get("APEX_GIT_AUTHOR_NAME", "apex [bot]") email = ne.get("APEX_GIT_AUTHOR_EMAIL", "apex@localhost") - env = dict(os.environ, - GIT_AUTHOR_NAME=author, GIT_AUTHOR_EMAIL=email, + env = dict(os.environ, GIT_AUTHOR_NAME=author, GIT_AUTHOR_EMAIL=email, GIT_COMMITTER_NAME=author, GIT_COMMITTER_EMAIL=email) - def git(*a, check=True): - return ctx.sys.run(["git", "-C", repo, *a], check=check, env=env) + def git(*a, check=True, capture=False): + return ctx.sys.run(["git", "-C", repo, *a], check=check, capture=capture, env=env) try: + branch = git("symbolic-ref", "--quiet", "--short", "HEAD", capture=True).stdout.strip() + if branch != "main": + raise RuntimeError("Repository sync requires main; switch branches explicitly first.") cp = ctx.sys.run(["git", "-C", commons, "status", "--porcelain"], capture=True) if (cp.stdout or "").strip(): - log.error(f"Commons submodule at {commons} has uncommitted changes. Aborting.") - raise SystemExit(1) + raise RuntimeError(f"Commons submodule at {commons} has uncommitted changes.") if not os.path.isdir(ctx.paths.compositions): - # Without this, `git add -A` would stage a deleted node tree and an - # unattended cron run would commit + force-push the deletion. - log.error(f"Directory '{ctx.paths.compositions}' does not exist. Cannot sync.") - raise SystemExit(1) - branch = f"sync/{node}" - ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - if ctx.sys.ok(["git", "-C", repo, "rev-parse", "--verify", branch]): - git("checkout", branch) - else: - git("checkout", "-b", branch) + raise RuntimeError(f"Directory '{ctx.paths.compositions}' does not exist.") + expected = git("rev-parse", "HEAD:commons", capture=True).stdout.strip() + actual = ctx.sys.run(["git", "-C", commons, "rev-parse", "HEAD"], capture=True).stdout.strip() + if actual != expected: + raise RuntimeError("Commons checkout differs from the committed pin; update the submodule before retrying.") git("add", "-A") - git("submodule", "update", "--init", "--recursive") - git("add", "commons") if git("diff", "--cached", "--quiet", check=False).returncode != 0: + ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") git("commit", "-m", f"[{node}] Auto-commit: {ts}") else: log.info("No changes to commit.") git("fetch", "origin", "main") if git("rebase", "origin/main", check=False).returncode != 0: - log.error("Rebase conflict — aborting rebase; manual intervention required.") git("rebase", "--abort", check=False) - ctx.notify.error(TITLE, url, "Rebase conflict. Manual intervention required.") - raise SystemExit(1) + raise RuntimeError("Rebase conflict; local commits retained for manual resolution.") git("submodule", "update", "--init", "--recursive") - git("push", "--force-with-lease", "origin", branch) - log.success(f"Pushed {branch}.") - if not ctx.notify.success(TITLE, url, f"Sync to '{branch}' completed."): + git("push", "origin", "HEAD:refs/heads/main") + log.success("Synchronized main.") + if url and not ctx.notify.success(TITLE, url, "Sync to main completed."): raise SystemExit(1) except SystemExit: raise except Exception as e: log.error(f"{type(e).__name__}: {e}") - ctx.notify.error(TITLE, url, f"Sync failed: {e}") + if url: + ctx.notify.error(TITLE, url, f"Sync failed: {e}") raise SystemExit(1) from e diff --git a/tests/test_sync_repository.py b/tests/test_sync_repository.py new file mode 100644 index 0000000..5f815f6 --- /dev/null +++ b/tests/test_sync_repository.py @@ -0,0 +1,133 @@ +import os +import subprocess +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock +from actions.sync import repository +from engine.lib.sys import Sys + + +class RepositorySyncTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.remote = self.root / "remote.git" + self.repo = self.root / "node" + self.env = dict(os.environ, GIT_AUTHOR_NAME="Test", GIT_AUTHOR_EMAIL="test@example.invalid", + GIT_COMMITTER_NAME="Test", GIT_COMMITTER_EMAIL="test@example.invalid", + GIT_ALLOW_PROTOCOL="file") + self.git("init", "--bare", str(self.remote)) + self.git("clone", str(self.remote), str(self.repo)) + self.git("checkout", "-b", "main", cwd=self.repo) + (self.repo / "compositions").mkdir() + (self.repo / "compositions/service").write_text("base\n") + self.commons_source = self.root / "shared" + self.git("init", str(self.commons_source)) + (self.commons_source / "file").write_text("shared\n") + self.git("add", ".", cwd=self.commons_source) + self.git("commit", "-m", "shared", cwd=self.commons_source) + self.git("submodule", "add", str(self.commons_source), "commons", cwd=self.repo) + self.git("add", ".", cwd=self.repo) + self.git("commit", "-m", "base", cwd=self.repo) + self.git("push", "origin", "main", cwd=self.repo) + self.log = Mock() + class Commands(Sys): + def run(inner, cmd, **kw): + kw["env"] = dict(self.env, **kw.get("env", {})) + kw["env"]["GIT_ALLOW_PROTOCOL"] = "file" + return super().run(cmd, **kw) + self.ctx = SimpleNamespace(log=self.log, sys=Commands(self.log), notify=Mock(), + node=SimpleNamespace(name="node"), + paths=SimpleNamespace(repo_root=str(self.repo), commons=str(self.repo / "commons"), + compositions=str(self.repo / "compositions"))) + self.args = SimpleNamespace(telegram_bot_url=None) + + def git(self, *args, cwd=None): + return subprocess.check_output(["git", *args], cwd=cwd, env=self.env, stderr=subprocess.PIPE, text=True).strip() + + def sync(self): + repository.run(self.ctx, self.args) + + def test_local_change_publishes_main_without_sync_branch_or_notification(self): + (self.repo / "compositions/service").write_text("changed\n") + self.sync() + self.assertEqual(self.git("--git-dir=" + str(self.remote), "show", "main:compositions/service"), "changed") + self.assertEqual(self.git("--git-dir=" + str(self.remote), "for-each-ref", "--format=%(refname)", "refs/heads"), "refs/heads/main") + self.ctx.notify.success.assert_not_called() + self.ctx.notify.error.assert_not_called() + + def test_noop_keeps_head(self): + before = self.git("rev-parse", "HEAD", cwd=self.repo) + self.sync() + self.assertEqual(before, self.git("rev-parse", "HEAD", cwd=self.repo)) + + def test_other_branch_does_not_stage_changes(self): + self.git("checkout", "-b", "work", cwd=self.repo) + (self.repo / "compositions/service").write_text("changed\n") + with self.assertRaises(SystemExit): + self.sync() + self.assertEqual(self.git("diff", "--cached", cwd=self.repo), "") + + def test_dirty_commons_blocks_parent_commit(self): + before = self.git("rev-parse", "HEAD", cwd=self.repo) + (self.repo / "commons/file").write_text("dirty\n") + with self.assertRaises(SystemExit): + self.sync() + self.assertEqual(before, self.git("rev-parse", "HEAD", cwd=self.repo)) + + def test_stale_submodule_after_failed_update_cannot_rollback_pin(self): + old = self.git("rev-parse", "HEAD", cwd=self.repo / "commons") + (self.commons_source / "file").write_text("new shared\n") + self.git("add", ".", cwd=self.commons_source) + self.git("commit", "-m", "new shared", cwd=self.commons_source) + new = self.git("rev-parse", "HEAD", cwd=self.commons_source) + self.git("-C", str(self.repo / "commons"), "fetch", "origin") + self.git("update-index", "--cacheinfo", "160000," + new + ",commons", cwd=self.repo) + self.git("commit", "-m", "advance pin", cwd=self.repo) + before = self.git("rev-parse", "HEAD", cwd=self.repo) + with self.assertRaises(SystemExit): + self.sync() + self.assertEqual(before, self.git("rev-parse", "HEAD", cwd=self.repo)) + self.assertEqual(old, self.git("rev-parse", "HEAD", cwd=self.repo / "commons")) + self.git("submodule", "update", "--init", cwd=self.repo) + self.sync() + self.assertEqual(new, self.git("--git-dir=" + str(self.remote), "rev-parse", "main:commons")) + + def test_concurrent_main_advance_rejects_push_without_overwriting(self): + original = self.ctx.sys.run + other = self.root / "other" + self.git("clone", "--branch", "main", str(self.remote), str(other)) + def race(cmd, **kw): + if "push" in cmd: + (other / "compositions/service").write_text("concurrent\n") + self.git("add", ".", cwd=other) + self.git("commit", "-m", "concurrent", cwd=other) + self.git("push", "origin", "main", cwd=other) + return original(cmd, **kw) + self.ctx.sys.run = race + (self.repo / "compositions/service").write_text("local\n") + with self.assertRaises(SystemExit): + self.sync() + self.assertEqual("concurrent", self.git("--git-dir=" + str(self.remote), "show", "main:compositions/service")) + self.assertEqual("local", self.git("show", "HEAD:compositions/service", cwd=self.repo)) + + def test_conflict_preserves_local_commit_and_remote(self): + other = self.root / "other" + self.git("clone", "--branch", "main", str(self.remote), str(other)) + (other / "compositions/service").write_text("remote\n") + self.git("add", ".", cwd=other) + self.git("commit", "-m", "remote", cwd=other) + self.git("push", "origin", "main", cwd=other) + (self.repo / "compositions/service").write_text("local\n") + with self.assertRaises(SystemExit): + self.sync() + self.assertEqual(self.git("show", "HEAD:compositions/service", cwd=self.repo), "local") + self.assertEqual(self.git("--git-dir=" + str(self.remote), "show", "main:compositions/service"), "remote") + self.assertFalse((self.repo / ".git/rebase-merge").exists()) + + +if __name__ == "__main__": + unittest.main()