tinyhorse 0.1.2__tar.gz → 0.2.2__tar.gz

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 (33) hide show
  1. {tinyhorse-0.1.2/src/tinyhorse.egg-info → tinyhorse-0.2.2}/PKG-INFO +32 -4
  2. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/README.md +31 -3
  3. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/pyproject.toml +2 -1
  4. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/src/tinyhorse/__init__.py +1 -1
  5. tinyhorse-0.2.2/src/tinyhorse/cli.py +347 -0
  6. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/src/tinyhorse/git.py +295 -2
  7. tinyhorse-0.2.2/src/tinyhorse/mcp.py +176 -0
  8. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/src/tinyhorse/workflow.py +179 -7
  9. {tinyhorse-0.1.2 → tinyhorse-0.2.2/src/tinyhorse.egg-info}/PKG-INFO +32 -4
  10. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/src/tinyhorse.egg-info/SOURCES.txt +3 -0
  11. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/src/tinyhorse.egg-info/entry_points.txt +1 -0
  12. tinyhorse-0.2.2/tests/test_help_and_tag.py +101 -0
  13. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/tests/test_release_tool.py +1 -1
  14. tinyhorse-0.2.2/tests/test_wip.py +214 -0
  15. tinyhorse-0.1.2/src/tinyhorse/cli.py +0 -180
  16. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/LICENSE +0 -0
  17. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/MANIFEST.in +0 -0
  18. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/docs/MVP.md +0 -0
  19. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/docs/RELEASING.md +0 -0
  20. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/docs/STORAGE_SAFETY.md +0 -0
  21. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/setup.cfg +0 -0
  22. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/src/tinyhorse/__main__.py +0 -0
  23. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/src/tinyhorse/auth.py +0 -0
  24. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/src/tinyhorse/config.py +0 -0
  25. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/src/tinyhorse/storage.py +0 -0
  26. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/src/tinyhorse.egg-info/dependency_links.txt +0 -0
  27. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/src/tinyhorse.egg-info/requires.txt +0 -0
  28. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/src/tinyhorse.egg-info/top_level.txt +0 -0
  29. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/tests/test_auth.py +0 -0
  30. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/tests/test_git.py +0 -0
  31. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/tests/test_storage.py +0 -0
  32. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/tests/test_workflow.py +0 -0
  33. {tinyhorse-0.1.2 → tinyhorse-0.2.2}/tools/release.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tinyhorse
3
- Version: 0.1.2
3
+ Version: 0.2.2
4
4
  Summary: Git-native project control for local and user-owned infrastructure
5
5
  Author: Tiny Horse contributors
6
6
  License-Expression: Apache-2.0
@@ -87,11 +87,33 @@ tinyhorse projects
87
87
  tinyhorse clone TULKAS
