syncestra 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- syncestra/__init__.py +14 -0
- syncestra/__main__.py +17 -0
- syncestra/adapters/__init__.py +5 -0
- syncestra/adapters/askpass_helper.sh +22 -0
- syncestra/adapters/classifier.py +111 -0
- syncestra/adapters/classifier_stub.py +20 -0
- syncestra/adapters/debounce.py +47 -0
- syncestra/adapters/docker_volume.py +76 -0
- syncestra/adapters/git_engine.py +1290 -0
- syncestra/adapters/git_ignore.py +196 -0
- syncestra/adapters/interfaces.py +175 -0
- syncestra/adapters/secrets.py +381 -0
- syncestra/adapters/watchman.py +119 -0
- syncestra/auth/__init__.py +5 -0
- syncestra/auth/github.py +124 -0
- syncestra/cli.py +623 -0
- syncestra/config.py +673 -0
- syncestra/core/__init__.py +10 -0
- syncestra/core/context.py +178 -0
- syncestra/core/init.py +73 -0
- syncestra/core/profile.py +95 -0
- syncestra/core/pull.py +157 -0
- syncestra/core/push.py +214 -0
- syncestra/core/restore.py +132 -0
- syncestra/core/status.py +47 -0
- syncestra/core/sync.py +90 -0
- syncestra/core/watch.py +229 -0
- syncestra/errors.py +117 -0
- syncestra/exit_codes.py +63 -0
- syncestra/logging.py +104 -0
- syncestra/mcp_server.py +360 -0
- syncestra/models.py +255 -0
- syncestra/renderers.py +107 -0
- syncestra/services/__init__.py +13 -0
- syncestra/services/claude_code.py +114 -0
- syncestra/services/registry.py +83 -0
- syncestra/services/spec.py +46 -0
- syncestra/state/__init__.py +14 -0
- syncestra/state/machine.py +222 -0
- syncestra-1.0.0.dist-info/METADATA +457 -0
- syncestra-1.0.0.dist-info/RECORD +43 -0
- syncestra-1.0.0.dist-info/WHEEL +4 -0
- syncestra-1.0.0.dist-info/entry_points.txt +5 -0
syncestra/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""syncestra — sync local service directories to private git mirrors.
|
|
2
|
+
|
|
3
|
+
Package root. Re-exports the frozen exit-code contract so downstream code and tests
|
|
4
|
+
can depend on stable, agent-facing codes. The CLI entry point lives in
|
|
5
|
+
:mod:`syncestra.cli`; result models in :mod:`syncestra.models`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
__version__ = "1.0.0"
|
|
11
|
+
|
|
12
|
+
from syncestra.exit_codes import ExitCode
|
|
13
|
+
|
|
14
|
+
__all__ = ["ExitCode", "__version__"]
|
syncestra/__main__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""``python -m syncestra`` entry point.
|
|
2
|
+
|
|
3
|
+
Delegates to the Typer app in :mod:`syncestra.cli`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from syncestra.cli import app
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
"""Run the syncestra CLI."""
|
|
13
|
+
app()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
main()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# syncestra GIT_ASKPASS helper (architecture §3, line 89: "PAT in credential
|
|
3
|
+
# helper"). Git invokes this once per credential prompt, passing the prompt
|
|
4
|
+
# text as $1. We answer with the token held in the SYNCESTRA_ASKPASS_TOKEN
|
|
5
|
+
# environment variable — the token rides the env of the single git invocation
|
|
6
|
+
# (set by _github_auth_env), never the remote URL, never .git/config, and never
|
|
7
|
+
# a process argument visible to other users on the host.
|
|
8
|
+
#
|
|
9
|
+
# Why an askpass (not a credential.helper store): a credential.helper would
|
|
10
|
+
# persist creds to ~/.git-credentials on disk; askpass is stateless and per-op,
|
|
11
|
+
# matching the architecture's "Never embed creds" rule. The token is read from
|
|
12
|
+
# the environment of the parent git process syncestra spawned, which is gone
|
|
13
|
+
# once the operation completes.
|
|
14
|
+
#
|
|
15
|
+
# This script is intentionally tiny and dependency-free (POSIX sh) so it runs
|
|
16
|
+
# anywhere git does. It must NEVER contain a token literal.
|
|
17
|
+
if [ -n "$SYNCESTRA_ASKPASS_TOKEN" ]; then
|
|
18
|
+
printf '%s\n' "$SYNCESTRA_ASKPASS_TOKEN"
|
|
19
|
+
exit 0
|
|
20
|
+
fi
|
|
21
|
+
# No token → exit non-zero so git does not hang waiting on a prompt.
|
|
22
|
+
exit 1
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""DefaultFileClassifier — rule-driven file bucketing (Story 3.1, FR-FILE-1).
|
|
2
|
+
|
|
3
|
+
Auto-classifies each file path into one of four sync buckets:
|
|
4
|
+
|
|
5
|
+
* ``config`` — settings, project docs (sync)
|
|
6
|
+
* ``session`` — recorded sessions / transcripts (sync)
|
|
7
|
+
* ``cache`` — logs, caches, ``node_modules`` (never sync)
|
|
8
|
+
* ``data`` — everything else, the syncable default (sync)
|
|
9
|
+
|
|
10
|
+
The classifier is **rule-driven and per-service**: each service spec supplies an
|
|
11
|
+
ordered list of :class:`ClassificationRule` (pattern + bucket). Rules are
|
|
12
|
+
evaluated in order and the **first match wins**; a path matching no rule falls
|
|
13
|
+
through to ``data``. Claude Code's rules live in
|
|
14
|
+
:mod:`syncestra.services.claude_code` (Story 3.2); the engine here is generic.
|
|
15
|
+
|
|
16
|
+
Patterns use gitignore-style matching (delegated to
|
|
17
|
+
:class:`~syncestra.adapters.git_ignore.SyncignoreParser` compilation rules) so a
|
|
18
|
+
service spec author writes the same glob syntax they already know: ``*.log``,
|
|
19
|
+
``sessions/*``, ``node_modules/**``, etc.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
|
|
27
|
+
from syncestra.adapters.interfaces import (
|
|
28
|
+
BUCKET_CACHE,
|
|
29
|
+
BUCKET_CONFIG,
|
|
30
|
+
BUCKET_DATA,
|
|
31
|
+
BUCKET_SESSION,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ClassificationRule:
|
|
37
|
+
"""One bucketing rule: a gitignore-style ``pattern`` mapping to a ``bucket``.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
pattern: A gitignore-style pattern (``*.log``, ``sessions/*``,
|
|
41
|
+
``node_modules/**``). Matching reuses the syncignore regex compiler
|
|
42
|
+
so the syntax is identical to ``.syncignore``.
|
|
43
|
+
bucket: One of the :class:`~syncestra.adapters.interfaces.FileBucket`
|
|
44
|
+
values (``config``/``session``/``cache``/``data``).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
pattern: str
|
|
48
|
+
bucket: str
|
|
49
|
+
|
|
50
|
+
def matches(self, path: str) -> bool:
|
|
51
|
+
"""Return True if ``path`` matches this rule's pattern."""
|
|
52
|
+
return _compile_pattern(self.pattern).search(path) is not None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class DefaultFileClassifier:
|
|
56
|
+
"""Rule-driven file classifier (FR-FILE-1; architecture §8).
|
|
57
|
+
|
|
58
|
+
Constructed with an ordered list of :class:`ClassificationRule`. The first
|
|
59
|
+
matching rule's bucket wins; a path matching no rule is ``data`` (syncable).
|
|
60
|
+
|
|
61
|
+
Satisfies the :class:`~syncestra.adapters.interfaces.FileClassifier`
|
|
62
|
+
Protocol so it slots into :class:`~syncestra.core.context.ServiceContext`.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, rules: list[ClassificationRule]) -> None:
|
|
66
|
+
self._compiled = [
|
|
67
|
+
(_compile_pattern(rule.pattern), rule.bucket) for rule in rules
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
def classify(self, path: str) -> str:
|
|
71
|
+
"""Return the bucket for ``path`` (first matching rule wins, else ``data``)."""
|
|
72
|
+
for regex, bucket in self._compiled:
|
|
73
|
+
if regex.search(path):
|
|
74
|
+
return bucket
|
|
75
|
+
return BUCKET_DATA
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Pattern compilation (reuses the syncignore → regex translation)
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _compile_pattern(pattern: str) -> re.Pattern[str]:
|
|
84
|
+
"""Compile a gitignore-style ``pattern`` into an anchored regex.
|
|
85
|
+
|
|
86
|
+
Delegates to the syncignore compiler so classification patterns use the exact
|
|
87
|
+
same glob syntax as ``.syncignore`` (``*``, ``**``, trailing-slash dirs,
|
|
88
|
+
anchoring on ``/``). Cached per-pattern for repeated classification.
|
|
89
|
+
"""
|
|
90
|
+
cached = _PATTERN_CACHE.get(pattern)
|
|
91
|
+
if cached is not None:
|
|
92
|
+
return cached
|
|
93
|
+
# Imported lazily to keep the module importable without git_ignore at load.
|
|
94
|
+
from syncestra.adapters.git_ignore import _compile as _sync_compile
|
|
95
|
+
|
|
96
|
+
compiled = _sync_compile(pattern)
|
|
97
|
+
_PATTERN_CACHE[pattern] = compiled
|
|
98
|
+
return compiled
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
_PATTERN_CACHE: dict[str, re.Pattern[str]] = {}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
__all__ = [
|
|
105
|
+
"BUCKET_CACHE",
|
|
106
|
+
"BUCKET_CONFIG",
|
|
107
|
+
"BUCKET_DATA",
|
|
108
|
+
"BUCKET_SESSION",
|
|
109
|
+
"ClassificationRule",
|
|
110
|
+
"DefaultFileClassifier",
|
|
111
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Stub file classifier for Epic 2 (replaced by DefaultFileClassifier in Epic 3).
|
|
2
|
+
|
|
3
|
+
Epic 2's plan/status pipelines need a non-None classifier slot, but the
|
|
4
|
+
rule-driven bucket logic is Story 3.1. This stub buckets every file as
|
|
5
|
+
``data`` (always syncable) so cache subtraction is a no-op until the real
|
|
6
|
+
classifier exists. Keeping it isolated means Epic 3 swaps it without touching
|
|
7
|
+
the git engine.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StubFileClassifier:
|
|
14
|
+
"""Buckets every file as ``data`` (syncable). Replaced in Epic 3."""
|
|
15
|
+
|
|
16
|
+
def classify(self, path: str) -> str: # noqa: ARG002 - slot contract
|
|
17
|
+
return "data"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
__all__ = ["StubFileClassifier"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Single-flight flock debounce for watch-fired pushes (auto-push epic).
|
|
2
|
+
|
|
3
|
+
watchman fires one event per file change. Without coalescing, a 10-file skill
|
|
4
|
+
install → 10 commits. This module provides trailing-edge debounce via a
|
|
5
|
+
non-blocking ``fcntl.flock``: the first invocation wins, sleeps the debounce
|
|
6
|
+
window, then runs the push; concurrent invocations find the lock held and exit
|
|
7
|
+
fast (coalesced). The winning push's ``git status`` sees every change in the
|
|
8
|
+
burst because they all landed before the sleep elapsed.
|
|
9
|
+
|
|
10
|
+
Filter logic (``.syncignore`` / classifier / exclude) lives in ``core.push`` —
|
|
11
|
+
this layer is deliberately dumb. If a fire contains only cache/ignored changes,
|
|
12
|
+
``core.push`` returns an idempotent no-op (H2 guard), so noise events cost one
|
|
13
|
+
``git status`` call and nothing more.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import fcntl
|
|
18
|
+
import os
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def default_lock_path(service: str) -> Path:
|
|
23
|
+
"""Per-service lockfile under ``~/.syncestra/watch.<service>.lock``."""
|
|
24
|
+
return Path.home() / ".syncestra" / f"watch.{service}.lock"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def try_acquire(lock_path: Path) -> tuple[bool, int]:
|
|
28
|
+
"""Non-blocking exclusive acquire.
|
|
29
|
+
|
|
30
|
+
Returns ``(won, fd)``. If ``won`` is True the caller holds the lock and
|
|
31
|
+
must call :func:`release` when done. If ``won`` is False another caller
|
|
32
|
+
holds it — the caller should exit 0 (coalesced); ``fd`` is -1.
|
|
33
|
+
"""
|
|
34
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR)
|
|
36
|
+
try:
|
|
37
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
38
|
+
except BlockingIOError:
|
|
39
|
+
os.close(fd)
|
|
40
|
+
return False, -1
|
|
41
|
+
return True, fd
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def release(fd: int) -> None:
|
|
45
|
+
"""Release a lock acquired via :func:`try_acquire`."""
|
|
46
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
47
|
+
os.close(fd)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Docker-volume StorageAdapter — v2 STUB only (Story 3.5, Arch-7, §11).
|
|
2
|
+
|
|
3
|
+
Implements the :class:`~syncestra.adapters.interfaces.StorageAdapter` Protocol
|
|
4
|
+
surface so the seam exists, but **every method raises** :class:`NotImplementedError`.
|
|
5
|
+
v1 ships only :class:`~syncestra.adapters.git_engine.GitStorageAdapter`; real
|
|
6
|
+
Docker-volume support (stop container → copy volume → start, then treat as a git
|
|
7
|
+
working tree) is v2+ scope (architecture §11 evolution table).
|
|
8
|
+
|
|
9
|
+
The stub lets config / tests reference a Docker-backed service without v1 having
|
|
10
|
+
to implement it — and makes the absence loud rather than silent.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING: # pragma: no cover - import cycle guard
|
|
18
|
+
from syncestra.core.context import ServiceContext
|
|
19
|
+
from syncestra.models import DiffPlan, PullResult, PushResult, RestoreResult, StatusResult
|
|
20
|
+
|
|
21
|
+
#: Message embedded in every NotImplementedError, pointing users to the v2 roadmap.
|
|
22
|
+
_DOCKER_V2_MESSAGE = (
|
|
23
|
+
"Docker-volume storage is not implemented in syncestra v1; "
|
|
24
|
+
"use the default git adapter or wait for v2."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DockerStorageAdapter:
|
|
29
|
+
"""v2 stub StorageAdapter for Docker-volume services (architecture §8, §11).
|
|
30
|
+
|
|
31
|
+
Satisfies the :class:`~syncestra.adapters.interfaces.StorageAdapter` Protocol
|
|
32
|
+
structurally (all methods are present) but raises on every call. v2 will
|
|
33
|
+
replace the bodies with real Docker-volume logic without changing the seam.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def _stub(self) -> Any:
|
|
37
|
+
raise NotImplementedError(_DOCKER_V2_MESSAGE)
|
|
38
|
+
|
|
39
|
+
def init(self, ctx: ServiceContext) -> InitResultType: # type: ignore[valid-type]
|
|
40
|
+
"""Create the local working repo (NOT IMPLEMENTED in v1)."""
|
|
41
|
+
self._stub() # pragma: no cover - raises before return
|
|
42
|
+
|
|
43
|
+
def plan(self, ctx: ServiceContext) -> DiffPlan:
|
|
44
|
+
"""Compute the staged candidate set (NOT IMPLEMENTED in v1)."""
|
|
45
|
+
self._stub() # pragma: no cover - raises before return
|
|
46
|
+
|
|
47
|
+
def commit_and_push(
|
|
48
|
+
self,
|
|
49
|
+
ctx: ServiceContext,
|
|
50
|
+
plan: DiffPlan,
|
|
51
|
+
message: str | None,
|
|
52
|
+
rollback_tag: str,
|
|
53
|
+
) -> PushResult:
|
|
54
|
+
"""Commit + push (NOT IMPLEMENTED in v1)."""
|
|
55
|
+
self._stub() # pragma: no cover - raises before return
|
|
56
|
+
|
|
57
|
+
def pull(self, ctx: ServiceContext) -> PullResult:
|
|
58
|
+
"""Pull from remote (NOT IMPLEMENTED in v1)."""
|
|
59
|
+
self._stub() # pragma: no cover - raises before return
|
|
60
|
+
|
|
61
|
+
def status(self, ctx: ServiceContext) -> StatusResult:
|
|
62
|
+
"""Return parity + changed files (NOT IMPLEMENTED in v1)."""
|
|
63
|
+
self._stub() # pragma: no cover - raises before return
|
|
64
|
+
|
|
65
|
+
def restore(self, ctx: ServiceContext) -> RestoreResult:
|
|
66
|
+
"""Restore from remote HEAD (NOT IMPLEMENTED in v1)."""
|
|
67
|
+
self._stub() # pragma: no cover - raises before return
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Type alias used only for the init return annotation (kept lazy to avoid an
|
|
71
|
+
# import cycle at module load).
|
|
72
|
+
if TYPE_CHECKING:
|
|
73
|
+
from syncestra.models import InitResult as InitResultType # noqa: F401
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
__all__ = ["DockerStorageAdapter"]
|