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.
Files changed (43) hide show
  1. syncestra/__init__.py +14 -0
  2. syncestra/__main__.py +17 -0
  3. syncestra/adapters/__init__.py +5 -0
  4. syncestra/adapters/askpass_helper.sh +22 -0
  5. syncestra/adapters/classifier.py +111 -0
  6. syncestra/adapters/classifier_stub.py +20 -0
  7. syncestra/adapters/debounce.py +47 -0
  8. syncestra/adapters/docker_volume.py +76 -0
  9. syncestra/adapters/git_engine.py +1290 -0
  10. syncestra/adapters/git_ignore.py +196 -0
  11. syncestra/adapters/interfaces.py +175 -0
  12. syncestra/adapters/secrets.py +381 -0
  13. syncestra/adapters/watchman.py +119 -0
  14. syncestra/auth/__init__.py +5 -0
  15. syncestra/auth/github.py +124 -0
  16. syncestra/cli.py +623 -0
  17. syncestra/config.py +673 -0
  18. syncestra/core/__init__.py +10 -0
  19. syncestra/core/context.py +178 -0
  20. syncestra/core/init.py +73 -0
  21. syncestra/core/profile.py +95 -0
  22. syncestra/core/pull.py +157 -0
  23. syncestra/core/push.py +214 -0
  24. syncestra/core/restore.py +132 -0
  25. syncestra/core/status.py +47 -0
  26. syncestra/core/sync.py +90 -0
  27. syncestra/core/watch.py +229 -0
  28. syncestra/errors.py +117 -0
  29. syncestra/exit_codes.py +63 -0
  30. syncestra/logging.py +104 -0
  31. syncestra/mcp_server.py +360 -0
  32. syncestra/models.py +255 -0
  33. syncestra/renderers.py +107 -0
  34. syncestra/services/__init__.py +13 -0
  35. syncestra/services/claude_code.py +114 -0
  36. syncestra/services/registry.py +83 -0
  37. syncestra/services/spec.py +46 -0
  38. syncestra/state/__init__.py +14 -0
  39. syncestra/state/machine.py +222 -0
  40. syncestra-1.0.0.dist-info/METADATA +457 -0
  41. syncestra-1.0.0.dist-info/RECORD +43 -0
  42. syncestra-1.0.0.dist-info/WHEEL +4 -0
  43. 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,5 @@
1
+ """Adapters package — pluggable ABCs and concrete implementations.
2
+
3
+ Epic 1 ships only the ``.syncignore`` parser (:mod:`syncestra.adapters.git_ignore`).
4
+ The classifier, secret scanner, git engine, and docker stub land in later epics.
5
+ """
@@ -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"]