88
88
  ```
89
89
 
90
+ ## Tiny verbs (0.2.2)
91
+
92
+ Autonomous git control uses short names. They wrap the same storage loop.
93
+ `tinyhorse --help` repeats this; every subcommand has a description.
94
+
95
+ | Tiny | Same as / meaning |
96
+ |------|-------------------|
97
+ | `tinyhorse park` | `push` — publish the canonical bundle, including parked WIP |
98
+ | `tinyhorse claim NAME` | `clone` — restore a Drive project by name |
99
+ | `tinyhorse doctor` | compare local vs canonical; dirty is ok if that dirt is parked |
100
+ | `tinyhorse freeze-writer` | refuse park/push from this checkout |
101
+ | `tinyhorse freeze-writer --clear` | unfreeze (or delete `.git/tinyhorse-freeze`) |
102
+ | `tinyhorse from-zip ZIP DEST` | GitHub zipball → **one new commit** (no history) |
103
+ | `tinyhorse ingest-github URL` | `migrate` a git URL → **full history** |
104
+ | `tinyhorse tag v1.2.0` | annotated git tag on **your** project, then park |
105
+
106
+ WIP (uncommitted files, extra worktrees, `refs/backup/`) is snapshotted into `refs/tinyhorse/wip/<name>` inside the **same** bundle. Park drops wip refs for worktrees that are gone or clean. An in-progress rebase/merge is refused until packed.
107
+
108
+ **Your versioning** is git tags on the project, not `tinyhorse --version`. `tinyhorse tag v1.0` creates `git tag -a v1.0` and parks so the tag lives in the canonical bundle.
109
+
110
+ MCP: `tinyhorse-mcp --help` then stdio JSON-RPC (`park`, `claim`, `doctor`, `from_zip`, `status`, `tag`).
111
+
90
112
  `projects` discovers top-level Drive project folders that are marked by Tiny Horse
91
113
  or contain Git bundles, surfaces empty/ambiguous folders instead of guessing, and
92
114
  does not print provider folder/file IDs. `clone PROJECT` requires one exact project
93
115
  name, restores the canonical bundle, writes the machine-local binding when needed,
94
- and verifies that the restored working tree is clean. Explicit `pull LOCATOR`
116
+ and restores parked WIP (the tree may be dirty; that is success). Explicit `pull LOCATOR`
95
117
  remains available for automation and unusual layouts.
96
118
 
97
119
  The Drive adapter searches for exactly one canonical file with that name in that folder. `push` creates it once and then updates the same Drive object in place. Multiple same-named candidates are treated as an error instead of guessed around. Before replacing an existing object, Tiny Horse downloads and verifies canonical Git, compares its durable refs with the local bundle, and writes only with the snapshot token from that inspect. File storage serializes readers and writers with an exclusive lock, hashes the bytes it copies, and `fsync`s unique temporary files before replace. Drive content replacement uses Drive API v2 `If-Match` so a changed object returns HTTP 412 instead of a silent overwrite. It then reads the object back and requires the SHA-256 of the downloaded bytes to match the upload. See `docs/STORAGE_SAFETY.md`.
@@ -141,19 +163,25 @@ For an intentional history rewrite or ref deletion, use `tinyhorse push --force`
141
163
  ## CLI
142
164
 
143
165
  ```bash
166
+ tinyhorse --help
144
167
  tinyhorse status [PATH]
145
168
  tinyhorse init [PATH] (--drive | --storage LOCATOR) [--project NAME]
146
- tinyhorse push [PATH] [--force]
169
+ tinyhorse park [PATH] [--force] # also: push
147
170
  tinyhorse pull LOCATOR DESTINATION
148
171
  tinyhorse doctor [PATH]
149
172
  tinyhorse projects
150
- tinyhorse clone PROJECT [DESTINATION]
173
+ tinyhorse claim PROJECT [DESTINATION] # also: clone
174
+ tinyhorse tag NAME [PATH] [-m MSG] [--no-park]
175
+ tinyhorse freeze-writer [PATH] [--clear]
176
+ tinyhorse from-zip ZIP DEST [--project NAME] [--storage LOCATOR]
177
+ tinyhorse ingest-github URL [DEST] [--drive | --storage LOCATOR] [--project NAME]
151
178
  tinyhorse bundle PATH OUTPUT
152
179
  tinyhorse verify-bundle BUNDLE
153
180
  tinyhorse migrate REMOTE [DESTINATION] [--drive | --storage LOCATOR] [--project NAME]
154
181
  tinyhorse auth login [--client-secrets PATH] [--no-browser]
155
182
  tinyhorse auth status [--refresh]
156
183
  tinyhorse auth logout
184
+ tinyhorse-mcp --help
157
185
  ```
158
186
 
159
187
  `status`, `migrate`, `push`, `pull`, `doctor`, and the auth commands produce machine-readable JSON where appropriate. `doctor` compares local HEAD to the canonical bundle and reports `in_sync`, `local_ahead`, `local_behind`, or `diverged_or_unrelated`. A refused safe push exits without replacing canonical storage and explains which HEAD, branch, tag, or ref would have been lost.
@@ -69,11 +69,33 @@ tinyhorse projects
69
69
  tinyhorse clone TULKAS
70
70
  ```
71
71
 
