granthi-sync v1: granthi-link provisioning service + client daemon + tests

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Nirav Patel
2026-08-19 00:09:19 -04:00
co-authored by Claude Fable 5
commit 2077591755
13 changed files with 1286 additions and 0 deletions
+197
View File
@@ -0,0 +1,197 @@
"""Unit tests for the granthi-sync client: autocommit / ff / diverged logic,
config handling, device-flow polling (mocked HTTP). Stdlib unittest only."""
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "client"))
# Point client config at a temp home BEFORE import side effects.
_TMP_HOME = tempfile.mkdtemp(prefix="granthi-test-home-")
os.environ["GRANTHI_SYNC_HOME"] = _TMP_HOME
import granthi_sync_client as client # noqa: E402
GIT_ENV = {
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
"HOME": _TMP_HOME, "PATH": os.environ["PATH"],
}
def run_git(cwd, *args):
return subprocess.run(["git", "-C", cwd] + list(args), check=True,
capture_output=True, text=True, env=GIT_ENV).stdout.strip()
class GitScenarioBase(unittest.TestCase):
"""bare 'cloud' repo + two working clones to simulate device vs remote."""
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="granthi-test-")
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
self.bare = os.path.join(self.tmp, "cloud.git")
subprocess.run(["git", "init", "--bare", "-b", "main", self.bare],
check=True, capture_output=True, env=GIT_ENV)
self.local = os.path.join(self.tmp, "local")
os.makedirs(self.local)
client.ensure_repo(self.local)
run_git(self.local, "remote", "add", "granthi", self.bare)
# git() in the client inherits our env via subprocess default; set
# identity locally in the repo so commits work.
run_git(self.local, "config", "user.name", "t")
run_git(self.local, "config", "user.email", "t@t")
def write(self, repo, name, content):
with open(os.path.join(repo, name), "w") as f:
f.write(content)
def other_clone(self):
other = os.path.join(self.tmp, "other")
subprocess.run(["git", "clone", self.bare, other], check=True,
capture_output=True, env=GIT_ENV)
run_git(other, "config", "user.name", "o")
run_git(other, "config", "user.email", "o@o")
return other
class TestAutocommit(GitScenarioBase):
def test_autocommit_commits_changes(self):
self.write(self.local, "a.txt", "one")
self.assertTrue(client.autocommit(self.local))
msg = run_git(self.local, "log", "-1", "--format=%s")
self.assertTrue(msg.startswith("sync: "), msg)
def test_autocommit_noop_when_clean(self):
self.write(self.local, "a.txt", "one")
client.autocommit(self.local)
self.assertFalse(client.autocommit(self.local))
class TestSyncFolder(GitScenarioBase):
def test_initial_push(self):
self.write(self.local, "a.txt", "one")
outcome, _ = client.sync_folder(self.local)
self.assertEqual(outcome, "pushed")
self.assertIn("a.txt", run_git(self.local, "ls-tree", "--name-only",
"granthi/main"))
def test_ff_pull_when_remote_ahead(self):
self.write(self.local, "a.txt", "one")
client.sync_folder(self.local)
other = self.other_clone()
self.write(other, "b.txt", "from-other")
run_git(other, "add", "-A")
run_git(other, "commit", "-m", "remote change")
run_git(other, "push", "origin", "main")
outcome, detail = client.sync_folder(self.local)
self.assertEqual((outcome, detail), ("synced", "ff-pulled"))
self.assertTrue(os.path.exists(os.path.join(self.local, "b.txt")))
def test_diverged_is_skipped_never_forced(self):
self.write(self.local, "a.txt", "one")
client.sync_folder(self.local)
other = self.other_clone()
self.write(other, "b.txt", "remote side")
run_git(other, "add", "-A")
run_git(other, "commit", "-m", "remote change")
run_git(other, "push", "origin", "main")
remote_sha = run_git(other, "rev-parse", "HEAD")
self.write(self.local, "a.txt", "local side") # divergence
outcome, _ = client.sync_folder(self.local)
self.assertEqual(outcome, "diverged")
# remote must be untouched (not forced, not merged)
bare_sha = run_git(self.bare, "rev-parse", "main")
self.assertEqual(bare_sha, remote_sha)
def test_clean_when_in_sync(self):
self.write(self.local, "a.txt", "one")
client.sync_folder(self.local)
outcome, _ = client.sync_folder(self.local)
self.assertEqual(outcome, "clean")
class TestConfig(unittest.TestCase):
def test_save_creates_0600(self):
client.save_config({"login": "x", "folders": {}})
st = os.stat(client.CONFIG_PATH)
self.assertEqual(st.st_mode & 0o777, 0o600)
self.assertEqual(client.load_config()["login"], "x")
def test_load_missing_returns_empty(self):
with mock.patch.object(client, "CONFIG_PATH", "/nonexistent/nope.json"):
self.assertEqual(client.load_config(), {})
class TestDeviceFlow(unittest.TestCase):
def test_device_flow_polls_until_token(self):
calls = []
def fake_http(method, url, headers=None, body=None, form=None, timeout=30):
calls.append(url)
if url.endswith("/device_authorization"):
return 200, {"device_code": "dc", "user_code": "AB-CD",
"verification_uri": "https://id/device",
"verification_uri_complete": "https://id/device?u=AB-CD",
"interval": 0, "expires_in": 300}
if len([c for c in calls if c.endswith("/token")]) < 3:
return 400, {"error": "authorization_pending"}
return 200, {"access_token": "ZTOK"}
with mock.patch.object(client, "http_json", fake_http), \
mock.patch.object(client.time, "sleep"):
tok = client.device_flow()
self.assertEqual(tok, "ZTOK")
self.assertEqual(len([c for c in calls if c.endswith("/token")]), 3)
def test_device_flow_slow_down_backs_off(self):
state = {"n": 0}
def fake_http(method, url, headers=None, body=None, form=None, timeout=30):
if url.endswith("/device_authorization"):
return 200, {"device_code": "dc", "user_code": "AB",
"verification_uri": "u", "interval": 1,
"expires_in": 300}
state["n"] += 1
if state["n"] == 1:
return 400, {"error": "slow_down"}
return 200, {"access_token": "T"}
sleeps = []
with mock.patch.object(client, "http_json", fake_http), \
mock.patch.object(client.time, "sleep", sleeps.append):
self.assertEqual(client.device_flow(), "T")
self.assertIn(6, sleeps) # 1 + 5 backoff after slow_down
class TestCredentialHelper(GitScenarioBase):
def test_helper_emits_creds_for_matching_host(self):
client.save_config({"gitea_base": "http://100.111.127.127:3041",
"login": "alice", "token": "sekrit", "folders": {}})
stdin = "protocol=http\nhost=100.111.127.127:3041\n\n"
out = subprocess.run(
[sys.executable, client.__file__, "git-credential", "get"],
input=stdin, capture_output=True, text=True,
env={**GIT_ENV, "GRANTHI_SYNC_HOME": _TMP_HOME})
self.assertIn("username=alice", out.stdout)
self.assertIn("password=sekrit", out.stdout)
def test_helper_silent_for_other_host(self):
client.save_config({"gitea_base": "http://100.111.127.127:3041",
"login": "alice", "token": "sekrit", "folders": {}})
out = subprocess.run(
[sys.executable, client.__file__, "git-credential", "get"],
input="protocol=https\nhost=github.com\n\n",
capture_output=True, text=True,
env={**GIT_ENV, "GRANTHI_SYNC_HOME": _TMP_HOME})
self.assertNotIn("password=", out.stdout)
if __name__ == "__main__":
unittest.main()