tinyhorse 0.1.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.
- tinyhorse/__init__.py +3 -0
- tinyhorse/__main__.py +3 -0
- tinyhorse/auth.py +288 -0
- tinyhorse/cli.py +180 -0
- tinyhorse/config.py +65 -0
- tinyhorse/git.py +148 -0
- tinyhorse/storage.py +571 -0
- tinyhorse/workflow.py +726 -0
- tinyhorse-0.1.0.dist-info/METADATA +185 -0
- tinyhorse-0.1.0.dist-info/RECORD +14 -0
- tinyhorse-0.1.0.dist-info/WHEEL +5 -0
- tinyhorse-0.1.0.dist-info/entry_points.txt +2 -0
- tinyhorse-0.1.0.dist-info/licenses/LICENSE +201 -0
- tinyhorse-0.1.0.dist-info/top_level.txt +1 -0
tinyhorse/__init__.py
ADDED
tinyhorse/__main__.py
ADDED
tinyhorse/auth.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
GOOGLE_DRIVE_SCOPE = "https://www.googleapis.com/auth/drive"
|
|
12
|
+
TOKEN_FILE = "google-drive-credentials.json"
|
|
13
|
+
CLIENT_FILE = "google-client.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AuthError(RuntimeError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class AuthStatus:
|
|
22
|
+
provider: str
|
|
23
|
+
authenticated: bool
|
|
24
|
+
source: str | None
|
|
25
|
+
credentials_path: str | None
|
|
26
|
+
expires_at: str | None
|
|
27
|
+
scopes: list[str]
|
|
28
|
+
details: list[str]
|
|
29
|
+
|
|
30
|
+
def to_json(self) -> str:
|
|
31
|
+
return json.dumps(asdict(self), indent=2, sort_keys=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def config_dir() -> Path:
|
|
35
|
+
override = os.environ.get("TINYHORSE_CONFIG_HOME")
|
|
36
|
+
if override:
|
|
37
|
+
return Path(override).expanduser().resolve()
|
|
38
|
+
|
|
39
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
40
|
+
if xdg:
|
|
41
|
+
return (Path(xdg).expanduser() / "tinyhorse").resolve()
|
|
42
|
+
|
|
43
|
+
appdata = os.environ.get("APPDATA")
|
|
44
|
+
if os.name == "nt" and appdata:
|
|
45
|
+
return (Path(appdata).expanduser() / "TinyHorse").resolve()
|
|
46
|
+
|
|
47
|
+
return (Path.home() / ".config" / "tinyhorse").resolve()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def credentials_path() -> Path:
|
|
51
|
+
return config_dir() / TOKEN_FILE
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def default_client_secrets_path() -> Path:
|
|
55
|
+
return config_dir() / CLIENT_FILE
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def packaged_client_secrets_path() -> Path:
|
|
59
|
+
return Path(__file__).with_name(CLIENT_FILE)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def resolve_client_secrets_path(explicit: str | Path | None = None) -> Path:
|
|
63
|
+
candidates: list[Path] = []
|
|
64
|
+
if explicit is not None:
|
|
65
|
+
candidates.append(Path(explicit).expanduser())
|
|
66
|
+
env = os.environ.get("TINYHORSE_GOOGLE_CLIENT_SECRETS")
|
|
67
|
+
if env:
|
|
68
|
+
candidates.append(Path(env).expanduser())
|
|
69
|
+
candidates.append(default_client_secrets_path())
|
|
70
|
+
candidates.append(packaged_client_secrets_path())
|
|
71
|
+
|
|
72
|
+
for candidate in candidates:
|
|
73
|
+
if candidate.is_file():
|
|
74
|
+
return candidate.resolve()
|
|
75
|
+
|
|
76
|
+
looked = ", ".join(str(path) for path in candidates)
|
|
77
|
+
raise AuthError(
|
|
78
|
+
"Google Drive OAuth client configuration not found. "
|
|
79
|
+
"Pass --client-secrets PATH, set TINYHORSE_GOOGLE_CLIENT_SECRETS, "
|
|
80
|
+
f"or place the file at {default_client_secrets_path()}. Looked in: {looked}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _google_modules() -> tuple[Any, Any, Any]:
|
|
85
|
+
try:
|
|
86
|
+
from google.auth.transport.requests import Request
|
|
87
|
+
from google.oauth2.credentials import Credentials
|
|
88
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
89
|
+
except ImportError as exc:
|
|
90
|
+
raise AuthError(
|
|
91
|
+
"Google Drive login support is not installed. "
|
|
92
|
+
"Install it with: pip install 'tinyhorse[gdrive]'"
|
|
93
|
+
) from exc
|
|
94
|
+
return Credentials, Request, InstalledAppFlow
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _remember_client_secrets(source: Path) -> Path:
|
|
98
|
+
destination = default_client_secrets_path()
|
|
99
|
+
if source.resolve() == destination.resolve():
|
|
100
|
+
return destination
|
|
101
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
tmp = destination.with_suffix(destination.suffix + ".tmp")
|
|
103
|
+
tmp.write_bytes(source.read_bytes())
|
|
104
|
+
try:
|
|
105
|
+
os.chmod(tmp, 0o600)
|
|
106
|
+
except OSError:
|
|
107
|
+
pass
|
|
108
|
+
os.replace(tmp, destination)
|
|
109
|
+
return destination
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _write_credentials(credentials: Any) -> Path:
|
|
113
|
+
path = credentials_path()
|
|
114
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
116
|
+
tmp.write_text(credentials.to_json(), encoding="utf-8")
|
|
117
|
+
try:
|
|
118
|
+
os.chmod(tmp, 0o600)
|
|
119
|
+
except OSError:
|
|
120
|
+
pass
|
|
121
|
+
os.replace(tmp, path)
|
|
122
|
+
return path
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _expiry_iso(credentials: Any) -> str | None:
|
|
126
|
+
expiry = getattr(credentials, "expiry", None)
|
|
127
|
+
if expiry is None:
|
|
128
|
+
return None
|
|
129
|
+
if expiry.tzinfo is None:
|
|
130
|
+
expiry = expiry.replace(tzinfo=timezone.utc)
|
|
131
|
+
return expiry.isoformat()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def login_google_drive(
|
|
135
|
+
client_secrets: str | Path | None = None,
|
|
136
|
+
*,
|
|
137
|
+
open_browser: bool = True,
|
|
138
|
+
port: int = 0,
|
|
139
|
+
) -> AuthStatus:
|
|
140
|
+
"""Run Google's installed-app OAuth flow and save refreshable credentials."""
|
|
141
|
+
_, _, InstalledAppFlow = _google_modules()
|
|
142
|
+
client_path = resolve_client_secrets_path(client_secrets)
|
|
143
|
+
flow = InstalledAppFlow.from_client_secrets_file(
|
|
144
|
+
str(client_path), scopes=[GOOGLE_DRIVE_SCOPE]
|
|
145
|
+
)
|
|
146
|
+
remembered_client_path = _remember_client_secrets(client_path)
|
|
147
|
+
credentials = flow.run_local_server(
|
|
148
|
+
host="localhost",
|
|
149
|
+
port=port,
|
|
150
|
+
open_browser=open_browser,
|
|
151
|
+
authorization_prompt_message=(
|
|
152
|
+
"Open this URL in a browser to authorize Tiny Horse:\n{url}"
|
|
153
|
+
),
|
|
154
|
+
success_message="Tiny Horse is authorized. You can close this browser window.",
|
|
155
|
+
)
|
|
156
|
+
path = _write_credentials(credentials)
|
|
157
|
+
return AuthStatus(
|
|
158
|
+
provider="google-drive",
|
|
159
|
+
authenticated=True,
|
|
160
|
+
source="saved-oauth",
|
|
161
|
+
credentials_path=str(path),
|
|
162
|
+
expires_at=_expiry_iso(credentials),
|
|
163
|
+
scopes=list(getattr(credentials, "scopes", None) or [GOOGLE_DRIVE_SCOPE]),
|
|
164
|
+
details=[f"OAuth client configuration saved at: {remembered_client_path}"],
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _load_saved_credentials(*, refresh: bool = True) -> Any:
|
|
169
|
+
Credentials, Request, _ = _google_modules()
|
|
170
|
+
path = credentials_path()
|
|
171
|
+
if not path.is_file():
|
|
172
|
+
raise AuthError(
|
|
173
|
+
"Google Drive is not authenticated. Run: tinyhorse auth login"
|
|
174
|
+
)
|
|
175
|
+
try:
|
|
176
|
+
credentials = Credentials.from_authorized_user_file(
|
|
177
|
+
str(path), scopes=[GOOGLE_DRIVE_SCOPE]
|
|
178
|
+
)
|
|
179
|
+
except (ValueError, json.JSONDecodeError, OSError) as exc:
|
|
180
|
+
raise AuthError(f"could not read saved Google Drive credentials: {exc}") from exc
|
|
181
|
+
|
|
182
|
+
if refresh and not credentials.valid:
|
|
183
|
+
if credentials.expired and credentials.refresh_token:
|
|
184
|
+
try:
|
|
185
|
+
credentials.refresh(Request())
|
|
186
|
+
except Exception as exc: # google-auth raises several transport/auth types
|
|
187
|
+
raise AuthError(f"could not refresh Google Drive credentials: {exc}") from exc
|
|
188
|
+
_write_credentials(credentials)
|
|
189
|
+
else:
|
|
190
|
+
raise AuthError(
|
|
191
|
+
"saved Google Drive credentials are not usable. Run: tinyhorse auth login"
|
|
192
|
+
)
|
|
193
|
+
return credentials
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def google_drive_access_token() -> str:
|
|
197
|
+
"""Resolve an access token, refreshing saved OAuth credentials when needed.
|
|
198
|
+
|
|
199
|
+
The legacy environment variable remains supported for automation and CI, but
|
|
200
|
+
interactive users should normally run `tinyhorse auth login` once.
|
|
201
|
+
"""
|
|
202
|
+
env_token = os.environ.get("TINYHORSE_GOOGLE_DRIVE_TOKEN")
|
|
203
|
+
if env_token:
|
|
204
|
+
return env_token
|
|
205
|
+
credentials = _load_saved_credentials(refresh=True)
|
|
206
|
+
token = getattr(credentials, "token", None)
|
|
207
|
+
if not token:
|
|
208
|
+
raise AuthError("Google Drive credentials did not yield an access token")
|
|
209
|
+
return token
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def google_drive_auth_status(*, refresh: bool = False) -> AuthStatus:
|
|
213
|
+
env_token = os.environ.get("TINYHORSE_GOOGLE_DRIVE_TOKEN")
|
|
214
|
+
if env_token:
|
|
215
|
+
return AuthStatus(
|
|
216
|
+
provider="google-drive",
|
|
217
|
+
authenticated=True,
|
|
218
|
+
source="environment-token",
|
|
219
|
+
credentials_path=None,
|
|
220
|
+
expires_at=None,
|
|
221
|
+
scopes=[],
|
|
222
|
+
details=["TINYHORSE_GOOGLE_DRIVE_TOKEN is set"],
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
path = credentials_path()
|
|
226
|
+
if not path.is_file():
|
|
227
|
+
return AuthStatus(
|
|
228
|
+
provider="google-drive",
|
|
229
|
+
authenticated=False,
|
|
230
|
+
source=None,
|
|
231
|
+
credentials_path=str(path),
|
|
232
|
+
expires_at=None,
|
|
233
|
+
scopes=[],
|
|
234
|
+
details=["Run: tinyhorse auth login"],
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
credentials = _load_saved_credentials(refresh=refresh)
|
|
239
|
+
except AuthError as exc:
|
|
240
|
+
return AuthStatus(
|
|
241
|
+
provider="google-drive",
|
|
242
|
+
authenticated=False,
|
|
243
|
+
source="saved-oauth",
|
|
244
|
+
credentials_path=str(path),
|
|
245
|
+
expires_at=None,
|
|
246
|
+
scopes=[],
|
|
247
|
+
details=[str(exc)],
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
valid = bool(getattr(credentials, "valid", False))
|
|
251
|
+
expired = bool(getattr(credentials, "expired", False))
|
|
252
|
+
refresh_token = bool(getattr(credentials, "refresh_token", None))
|
|
253
|
+
authenticated = valid or (expired and refresh_token and not refresh)
|
|
254
|
+
details: list[str] = []
|
|
255
|
+
if expired and refresh_token and not refresh:
|
|
256
|
+
details.append("access token is expired but a refresh token is available")
|
|
257
|
+
elif not valid:
|
|
258
|
+
details.append("saved credentials are not currently valid")
|
|
259
|
+
|
|
260
|
+
return AuthStatus(
|
|
261
|
+
provider="google-drive",
|
|
262
|
+
authenticated=authenticated,
|
|
263
|
+
source="saved-oauth",
|
|
264
|
+
credentials_path=str(path),
|
|
265
|
+
expires_at=_expiry_iso(credentials),
|
|
266
|
+
scopes=list(getattr(credentials, "scopes", None) or [GOOGLE_DRIVE_SCOPE]),
|
|
267
|
+
details=details,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def logout_google_drive() -> AuthStatus:
|
|
272
|
+
path = credentials_path()
|
|
273
|
+
removed = False
|
|
274
|
+
if path.exists():
|
|
275
|
+
path.unlink()
|
|
276
|
+
removed = True
|
|
277
|
+
return AuthStatus(
|
|
278
|
+
provider="google-drive",
|
|
279
|
+
authenticated=False,
|
|
280
|
+
source=None,
|
|
281
|
+
credentials_path=str(path),
|
|
282
|
+
expires_at=None,
|
|
283
|
+
scopes=[],
|
|
284
|
+
details=[
|
|
285
|
+
"saved credentials removed" if removed else "no saved credentials were present",
|
|
286
|
+
"This removes Tiny Horse's local credential cache; it does not revoke access in your Google account.",
|
|
287
|
+
],
|
|
288
|
+
)
|
tinyhorse/cli.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from . import __version__
|
|
7
|
+
|
|
8
|
+
from .auth import AuthError, google_drive_auth_status, login_google_drive, logout_google_drive
|
|
9
|
+
from .config import ConfigError
|
|
10
|
+
from .git import GitError, create_bundle, repository_state, verify_bundle
|
|
11
|
+
from .storage import StorageError
|
|
12
|
+
from .workflow import (
|
|
13
|
+
WorkflowError,
|
|
14
|
+
clone_project,
|
|
15
|
+
doctor,
|
|
16
|
+
init_drive_project,
|
|
17
|
+
init_project,
|
|
18
|
+
migrate_remote,
|
|
19
|
+
projects,
|
|
20
|
+
pull,
|
|
21
|
+
push,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parser() -> argparse.ArgumentParser:
|
|
26
|
+
parser = argparse.ArgumentParser(prog="tinyhorse")
|
|
27
|
+
parser.add_argument("--version", action="version", version=f"tinyhorse {__version__}")
|
|
28
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
29
|
+
|
|
30
|
+
status = sub.add_parser("status", help="print local Git state as JSON")
|
|
31
|
+
status.add_argument("path", nargs="?", default=".")
|
|
32
|
+
|
|
33
|
+
init = sub.add_parser("init", help="bind an existing Git repo to canonical storage")
|
|
34
|
+
init.add_argument("path", nargs="?", default=".")
|
|
35
|
+
init_storage = init.add_mutually_exclusive_group(required=True)
|
|
36
|
+
init_storage.add_argument("--storage", help="explicit storage locator")
|
|
37
|
+
init_storage.add_argument(
|
|
38
|
+
"--drive",
|
|
39
|
+
action="store_true",
|
|
40
|
+
help="find or create this project's Google Drive storage automatically",
|
|
41
|
+
)
|
|
42
|
+
init.add_argument("--project", help="project display name; defaults to repository directory name")
|
|
43
|
+
|
|
44
|
+
push_cmd = sub.add_parser("push", help="safely publish a verified canonical bundle")
|
|
45
|
+
push_cmd.add_argument("path", nargs="?", default=".")
|
|
46
|
+
push_cmd.add_argument(
|
|
47
|
+
"--force",
|
|
48
|
+
action="store_true",
|
|
49
|
+
help="allow intentional non-fast-forward history/ref changes; concurrent storage changes still abort",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
pull_cmd = sub.add_parser("pull", help="restore a repository from canonical storage")
|
|
53
|
+
pull_cmd.add_argument("storage", help="file:... or gdrive://FOLDER_ID/FILENAME")
|
|
54
|
+
pull_cmd.add_argument("destination")
|
|
55
|
+
|
|
56
|
+
doctor_cmd = sub.add_parser("doctor", help="compare local state with canonical storage")
|
|
57
|
+
doctor_cmd.add_argument("path", nargs="?", default=".")
|
|
58
|
+
|
|
59
|
+
projects_cmd = sub.add_parser(
|
|
60
|
+
"projects", help="list discoverable Tiny Horse projects on Google Drive"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
clone_cmd = sub.add_parser(
|
|
64
|
+
"clone", help="discover and restore a Tiny Horse project by name"
|
|
65
|
+
)
|
|
66
|
+
clone_cmd.add_argument("project")
|
|
67
|
+
clone_cmd.add_argument("destination", nargs="?")
|
|
68
|
+
|
|
69
|
+
bundle = sub.add_parser("bundle", help="create a complete Git bundle")
|
|
70
|
+
bundle.add_argument("path")
|
|
71
|
+
bundle.add_argument("output")
|
|
72
|
+
|
|
73
|
+
verify = sub.add_parser("verify-bundle", help="verify a Git bundle")
|
|
74
|
+
verify.add_argument("bundle")
|
|
75
|
+
|
|
76
|
+
move = sub.add_parser(
|
|
77
|
+
"migrate",
|
|
78
|
+
help="migrate a Git remote into Tiny Horse canonical storage",
|
|
79
|
+
)
|
|
80
|
+
move.add_argument("remote")
|
|
81
|
+
move.add_argument(
|
|
82
|
+
"destination",
|
|
83
|
+
nargs="?",
|
|
84
|
+
help="local checkout destination; defaults to the repository name",
|
|
85
|
+
)
|
|
86
|
+
move.add_argument("--project", help="project display name; defaults to repository name")
|
|
87
|
+
migrate_storage = move.add_mutually_exclusive_group()
|
|
88
|
+
migrate_storage.add_argument(
|
|
89
|
+
"--drive",
|
|
90
|
+
action="store_true",
|
|
91
|
+
help="auto-provision Google Drive storage (the default)",
|
|
92
|
+
)
|
|
93
|
+
migrate_storage.add_argument(
|
|
94
|
+
"--storage",
|
|
95
|
+
help="explicit canonical storage locator instead of Google Drive",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
auth = sub.add_parser("auth", help="manage storage-provider authentication")
|
|
99
|
+
auth_sub = auth.add_subparsers(dest="auth_command", required=True)
|
|
100
|
+
|
|
101
|
+
auth_login = auth_sub.add_parser("login", help="sign in to Google Drive")
|
|
102
|
+
auth_login.add_argument(
|
|
103
|
+
"--client-secrets",
|
|
104
|
+
help="Google OAuth desktop-app client JSON; defaults to configured locations",
|
|
105
|
+
)
|
|
106
|
+
auth_login.add_argument(
|
|
107
|
+
"--no-browser",
|
|
108
|
+
action="store_true",
|
|
109
|
+
help="print the authorization URL instead of opening a browser",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
auth_status = auth_sub.add_parser("status", help="show Google Drive authentication state")
|
|
113
|
+
auth_status.add_argument(
|
|
114
|
+
"--refresh",
|
|
115
|
+
action="store_true",
|
|
116
|
+
help="refresh an expired access token while checking status",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
auth_sub.add_parser("logout", help="remove Tiny Horse's saved Google Drive credentials")
|
|
120
|
+
|
|
121
|
+
return parser
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def main(argv: list[str] | None = None) -> int:
|
|
125
|
+
args = _parser().parse_args(argv)
|
|
126
|
+
try:
|
|
127
|
+
if args.command == "status":
|
|
128
|
+
print(repository_state(args.path).to_json())
|
|
129
|
+
elif args.command == "init":
|
|
130
|
+
if args.drive:
|
|
131
|
+
print(init_drive_project(args.path, args.project))
|
|
132
|
+
else:
|
|
133
|
+
print(init_project(args.path, args.storage, args.project))
|
|
134
|
+
elif args.command == "push":
|
|
135
|
+
print(push(args.path, force=args.force).to_json())
|
|
136
|
+
elif args.command == "pull":
|
|
137
|
+
print(pull(args.storage, args.destination).to_json())
|
|
138
|
+
elif args.command == "doctor":
|
|
139
|
+
result = doctor(args.path)
|
|
140
|
+
print(result.to_json())
|
|
141
|
+
return 0 if result.ok else 1
|
|
142
|
+
elif args.command == "projects":
|
|
143
|
+
print(projects().to_json())
|
|
144
|
+
elif args.command == "clone":
|
|
145
|
+
print(clone_project(args.project, args.destination).to_json())
|
|
146
|
+
elif args.command == "bundle":
|
|
147
|
+
print(create_bundle(args.path, args.output))
|
|
148
|
+
elif args.command == "verify-bundle":
|
|
149
|
+
print(verify_bundle(args.bundle))
|
|
150
|
+
elif args.command == "migrate":
|
|
151
|
+
print(
|
|
152
|
+
migrate_remote(
|
|
153
|
+
args.remote,
|
|
154
|
+
args.destination,
|
|
155
|
+
project=args.project,
|
|
156
|
+
storage=args.storage,
|
|
157
|
+
).to_json()
|
|
158
|
+
)
|
|
159
|
+
elif args.command == "auth":
|
|
160
|
+
if args.auth_command == "login":
|
|
161
|
+
print(
|
|
162
|
+
login_google_drive(
|
|
163
|
+
args.client_secrets,
|
|
164
|
+
open_browser=not args.no_browser,
|
|
165
|
+
).to_json()
|
|
166
|
+
)
|
|
167
|
+
elif args.auth_command == "status":
|
|
168
|
+
status = google_drive_auth_status(refresh=args.refresh)
|
|
169
|
+
print(status.to_json())
|
|
170
|
+
return 0 if status.authenticated else 1
|
|
171
|
+
elif args.auth_command == "logout":
|
|
172
|
+
print(logout_google_drive().to_json())
|
|
173
|
+
return 0
|
|
174
|
+
except (AuthError, GitError, ConfigError, StorageError, WorkflowError) as exc:
|
|
175
|
+
print(f"tinyhorse: {exc}", file=sys.stderr)
|
|
176
|
+
return 2
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__":
|
|
180
|
+
raise SystemExit(main())
|
tinyhorse/config.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import tomllib
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
CONFIG_NAME = ".tinyhorse.toml"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ConfigError(RuntimeError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ProjectConfig:
|
|
17
|
+
project: str
|
|
18
|
+
storage: str
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def storage_scheme(self) -> str:
|
|
22
|
+
return self.storage.split(":", 1)[0]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def config_path(repo: str | Path) -> Path:
|
|
26
|
+
return Path(repo).resolve() / CONFIG_NAME
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_config(repo: str | Path) -> ProjectConfig:
|
|
30
|
+
path = config_path(repo)
|
|
31
|
+
if not path.exists():
|
|
32
|
+
raise ConfigError(f"missing {CONFIG_NAME} in {Path(repo).resolve()}")
|
|
33
|
+
with path.open("rb") as fh:
|
|
34
|
+
data = tomllib.load(fh)
|
|
35
|
+
section = data.get("tinyhorse")
|
|
36
|
+
if not isinstance(section, dict):
|
|
37
|
+
raise ConfigError(f"{CONFIG_NAME} is missing [tinyhorse]")
|
|
38
|
+
project = section.get("project")
|
|
39
|
+
storage = section.get("storage")
|
|
40
|
+
if not isinstance(project, str) or not project.strip():
|
|
41
|
+
raise ConfigError("tinyhorse.project must be a non-empty string")
|
|
42
|
+
if not isinstance(storage, str) or ":" not in storage:
|
|
43
|
+
raise ConfigError("tinyhorse.storage must be a storage locator")
|
|
44
|
+
return ProjectConfig(project=project.strip(), storage=storage.strip())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def write_config(repo: str | Path, project: str, storage: str) -> Path:
|
|
48
|
+
path = config_path(repo)
|
|
49
|
+
project = project.strip()
|
|
50
|
+
storage = storage.strip()
|
|
51
|
+
if not project:
|
|
52
|
+
raise ConfigError("project name cannot be empty")
|
|
53
|
+
if ":" not in storage:
|
|
54
|
+
raise ConfigError("storage must be a locator such as file:/path/project.bundle")
|
|
55
|
+
|
|
56
|
+
def q(value: str) -> str:
|
|
57
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
58
|
+
|
|
59
|
+
path.write_text(
|
|
60
|
+
"[tinyhorse]\n"
|
|
61
|
+
f"project = {q(project)}\n"
|
|
62
|
+
f"storage = {q(storage)}\n",
|
|
63
|
+
encoding="utf-8",
|
|
64
|
+
)
|
|
65
|
+
return path
|
tinyhorse/git.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import tempfile
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Iterable
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class GitError(RuntimeError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _run(args: Iterable[str], cwd: str | Path | None = None) -> str:
|
|
16
|
+
cmd = ["git", *args]
|
|
17
|
+
proc = subprocess.run(
|
|
18
|
+
cmd,
|
|
19
|
+
cwd=str(cwd) if cwd is not None else None,
|
|
20
|
+
text=True,
|
|
21
|
+
stdout=subprocess.PIPE,
|
|
22
|
+
stderr=subprocess.PIPE,
|
|
23
|
+
)
|
|
24
|
+
if proc.returncode != 0:
|
|
25
|
+
raise GitError(proc.stderr.strip() or f"git exited with {proc.returncode}")
|
|
26
|
+
return proc.stdout.strip()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class RepoState:
|
|
31
|
+
root: str
|
|
32
|
+
head: str
|
|
33
|
+
head_short: str
|
|
34
|
+
branch: str | None
|
|
35
|
+
commit_time: str
|
|
36
|
+
commit_message: str
|
|
37
|
+
dirty: bool
|
|
38
|
+
upstream: str | None
|
|
39
|
+
remotes: dict[str, str]
|
|
40
|
+
|
|
41
|
+
def to_json(self) -> str:
|
|
42
|
+
return json.dumps(asdict(self), indent=2, sort_keys=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def repository_state(path: str | Path = ".") -> RepoState:
|
|
46
|
+
root = _run(["-C", str(path), "rev-parse", "--show-toplevel"])
|
|
47
|
+
head = _run(["-C", root, "rev-parse", "HEAD"])
|
|
48
|
+
head_short = _run(["-C", root, "rev-parse", "--short=12", "HEAD"])
|
|
49
|
+
|
|
50
|
+
branch_proc = subprocess.run(
|
|
51
|
+
["git", "-C", root, "symbolic-ref", "--short", "-q", "HEAD"],
|
|
52
|
+
text=True,
|
|
53
|
+
stdout=subprocess.PIPE,
|
|
54
|
+
stderr=subprocess.PIPE,
|
|
55
|
+
)
|
|
56
|
+
branch = branch_proc.stdout.strip() or None
|
|
57
|
+
|
|
58
|
+
commit_time = _run(["-C", root, "show", "-s", "--format=%cI", "HEAD"])
|
|
59
|
+
commit_message = _run(["-C", root, "show", "-s", "--format=%s", "HEAD"])
|
|
60
|
+
dirty = bool(_run(["-C", root, "status", "--porcelain=v1"]))
|
|
61
|
+
|
|
62
|
+
upstream_proc = subprocess.run(
|
|
63
|
+
["git", "-C", root, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
|
|
64
|
+
text=True,
|
|
65
|
+
stdout=subprocess.PIPE,
|
|
66
|
+
stderr=subprocess.PIPE,
|
|
67
|
+
)
|
|
68
|
+
upstream = upstream_proc.stdout.strip() or None
|
|
69
|
+
|
|
70
|
+
remotes: dict[str, str] = {}
|
|
71
|
+
remote_names = _run(["-C", root, "remote"]).splitlines()
|
|
72
|
+
for name in remote_names:
|
|
73
|
+
if name:
|
|
74
|
+
remotes[name] = _run(["-C", root, "remote", "get-url", name])
|
|
75
|
+
|
|
76
|
+
return RepoState(
|
|
77
|
+
root=root,
|
|
78
|
+
head=head,
|
|
79
|
+
head_short=head_short,
|
|
80
|
+
branch=branch,
|
|
81
|
+
commit_time=commit_time,
|
|
82
|
+
commit_message=commit_message,
|
|
83
|
+
dirty=dirty,
|
|
84
|
+
upstream=upstream,
|
|
85
|
+
remotes=remotes,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def create_bundle(repo: str | Path, output: str | Path) -> Path:
|
|
90
|
+
"""Create a complete project bundle without remote-tracking cache refs."""
|
|
91
|
+
repo = Path(repo)
|
|
92
|
+
output = Path(output).resolve()
|
|
93
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
refs = _run(
|
|
95
|
+
[
|
|
96
|
+
"-C", str(repo), "for-each-ref", "--format=%(refname)",
|
|
97
|
+
"refs/heads", "refs/tags", "refs/notes",
|
|
98
|
+
]
|
|
99
|
+
).splitlines()
|
|
100
|
+
revisions = ["HEAD", *[ref for ref in refs if ref]]
|
|
101
|
+
_run(["-C", str(repo), "bundle", "create", str(output), *revisions])
|
|
102
|
+
return output
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def verify_bundle(bundle: str | Path) -> str:
|
|
106
|
+
"""Verify a bundle without requiring the caller to be inside a Git repo."""
|
|
107
|
+
bundle_path = Path(bundle).resolve()
|
|
108
|
+
with tempfile.TemporaryDirectory(prefix="tinyhorse-verify-") as td:
|
|
109
|
+
check_repo = Path(td) / "check.git"
|
|
110
|
+
_run(["init", "--bare", str(check_repo)])
|
|
111
|
+
return _run(["-C", str(check_repo), "bundle", "verify", str(bundle_path)])
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def mirror_clone(remote: str, destination: str | Path) -> Path:
|
|
115
|
+
"""Low-level mirror clone used when an exact remote snapshot is needed.
|
|
116
|
+
|
|
117
|
+
A mirror clone preserves branches, tags, and other refs. Tiny Horse does not
|
|
118
|
+
delete or mutate the source remote; migration and cut-over are separate steps.
|
|
119
|
+
"""
|
|
120
|
+
destination = Path(destination).resolve()
|
|
121
|
+
if destination.exists():
|
|
122
|
+
raise GitError(f"destination already exists: {destination}")
|
|
123
|
+
_run(["clone", "--mirror", remote, str(destination)])
|
|
124
|
+
return destination
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def bundle_heads(bundle: str | Path) -> dict[str, str]:
|
|
128
|
+
"""Return ref -> object id from `git bundle list-heads`."""
|
|
129
|
+
output = _run(["bundle", "list-heads", str(bundle)])
|
|
130
|
+
heads: dict[str, str] = {}
|
|
131
|
+
for line in output.splitlines():
|
|
132
|
+
if not line.strip():
|
|
133
|
+
continue
|
|
134
|
+
oid, ref = line.split(maxsplit=1)
|
|
135
|
+
heads[ref] = oid
|
|
136
|
+
return heads
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def bundle_head(bundle: str | Path) -> str:
|
|
140
|
+
heads = bundle_heads(bundle)
|
|
141
|
+
if "HEAD" in heads:
|
|
142
|
+
return heads["HEAD"]
|
|
143
|
+
if "refs/heads/main" in heads:
|
|
144
|
+
return heads["refs/heads/main"]
|
|
145
|
+
branch_refs = sorted(ref for ref in heads if ref.startswith("refs/heads/"))
|
|
146
|
+
if len(branch_refs) == 1:
|
|
147
|
+
return heads[branch_refs[0]]
|
|
148
|
+
raise GitError("bundle does not identify a unique canonical HEAD")
|