72
+ ## Tiny verbs (0.2.2)
73
+
74
+ Autonomous git control uses short names. They wrap the same storage loop.
75
+ `tinyhorse --help` repeats this; every subcommand has a description.
76
+
77
+ | Tiny | Same as / meaning |
78
+ |------|-------------------|
79
+ | `tinyhorse park` | `push` — publish the canonical bundle, including parked WIP |
80
+ | `tinyhorse claim NAME` | `clone` — restore a Drive project by name |
81
+ | `tinyhorse doctor` | compare local vs canonical; dirty is ok if that dirt is parked |
82
+ | `tinyhorse freeze-writer` | refuse park/push from this checkout |
83
+ | `tinyhorse freeze-writer --clear` | unfreeze (or delete `.git/tinyhorse-freeze`) |
84
+ | `tinyhorse from-zip ZIP DEST` | GitHub zipball → **one new commit** (no history) |
85
+ | `tinyhorse ingest-github URL` | `migrate` a git URL → **full history** |
86
+ | `tinyhorse tag v1.2.0` | annotated git tag on **your** project, then park |
87
+
88
+ WIP (uncommitted files, extra worktrees, `refs/backup/`) is snapshotted into `refs/tinyhorse/wip/<name>` inside the **same** bundle. Park drops wip refs for worktrees that are gone or clean. An in-progress rebase/merge is refused until packed.
89
+
90
+ **Your versioning** is git tags on the project, not `tinyhorse --version`. `tinyhorse tag v1.0` creates `git tag -a v1.0` and parks so the tag lives in the canonical bundle.
91
+
92
+ MCP: `tinyhorse-mcp --help` then stdio JSON-RPC (`park`, `claim`, `doctor`, `from_zip`, `status`, `tag`).
93
+
72
94
  `projects` discovers top-level Drive project folders that are marked by Tiny Horse
73
95
  or contain Git bundles, surfaces empty/ambiguous folders instead of guessing, and
74
96
  does not print provider folder/file IDs. `clone PROJECT` requires one exact project
75
97
  name, restores the canonical bundle, writes the machine-local binding when needed,
76
- and verifies that the restored working tree is clean. Explicit `pull LOCATOR`
98
+ and restores parked WIP (the tree may be dirty; that is success). Explicit `pull LOCATOR`
77
99
  remains available for automation and unusual layouts.
78
100
 
79
101
  The Drive adapter searches for exactly one canonical file with that name in that folder. `push` creates it once and then updates the same Drive object in place. Multiple same-named candidates are treated as an error instead of guessed around. Before replacing an existing object, Tiny Horse downloads and verifies canonical Git, compares its durable refs with the local bundle, and writes only with the snapshot token from that inspect. File storage serializes readers and writers with an exclusive lock, hashes the bytes it copies, and `fsync`s unique temporary files before replace. Drive content replacement uses Drive API v2 `If-Match` so a changed object returns HTTP 412 instead of a silent overwrite. It then reads the object back and requires the SHA-256 of the downloaded bytes to match the upload. See `docs/STORAGE_SAFETY.md`.
@@ -123,19 +145,25 @@ For an intentional history rewrite or ref deletion, use `tinyhorse push --force`
123
145
  ## CLI
124
146
 
125
147
  ```bash
148
+ tinyhorse --help
126
149
  tinyhorse status [PATH]
127
150
  tinyhorse init [PATH] (--drive | --storage LOCATOR) [--project NAME]
128
- tinyhorse push [PATH] [--force]
151
+ tinyhorse park [PATH] [--force] # also: push
129
152
  tinyhorse pull LOCATOR DESTINATION
130
153
  tinyhorse doctor [PATH]
131
154
  tinyhorse projects
132
- tinyhorse clone PROJECT [DESTINATION]
155
+ tinyhorse claim PROJECT [DESTINATION] # also: clone
156
+ tinyhorse tag NAME [PATH] [-m MSG] [--no-park]
157
+ tinyhorse freeze-writer [PATH] [--clear]
158
+ tinyhorse from-zip ZIP DEST [--project NAME] [--storage LOCATOR]
159
+ tinyhorse ingest-github URL [DEST] [--drive | --storage LOCATOR] [--project NAME]
133
160
  tinyhorse bundle PATH OUTPUT
134
161
  tinyhorse verify-bundle BUNDLE
135
162
  tinyhorse migrate REMOTE [DESTINATION] [--drive | --storage LOCATOR] [--project NAME]
136
163
  tinyhorse auth login [--client-secrets PATH] [--no-browser]
137
164
  tinyhorse auth status [--refresh]
138
165
  tinyhorse auth logout
166
+ tinyhorse-mcp --help
139
167
  ```
140
168
 
141
169
  `status`, `migrate`, `push`, `pull`, `doctor`, and the auth commands produce machine-readable JSON where appropriate. `doctor` compares local HEAD to the canonical bundle and reports `in_sync`, `local_ahead`, `local_behind`, or `diverged_or_unrelated`. A refused safe push exits without replacing canonical storage and explains which HEAD, branch, tag, or ref would have been lost.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tinyhorse"
7
- version = "0.1.2"
7
+ version = "0.2.2"
8
8
  description = "Git-native project control for local and user-owned infrastructure"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -27,6 +27,7 @@ release = [
27
27
 
28
28
  [project.scripts]
29
29
  tinyhorse = "tinyhorse.cli:main"
30
+ tinyhorse-mcp = "tinyhorse.mcp:main"
30
31
 
31
32
  [tool.setuptools.packages.find]
32
33
  where = ["src"]
@@ -1,3 +1,3 @@
1
1
  """Tiny Horse: Git-native project control."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.2.2"
@@ -0,0 +1,347 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from argparse import RawDescriptionHelpFormatter
6
+
7
+ from . import __version__
8
+
9
+ from .auth import AuthError, google_drive_auth_status, login_google_drive, logout_google_drive
10
+ from .config import ConfigError
11
+ from .git import GitError, create_bundle, repository_state, verify_bundle
12
+ from .storage import StorageError
13
+ from .workflow import (
14
+ WorkflowError,
15
+ clone_project,
16
+ doctor,
17
+ freeze_writer,
18
+ from_zip,
19
+ ingest_github,
20
+ init_drive_project,
21
+ init_project,
22
+ migrate_remote,
23
+ projects,
24
+ pull,
25
+ push,
26
+ tag_project,
27
+ unfreeze_writer,
28
+ )
29
+
30
+
31
+ EPILOG = """
32
+ WIP: park snapshots dirty files, untracked files, and extra worktrees into
33
+ refs/tinyhorse/wip/<name> inside the same canonical bundle. refs/backup/* is
34
+ kept. An in-progress rebase/merge/cherry-pick is refused until finished.
35
+ Removed or clean worktrees drop their wip refs on the next park.
36
+
37
+ Your versioning: git tags on THIS repo are durable. Prefer:
38
+ tinyhorse tag v1.0.0
39
+ which creates an annotated git tag and parks. You can also `git tag -a` then
40
+ `tinyhorse park`. Tiny Horse's own package version (tinyhorse --version) is
41
+ unrelated to your project's tags.
42
+
43
+ Zip vs git URL:
44
+ from-zip GitHub zipball → ONE new commit (no history)
45
+ ingest-github / migrate clone the git URL → full history
46
+
47
+ Freeze: tinyhorse freeze-writer writes .git/tinyhorse-freeze.
48
+ Unfreeze: tinyhorse freeze-writer --clear (or delete that file).
49
+
50
+ MCP: tinyhorse-mcp --help then stdio JSON-RPC (park, claim, doctor, from_zip, status, tag).
51
+ """
52
+
53
+
54
+ def _parser() -> argparse.ArgumentParser:
55
+ parser = argparse.ArgumentParser(
56
+ prog="tinyhorse",
57
+ formatter_class=RawDescriptionHelpFormatter,
58
+ description="Git-native canonical storage. One bundle per project. Does not replace git.",
59
+ epilog=EPILOG,
60
+ )
61
+ parser.add_argument("--version", action="version", version=f"tinyhorse {__version__}")
62
+ sub = parser.add_subparsers(dest="command", required=True)
63
+
64
+ status = sub.add_parser(
65
+ "status",
66
+ help="print local Git state as JSON",
67
+ description="HEAD, branch, dirty, remotes. Does not talk to Drive.",
68
+ )
69
+ status.add_argument("path", nargs="?", default=".", help="git checkout (default: .)")
70
+
71
+ init = sub.add_parser(
72
+ "init",
73
+ help="bind an existing Git repo to canonical storage",
74
+ description="Writes .tinyhorse.toml (machine-local). Does not park yet.",
75
+ )
76
+ init.add_argument("path", nargs="?", default=".", help="git checkout (default: .)")
77
+ init_storage = init.add_mutually_exclusive_group(required=True)
78
+ init_storage.add_argument(
79
+ "--storage",
80
+ help="file:/path/to.bundle or gdrive://FOLDER_ID/FILE.bundle",
81
+ )
82
+ init_storage.add_argument(
83
+ "--drive",
84
+ action="store_true",
85
+ help="find or create a top-level Drive folder named --project (or the directory name)",
86
+ )
87
+ init.add_argument("--project", help="Drive/project display name; defaults to directory name")
88
+
89
+ force_help = (
90
+ "allow non-fast-forward history or ref deletion; "
91
+ "still aborts if storage changed during preflight"
92
+ )
93
+
94
+ push_cmd = sub.add_parser(
95
+ "push",
96
+ help="safely publish a verified canonical bundle (also: park)",
97
+ description="Parks WIP into refs/tinyhorse/wip/*, then publishes. Dirty is included, not dropped.",
98
+ )
99
+ push_cmd.add_argument("path", nargs="?", default=".", help="git checkout (default: .)")
100
+ push_cmd.add_argument("--force", action="store_true", help=force_help)
101
+
102
+ pull_cmd = sub.add_parser(
103
+ "pull",
104
+ help="restore a repository from a storage locator",
105
+ description="Restores durable refs and parked WIP. Recovered trees may be dirty; that is success.",
106
+ )
107
+ pull_cmd.add_argument("storage", help="file:... or gdrive://FOLDER_ID/FILENAME")
108
+ pull_cmd.add_argument("destination", help="new directory; must not already exist")
109
+
110
+ doctor_cmd = sub.add_parser(
111
+ "doctor",
112
+ help="compare local state with canonical storage",
113
+ description=(
114
+ "ok if HEAD/refs match and either the tree is clean or its dirt is parked "
115
+ "in refs/tinyhorse/wip. Exit 1 if not ok."
116
+ ),
117
+ )
118
+ doctor_cmd.add_argument("path", nargs="?", default=".", help="git checkout (default: .)")
119
+
120
+ sub.add_parser(
121
+ "projects",
122
+ help="list discoverable Tiny Horse projects on Google Drive",
123
+ description="Names and bundle filenames only; no Drive folder IDs.",
124
+ )
125
+
126
+ clone_cmd = sub.add_parser(
127
+ "clone",
128
+ help="discover and restore a Tiny Horse project by name (also: claim)",
129
+ description="Restores WIP. Dirty after claim is ok when that dirt was parked.",
130
+ )
131
+ clone_cmd.add_argument("project", help="exact Drive project display name")
132
+ clone_cmd.add_argument("destination", nargs="?", help="new directory; defaults to the project name")
133
+
134
+ bundle = sub.add_parser("bundle", help="create a complete Git bundle (includes parked WIP)")
135
+ bundle.add_argument("path", help="git checkout")
136
+ bundle.add_argument("output", help="bundle file to write")
137
+
138
+ verify = sub.add_parser("verify-bundle", help="verify a Git bundle (header + fsck)")
139
+ verify.add_argument("bundle", help="path to a .bundle file")
140
+
141
+ park = sub.add_parser(
142
+ "park",
143
+ help="tiny: publish canonical bundle including WIP (same as push)",
144
+ description="Preferred autonomous name for push.",
145
+ )
146
+ park.add_argument("path", nargs="?", default=".", help="git checkout (default: .)")
147
+ park.add_argument("--force", action="store_true", help=force_help)
148
+
149
+ claim = sub.add_parser(
150
+ "claim",
151
+ help="tiny: restore a Drive project by name (same as clone)",
152
+ )
153
+ claim.add_argument("project", help="exact Drive project display name")
154
+ claim.add_argument("destination", nargs="?", help="new directory")
155
+
156
+ freeze = sub.add_parser(
157
+ "freeze-writer",
158
+ help="tiny: refuse park/push from this checkout until cleared",
159
+ description="Writes .git/tinyhorse-freeze. Unfreeze with --clear or by deleting that file.",
160
+ )
161
+ freeze.add_argument("path", nargs="?", default=".", help="git checkout (default: .)")
162
+ freeze.add_argument(
163
+ "--clear",
164
+ action="store_true",
165
+ help="remove .git/tinyhorse-freeze so park/push work again",
166
+ )
167
+
168
+ fromzip = sub.add_parser(
169
+ "from-zip",
170
+ help="tiny: zipball → ONE new git commit (no history)",
171
+ description=(
172
+ "GitHub zipballs have no .git. Tiny Horse inits a repo and makes one commit. "
173
+ "For full history use ingest-github / migrate on the git URL. "
174
+ "Then init --drive and park, or pass --storage here."
175
+ ),
176
+ )
177
+ fromzip.add_argument("archive", help="path to .zip (GitHub zipball or any project zip)")
178
+ fromzip.add_argument("destination", help="new directory; must not already exist")
179
+ fromzip.add_argument("--project", help="display name stored if --storage is also passed")
180
+ fromzip.add_argument(
181
+ "--storage",
182
+ help="optional bind: file:... or gdrive://FOLDER_ID/FILE.bundle",
183
+ )
184
+
185
+ ingest = sub.add_parser(
186
+ "ingest-github",
187
+ help="tiny: git URL → Tiny Horse with full history (same as migrate)",
188
+ description="Does not modify the GitHub repo. Canonical authority becomes Tiny Horse storage.",
189
+ )
190
+ ingest.add_argument("remote", help="https://github.com/owner/repo.git or any git URL")
191
+ ingest.add_argument("destination", nargs="?", help="local checkout; defaults to repo name")
192
+ ingest.add_argument("--project", help="Drive/project display name")
193
+ ingest_storage = ingest.add_mutually_exclusive_group()
194
+ ingest_storage.add_argument("--drive", action="store_true", help="provision Drive (default if neither flag)")
195
+ ingest_storage.add_argument("--storage", help="file:... or gdrive:// locator")
196
+
197
+ tag = sub.add_parser(
198
+ "tag",
199
+ help="tiny: annotated git tag on THIS project, then park",
200
+ description=(
201
+ "Your versioning, not tinyhorse --version. "
202
+ "Creates git tag -a NAME and parks so the tag lives in the canonical bundle. "
203
+ "Example: tinyhorse tag v1.2.0 -m 'html-live S1 freeze'"
204
+ ),
205
+ )
206
+ tag.add_argument("name", help="tag name, e.g. v1.2.0")
207
+ tag.add_argument("path", nargs="?", default=".", help="git checkout (default: .)")
208
+ tag.add_argument("-m", "--message", help="annotation; default: tinyhorse tag NAME")
209
+ tag.add_argument(
210
+ "--no-park",
211
+ action="store_true",
212
+ help="only create the git tag; do not park",
213
+ )
214
+ tag.add_argument("--force", action="store_true", help="move an existing tag, then park with --force")
215
+
216
+ move = sub.add_parser(
217
+ "migrate",
218
+ help="migrate a Git remote into Tiny Horse canonical storage",
219
+ description="Same as ingest-github. Never pushes to or deletes the source remote.",
220
+ )
221
+ move.add_argument("remote", help="git URL or local path")
222
+ move.add_argument(
223
+ "destination",
224
+ nargs="?",
225
+ help="local checkout destination; defaults to the repository name",
226
+ )
227
+ move.add_argument("--project", help="project display name; defaults to repository name")
228
+ migrate_storage = move.add_mutually_exclusive_group()
229
+ migrate_storage.add_argument(
230
+ "--drive",
231
+ action="store_true",
232
+ help="auto-provision Google Drive storage (the default)",
233
+ )
234
+ migrate_storage.add_argument(
235
+ "--storage",
236
+ help="explicit canonical storage locator instead of Google Drive",
237
+ )
238
+
239
+ auth = sub.add_parser("auth", help="manage storage-provider authentication")
240
+ auth_sub = auth.add_subparsers(dest="auth_command", required=True)
241
+
242
+ auth_login = auth_sub.add_parser("login", help="sign in to Google Drive")
243
+ auth_login.add_argument(
244
+ "--client-secrets",
245
+ help="Google OAuth desktop-app client JSON; defaults to configured locations",
246
+ )
247
+ auth_login.add_argument(
248
+ "--no-browser",
249
+ action="store_true",
250
+ help="print the authorization URL instead of opening a browser",
251
+ )
252
+
253
+ auth_status = auth_sub.add_parser("status", help="show Google Drive authentication state")
254
+ auth_status.add_argument(
255
+ "--refresh",
256
+ action="store_true",
257
+ help="refresh an expired access token while checking status",
258
+ )
259
+
260
+ auth_sub.add_parser("logout", help="remove Tiny Horse's saved Google Drive credentials")
261
+
262
+ return parser
263
+
264
+
265
+ def main(argv: list[str] | None = None) -> int:
266
+ args = _parser().parse_args(argv)
267
+ try:
268
+ if args.command == "status":
269
+ print(repository_state(args.path).to_json())
270
+ elif args.command == "init":
271
+ if args.drive:
272
+ print(init_drive_project(args.path, args.project))
273
+ else:
274
+ print(init_project(args.path, args.storage, args.project))
275
+ elif args.command in ("push", "park"):
276
+ print(push(args.path, force=args.force).to_json())
277
+ elif args.command == "pull":
278
+ print(pull(args.storage, args.destination).to_json())
279
+ elif args.command == "doctor":
280
+ result = doctor(args.path)
281
+ print(result.to_json())
282
+ return 0 if result.ok else 1
283
+ elif args.command == "projects":
284
+ print(projects().to_json())
285
+ elif args.command in ("clone", "claim"):
286
+ print(clone_project(args.project, args.destination).to_json())
287
+ elif args.command == "freeze-writer":
288
+ if args.clear:
289
+ print(str(unfreeze_writer(args.path)))
290
+ else:
291
+ print(str(freeze_writer(args.path)))
292
+ elif args.command == "from-zip":
293
+ print(from_zip(args.archive, args.destination, project=args.project, storage=args.storage))
294
+ elif args.command == "ingest-github":
295
+ print(
296
+ ingest_github(
297
+ args.remote,
298
+ args.destination,
299
+ project=args.project,
300
+ storage=args.storage,
301
+ ).to_json()
302
+ )
303
+ elif args.command == "tag":
304
+ print(
305
+ tag_project(
306
+ args.path,
307
+ args.name,
308
+ message=args.message,
309
+ park_after=not args.no_park,
310
+ force=args.force,
311
+ ).to_json()
312
+ )
313
+ elif args.command == "bundle":
314
+ print(create_bundle(args.path, args.output))
315
+ elif args.command == "verify-bundle":
316
+ print(verify_bundle(args.bundle))
317
+ elif args.command == "migrate":
318
+ print(
319
+ migrate_remote(
320
+ args.remote,
321
+ args.destination,
322
+ project=args.project,
323
+ storage=args.storage,
324
+ ).to_json()
325
+ )
326
+ elif args.command == "auth":
327
+ if args.auth_command == "login":
328
+ print(
329
+ login_google_drive(
330
+ args.client_secrets,
331
+ open_browser=not args.no_browser,
332
+ ).to_json()
333
+ )
334
+ elif args.auth_command == "status":
335
+ status = google_drive_auth_status(refresh=args.refresh)
336
+ print(status.to_json())
337
+ return 0 if status.authenticated else 1
338
+ elif args.auth_command == "logout":
339
+ print(logout_google_drive().to_json())
340
+ return 0
341
+ except (AuthError, GitError, ConfigError, StorageError, WorkflowError) as exc:
342
+ print(f"tinyhorse: {exc}", file=sys.stderr)
343
+ return 2
344
+
345
+
346
+ if __name__ == "__main__":
347
+ raise SystemExit(main())