tinyhorse 0.2.1__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.2.1/src/tinyhorse.egg-info → tinyhorse-0.2.2}/PKG-INFO +23 -10
  2. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/README.md +22 -9
  3. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/pyproject.toml +1 -1
  4. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse/__init__.py +1 -1
  5. tinyhorse-0.2.2/src/tinyhorse/cli.py +347 -0
  6. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse/mcp.py +40 -1
  7. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse/workflow.py +68 -14
  8. {tinyhorse-0.2.1 → tinyhorse-0.2.2/src/tinyhorse.egg-info}/PKG-INFO +23 -10
  9. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse.egg-info/SOURCES.txt +1 -0
  10. tinyhorse-0.2.2/tests/test_help_and_tag.py +101 -0
  11. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/tests/test_release_tool.py +1 -1
  12. tinyhorse-0.2.1/src/tinyhorse/cli.py +0 -230
  13. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/LICENSE +0 -0
  14. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/MANIFEST.in +0 -0
  15. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/docs/MVP.md +0 -0
  16. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/docs/RELEASING.md +0 -0
  17. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/docs/STORAGE_SAFETY.md +0 -0
  18. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/setup.cfg +0 -0
  19. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse/__main__.py +0 -0
  20. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse/auth.py +0 -0
  21. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse/config.py +0 -0
  22. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse/git.py +0 -0
  23. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse/storage.py +0 -0
  24. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse.egg-info/dependency_links.txt +0 -0
  25. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse.egg-info/entry_points.txt +0 -0
  26. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse.egg-info/requires.txt +0 -0
  27. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse.egg-info/top_level.txt +0 -0
  28. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/tests/test_auth.py +0 -0
  29. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/tests/test_git.py +0 -0
  30. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/tests/test_storage.py +0 -0
  31. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/tests/test_wip.py +0 -0
  32. {tinyhorse-0.2.1 → tinyhorse-0.2.2}/tests/test_workflow.py +0 -0
  33. {tinyhorse-0.2.1 → 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.2.1
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,26 +87,33 @@ tinyhorse projects
87
87
  tinyhorse clone TULKAS
88
88
  ```
89
89
 
90
- ## Tiny verbs (0.2.0)
90
+ ## Tiny verbs (0.2.2)
91
91
 
92
- Autonomous git control uses short names. They wrap the same storage loop:
92
+ Autonomous git control uses short names. They wrap the same storage loop.
93
+ `tinyhorse --help` repeats this; every subcommand has a description.
93
94
 
94
95
  | Tiny | Same as / meaning |
95
96
  |------|-------------------|
96
97
  | `tinyhorse park` | `push` — publish the canonical bundle, including parked WIP |
97
98
  | `tinyhorse claim NAME` | `clone` — restore a Drive project by name |
98
99
  | `tinyhorse doctor` | compare local vs canonical; dirty is ok if that dirt is parked |
99
- | `tinyhorse freeze-writer` | refuse later park/push from this checkout |
100
- | `tinyhorse from-zip ZIP DEST` | GitHub zipball → git checkout (optional `--storage`) |
101
- | `tinyhorse ingest-github URL` | `migrate` a git remote into Tiny Horse |
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 |
102
105
 
103
- 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. MCP stdio: `tinyhorse-mcp` (`park`, `claim`, `doctor`, `from_zip`, `status`).
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`).
104
111
 
105
112
  `projects` discovers top-level Drive project folders that are marked by Tiny Horse
106
113
  or contain Git bundles, surfaces empty/ambiguous folders instead of guessing, and
107
114
  does not print provider folder/file IDs. `clone PROJECT` requires one exact project
108
115
  name, restores the canonical bundle, writes the machine-local binding when needed,
109
- 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`
110
117
  remains available for automation and unusual layouts.
111
118
 
112
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`.
@@ -156,19 +163,25 @@ For an intentional history rewrite or ref deletion, use `tinyhorse push --force`
156
163
  ## CLI
157
164
 
158
165
  ```bash
166
+ tinyhorse --help
159
167
  tinyhorse status [PATH]
160
168
  tinyhorse init [PATH] (--drive | --storage LOCATOR) [--project NAME]
161
- tinyhorse push [PATH] [--force]
169
+ tinyhorse park [PATH] [--force] # also: push
162
170
  tinyhorse pull LOCATOR DESTINATION
163
171
  tinyhorse doctor [PATH]
164
172
  tinyhorse projects
165
- 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]
166
178
  tinyhorse bundle PATH OUTPUT
167
179
  tinyhorse verify-bundle BUNDLE
168
180
  tinyhorse migrate REMOTE [DESTINATION] [--drive | --storage LOCATOR] [--project NAME]
169
181
  tinyhorse auth login [--client-secrets PATH] [--no-browser]
170
182
  tinyhorse auth status [--refresh]
171
183
  tinyhorse auth logout
184
+ tinyhorse-mcp --help
172
185
  ```
173
186
 
174
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,26 +69,33 @@ tinyhorse projects
69
69
  tinyhorse clone TULKAS
70
70
  ```
71
71
 
72
- ## Tiny verbs (0.2.0)
72
+ ## Tiny verbs (0.2.2)
73
73
 
74
- Autonomous git control uses short names. They wrap the same storage loop:
74
+ Autonomous git control uses short names. They wrap the same storage loop.
75
+ `tinyhorse --help` repeats this; every subcommand has a description.
75
76
 
76
77
  | Tiny | Same as / meaning |
77
78
  |------|-------------------|
78
79
  | `tinyhorse park` | `push` — publish the canonical bundle, including parked WIP |
79
80
  | `tinyhorse claim NAME` | `clone` — restore a Drive project by name |
80
81
  | `tinyhorse doctor` | compare local vs canonical; dirty is ok if that dirt is parked |
81
- | `tinyhorse freeze-writer` | refuse later park/push from this checkout |
82
- | `tinyhorse from-zip ZIP DEST` | GitHub zipball → git checkout (optional `--storage`) |
83
- | `tinyhorse ingest-github URL` | `migrate` a git remote into Tiny Horse |
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 |
84
87
 
85
- 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. MCP stdio: `tinyhorse-mcp` (`park`, `claim`, `doctor`, `from_zip`, `status`).
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`).
86
93
 
87
94
  `projects` discovers top-level Drive project folders that are marked by Tiny Horse
88
95
  or contain Git bundles, surfaces empty/ambiguous folders instead of guessing, and
89
96
  does not print provider folder/file IDs. `clone PROJECT` requires one exact project
90
97
  name, restores the canonical bundle, writes the machine-local binding when needed,
91
- 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`
92
99
  remains available for automation and unusual layouts.
93
100
 
94
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`.
@@ -138,19 +145,25 @@ For an intentional history rewrite or ref deletion, use `tinyhorse push --force`
138
145
  ## CLI
139
146
 
140
147
  ```bash
148
+ tinyhorse --help
141
149
  tinyhorse status [PATH]
142
150
  tinyhorse init [PATH] (--drive | --storage LOCATOR) [--project NAME]
143
- tinyhorse push [PATH] [--force]
151
+ tinyhorse park [PATH] [--force] # also: push
144
152
  tinyhorse pull LOCATOR DESTINATION
145
153
  tinyhorse doctor [PATH]
146
154
  tinyhorse projects
147
- 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]
148
160
  tinyhorse bundle PATH OUTPUT
149
161
  tinyhorse verify-bundle BUNDLE
150
162
  tinyhorse migrate REMOTE [DESTINATION] [--drive | --storage LOCATOR] [--project NAME]
151
163
  tinyhorse auth login [--client-secrets PATH] [--no-browser]
152
164
  tinyhorse auth status [--refresh]
153
165
  tinyhorse auth logout
166
+ tinyhorse-mcp --help
154
167
  ```
155
168
 
156
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.2.1"
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"
@@ -1,3 +1,3 @@
1
1
  """Tiny Horse: Git-native project control."""
2
2
 
3
- __version__ = "0.2.1"
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())
@@ -61,8 +61,33 @@ TOOLS = [
61
61
  "properties": {"path": {"type": "string"}},
62
62
  },
63
63
  },
64
+ {
65
+ "name": "tag",
66
+ "description": "Annotated git tag on this project, then park (tinyhorse tag).",
67
+ "inputSchema": {
68
+ "type": "object",
69
+ "properties": {
70
+ "name": {"type": "string"},
71
+ "path": {"type": "string"},
72
+ "message": {"type": "string"},
73
+ },
74
+ "required": ["name"],
75
+ },
76
+ },
64
77
  ]
65
78
 
79
+ HELP = """tinyhorse-mcp — stdio JSON-RPC 2.0 for Tiny Horse
80
+
81
+ Tools: park, claim, doctor, from_zip, status, tag
82
+
83
+ This process reads JSON-RPC from stdin. If you ran it in a terminal by
84
+ mistake, this help is the intended output. Example:
85
+
86
+ echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | tinyhorse-mcp
87
+
88
+ tinyhorse --help documents WIP, zip vs git ingest, freeze, and user tags.
89
+ """
90
+
66
91
 
67
92
  def _run_cli(argv: list[str]) -> tuple[int, str]:
68
93
  from io import StringIO
@@ -98,6 +123,13 @@ def _handle(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
98
123
  code, text = _run_cli(argv)
99
124
  elif name == "status":
100
125
  code, text = _run_cli(["status", arguments.get("path") or "."])
126
+ elif name == "tag":
127
+ argv = ["tag", arguments["name"]]
128
+ if arguments.get("path"):
129
+ argv.append(arguments["path"])
130
+ if arguments.get("message"):
131
+ argv.extend(["-m", arguments["message"]])
132
+ code, text = _run_cli(argv)
101
133
  else:
102
134
  return {"error": {"code": -32601, "message": f"unknown tool {name}"}}
103
135
  return {"content": [{"type": "text", "text": text}], "isError": code != 0}
@@ -128,7 +160,14 @@ def serve() -> None:
128
160
  sys.stdout.flush()
129
161
 
130
162
 
131
- def main() -> int:
163
+ def main(argv: list[str] | None = None) -> int:
164
+ argv = list(sys.argv[1:] if argv is None else argv)
165
+ if argv in ([], ["-h"], ["--help"]) and (argv or sys.stdin.isatty()):
166
+ print(HELP, end="")
167
+ return 0
168
+ if "-h" in argv or "--help" in argv:
169
+ print(HELP, end="")
170
+ return 0
132
171
  serve()
133
172
  return 0
134
173
 
@@ -335,7 +335,7 @@ def migrate_remote(
335
335
  FREEZE_NAME = "tinyhorse-freeze"
336
336
 
337
337
 
338
- def writer_frozen(repo: str | Path) -> bool:
338
+ def _freeze_marker(repo: str | Path) -> Path:
339
339
  root = Path(repository_state(repo).root)
340
340
  marker = Path(
341
341
  subprocess.run(
@@ -348,28 +348,82 @@ def writer_frozen(repo: str | Path) -> bool:
348
348
  )
349
349
  if not marker.is_absolute():
350
350
  marker = root / marker
351
- return marker.is_file()
351
+ return marker
352
+
353
+
354
+ def writer_frozen(repo: str | Path) -> bool:
355
+ return _freeze_marker(repo).is_file()
352
356
 
353
357
 
354
358
  def freeze_writer(repo: str | Path = ".") -> Path:
355
359
  """Refuse later park/push from this checkout until the freeze file is removed."""
356
- root = Path(repository_state(repo).root)
357
- marker = Path(
358
- subprocess.run(
359
- ["git", "-C", str(root), "rev-parse", "--git-path", FREEZE_NAME],
360
- check=True,
361
- text=True,
362
- stdout=subprocess.PIPE,
363
- stderr=subprocess.PIPE,
364
- ).stdout.strip()
365
- )
366
- if not marker.is_absolute():
367
- marker = root / marker
360
+ marker = _freeze_marker(repo)
368
361
  marker.parent.mkdir(parents=True, exist_ok=True)
369
362
  marker.write_text("frozen\n", encoding="utf-8")
370
363
  return marker
371
364
 
372
365
 
366
+ def unfreeze_writer(repo: str | Path = ".") -> Path:
367
+ marker = _freeze_marker(repo)
368
+ marker.unlink(missing_ok=True)
369
+ return marker
370
+
371
+
372
+ @dataclass(frozen=True)
373
+ class TagResult:
374
+ name: str
375
+ oid: str
376
+ parked: bool
377
+ push: SyncResult | None = None
378
+
379
+ def to_json(self) -> str:
380
+ return json.dumps(
381
+ {
382
+ "name": self.name,
383
+ "oid": self.oid,
384
+ "parked": self.parked,
385
+ "push": None if self.push is None else json.loads(self.push.to_json()),
386
+ },
387
+ indent=2,
388
+ sort_keys=True,
389
+ )
390
+
391
+
392
+ def tag_project(
393
+ repo: str | Path,
394
+ name: str,
395
+ message: str | None = None,
396
+ *,
397
+ park_after: bool = True,
398
+ force: bool = False,
399
+ ) -> TagResult:
400
+ """Annotated git tag on the operator's repo, then park into canonical storage."""
401
+ name = name.strip()
402
+ if not name or any(c.isspace() for c in name):
403
+ raise WorkflowError("tag name must be a single git-ref token, e.g. v1.2.0")
404
+ root = Path(repository_state(repo).root)
405
+ msg = (message or f"tinyhorse tag {name}").strip()
406
+ args = ["-C", str(root), "tag", "-a", name, "-m", msg]
407
+ if force:
408
+ args.insert(3, "-f")
409
+ try:
410
+ _git(args)
411
+ except WorkflowError as exc:
412
+ raise WorkflowError(f"could not create tag {name}: {exc}") from exc
413
+ oid = _git(["-C", str(root), "rev-parse", name])
414
+ parked = False
415
+ sync = None
416
+ if park_after:
417
+ if not (root / CONFIG_NAME).is_file():
418
+ raise WorkflowError(
419
+ f"tagged {name} locally but this checkout is not bound; "
420
+ "run tinyhorse init then tinyhorse park"
421
+ )
422
+ sync = push(root, force=force)
423
+ parked = True
424
+ return TagResult(name=name, oid=oid, parked=parked, push=sync)
425
+
426
+
373
427
  def from_zip(
374
428
  archive: str | Path,
375
429
  destination: str | Path,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tinyhorse
3
- Version: 0.2.1
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,26 +87,33 @@ tinyhorse projects
87
87
  tinyhorse clone TULKAS
88
88
  ```
89
89
 
90
- ## Tiny verbs (0.2.0)
90
+ ## Tiny verbs (0.2.2)
91
91
 
92
- Autonomous git control uses short names. They wrap the same storage loop:
92
+ Autonomous git control uses short names. They wrap the same storage loop.
93
+ `tinyhorse --help` repeats this; every subcommand has a description.
93
94
 
94
95
  | Tiny | Same as / meaning |
95
96
  |------|-------------------|
96
97
  | `tinyhorse park` | `push` — publish the canonical bundle, including parked WIP |
97
98
  | `tinyhorse claim NAME` | `clone` — restore a Drive project by name |
98
99
  | `tinyhorse doctor` | compare local vs canonical; dirty is ok if that dirt is parked |
99
- | `tinyhorse freeze-writer` | refuse later park/push from this checkout |
100
- | `tinyhorse from-zip ZIP DEST` | GitHub zipball → git checkout (optional `--storage`) |
101
- | `tinyhorse ingest-github URL` | `migrate` a git remote into Tiny Horse |
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 |
102
105
 
103
- 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. MCP stdio: `tinyhorse-mcp` (`park`, `claim`, `doctor`, `from_zip`, `status`).
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`).
104
111
 
105
112
  `projects` discovers top-level Drive project folders that are marked by Tiny Horse
106
113
  or contain Git bundles, surfaces empty/ambiguous folders instead of guessing, and
107
114
  does not print provider folder/file IDs. `clone PROJECT` requires one exact project
108
115
  name, restores the canonical bundle, writes the machine-local binding when needed,
109
- 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`
110
117
  remains available for automation and unusual layouts.
111
118
 
112
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`.
@@ -156,19 +163,25 @@ For an intentional history rewrite or ref deletion, use `tinyhorse push --force`
156
163
  ## CLI
157
164
 
158
165
  ```bash
166
+ tinyhorse --help
159
167
  tinyhorse status [PATH]
160
168
  tinyhorse init [PATH] (--drive | --storage LOCATOR) [--project NAME]
161
- tinyhorse push [PATH] [--force]
169
+ tinyhorse park [PATH] [--force] # also: push
162
170
  tinyhorse pull LOCATOR DESTINATION
163
171
  tinyhorse doctor [PATH]
164
172
  tinyhorse projects
165
- 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]
166
178
  tinyhorse bundle PATH OUTPUT
167
179
  tinyhorse verify-bundle BUNDLE
168
180
  tinyhorse migrate REMOTE [DESTINATION] [--drive | --storage LOCATOR] [--project NAME]
169
181
  tinyhorse auth login [--client-secrets PATH] [--no-browser]
170
182
  tinyhorse auth status [--refresh]
171
183
  tinyhorse auth logout
184
+ tinyhorse-mcp --help
172
185
  ```
173
186
 
174
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.
@@ -22,6 +22,7 @@ src/tinyhorse.egg-info/requires.txt
22
22
  src/tinyhorse.egg-info/top_level.txt
23
23
  tests/test_auth.py
24
24
  tests/test_git.py
25
+ tests/test_help_and_tag.py
25
26
  tests/test_release_tool.py
26
27
  tests/test_storage.py
27
28
  tests/test_wip.py
@@ -0,0 +1,101 @@
1
+ import os
2
+ import subprocess
3
+ import tempfile
4
+ import unittest
5
+ from io import StringIO
6
+ from pathlib import Path
7
+ from unittest.mock import patch
8
+
9
+ from tinyhorse.cli import main
10
+ from tinyhorse.mcp import HELP, main as mcp_main
11
+ from tinyhorse.workflow import freeze_writer, init_project, tag_project, unfreeze_writer, writer_frozen
12
+
13
+
14
+ def _env(home: Path) -> dict[str, str]:
15
+ env = os.environ.copy()
16
+ env["HOME"] = str(home)
17
+ env["GIT_CONFIG_NOSYSTEM"] = "1"
18
+ env.pop("GIT_CONFIG_GLOBAL", None)
19
+ return env
20
+
21
+
22
+ def _repo(path: Path) -> Path:
23
+ path.mkdir()
24
+ env = _env(path)
25
+ subprocess.run(["git", "init", "-b", "main"], cwd=path, check=True, stdout=subprocess.PIPE, env=env)
26
+ subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=path, check=True, env=env)
27
+ subprocess.run(["git", "config", "user.name", "Tiny Horse Test"], cwd=path, check=True, env=env)
28
+ (path / "hello.txt").write_text("horse\n")
29
+ subprocess.run(["git", "add", "."], cwd=path, check=True, env=env)
30
+ subprocess.run(["git", "commit", "-m", "initial"], cwd=path, check=True, stdout=subprocess.PIPE, env=env)
31
+ return path
32
+
33
+
34
+ class HelpTests(unittest.TestCase):
35
+ def test_top_help_teaches_wip_zip_tag(self):
36
+ buf = StringIO()
37
+ with patch("sys.stdout", buf), self.assertRaises(SystemExit) as ctx:
38
+ main(["--help"])
39
+ self.assertEqual(ctx.exception.code, 0)
40
+ text = buf.getvalue()
41
+ self.assertIn("refs/tinyhorse/wip", text)
42
+ self.assertIn("from-zip", text)
43
+ self.assertIn("one new commit", text.lower())
44
+ self.assertIn("ingest-github", text)
45
+ self.assertIn("tinyhorse tag", text)
46
+ self.assertIn("tinyhorse-freeze", text)
47
+ self.assertIn("MCP", text)
48
+
49
+ def test_from_zip_help_says_no_history(self):
50
+ buf = StringIO()
51
+ with patch("sys.stdout", buf), self.assertRaises(SystemExit):
52
+ main(["from-zip", "--help"])
53
+ self.assertIn("one commit", buf.getvalue().lower())
54
+ self.assertIn("ingest-github", buf.getvalue())
55
+
56
+ def test_mcp_help(self):
57
+ buf = StringIO()
58
+ with patch("sys.stdout", buf):
59
+ self.assertEqual(mcp_main(["--help"]), 0)
60
+ out = buf.getvalue()
61
+ self.assertIn("JSON-RPC", out)
62
+ self.assertIn("tinyhorse-mcp", out)
63
+
64
+
65
+ class TagAndFreezeTests(unittest.TestCase):
66
+ def test_tag_then_park_round_trip(self):
67
+ with tempfile.TemporaryDirectory() as td:
68
+ root = Path(td)
69
+ repo = _repo(root / "repo")
70
+ bundle = root / "repo.bundle"
71
+ init_project(repo, bundle.as_uri(), "tagged")
72
+ subprocess.run(["git", "add", ".tinyhorse.toml"], cwd=repo, check=True)
73
+ subprocess.run(["git", "commit", "-m", "config"], cwd=repo, check=True, stdout=subprocess.PIPE)
74
+ result = tag_project(repo, "v1.2.0", message="operator version")
75
+ self.assertTrue(result.parked)
76
+ self.assertEqual(result.name, "v1.2.0")
77
+ restored = root / "restored"
78
+ from tinyhorse.workflow import pull
79
+
80
+ pull(bundle.as_uri(), restored)
81
+ tag = subprocess.run(
82
+ ["git", "rev-parse", "v1.2.0"],
83
+ cwd=restored,
84
+ check=True,
85
+ text=True,
86
+ stdout=subprocess.PIPE,
87
+ ).stdout.strip()
88
+ self.assertEqual(tag, result.oid)
89
+
90
+ def test_unfreeze(self):
91
+ with tempfile.TemporaryDirectory() as td:
92
+ root = Path(td)
93
+ repo = _repo(root / "repo")
94
+ freeze_writer(repo)
95
+ self.assertTrue(writer_frozen(repo))
96
+ unfreeze_writer(repo)
97
+ self.assertFalse(writer_frozen(repo))
98
+
99
+
100
+ if __name__ == "__main__":
101
+ unittest.main()
@@ -24,7 +24,7 @@ class ReleaseToolTests(unittest.TestCase):
24
24
  pyproject_version, init_version = release.package_version(ROOT)
25
25
  self.assertEqual(pyproject_version, init_version)
26
26
  self.assertEqual(pyproject_version, __version__)
27
- self.assertEqual(pyproject_version, "0.2.1")
27
+ self.assertEqual(pyproject_version, "0.2.2")
28
28
 
29
29
  def test_manifest_includes_release_tooling(self):
30
30
  manifest = (ROOT / "MANIFEST.in").read_text(encoding="utf-8")
@@ -1,230 +0,0 @@
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
- freeze_writer,
17
- from_zip,
18
- ingest_github,
19
- init_drive_project,
20
- init_project,
21
- migrate_remote,
22
- projects,
23
- pull,
24
- push,
25
- )
26
-
27
-
28
- def _parser() -> argparse.ArgumentParser:
29
- parser = argparse.ArgumentParser(prog="tinyhorse")
30
- parser.add_argument("--version", action="version", version=f"tinyhorse {__version__}")
31
- sub = parser.add_subparsers(dest="command", required=True)
32
-
33
- status = sub.add_parser("status", help="print local Git state as JSON")
34
- status.add_argument("path", nargs="?", default=".")
35
-
36
- init = sub.add_parser("init", help="bind an existing Git repo to canonical storage")
37
- init.add_argument("path", nargs="?", default=".")
38
- init_storage = init.add_mutually_exclusive_group(required=True)
39
- init_storage.add_argument("--storage", help="explicit storage locator")
40
- init_storage.add_argument(
41
- "--drive",
42
- action="store_true",
43
- help="find or create this project's Google Drive storage automatically",
44
- )
45
- init.add_argument("--project", help="project display name; defaults to repository directory name")
46
-
47
- push_cmd = sub.add_parser("push", help="safely publish a verified canonical bundle")
48
- push_cmd.add_argument("path", nargs="?", default=".")
49
- push_cmd.add_argument(
50
- "--force",
51
- action="store_true",
52
- help="allow intentional non-fast-forward history/ref changes; concurrent storage changes still abort",
53
- )
54
-
55
- pull_cmd = sub.add_parser("pull", help="restore a repository from canonical storage")
56
- pull_cmd.add_argument("storage", help="file:... or gdrive://FOLDER_ID/FILENAME")
57
- pull_cmd.add_argument("destination")
58
-
59
- doctor_cmd = sub.add_parser("doctor", help="compare local state with canonical storage")
60
- doctor_cmd.add_argument("path", nargs="?", default=".")
61
-
62
- projects_cmd = sub.add_parser(
63
- "projects", help="list discoverable Tiny Horse projects on Google Drive"
64
- )
65
-
66
- clone_cmd = sub.add_parser(
67
- "clone", help="discover and restore a Tiny Horse project by name"
68
- )
69
- clone_cmd.add_argument("project")
70
- clone_cmd.add_argument("destination", nargs="?")
71
-
72
- bundle = sub.add_parser("bundle", help="create a complete Git bundle")
73
- bundle.add_argument("path")
74
- bundle.add_argument("output")
75
-
76
- verify = sub.add_parser("verify-bundle", help="verify a Git bundle")
77
- verify.add_argument("bundle")
78
-
79
- park = sub.add_parser("park", help="tiny: publish canonical bundle (same as push)")
80
- park.add_argument("path", nargs="?", default=".")
81
- park.add_argument("--force", action="store_true")
82
-
83
- claim = sub.add_parser("claim", help="tiny: restore a Drive project by name (same as clone)")
84
- claim.add_argument("project")
85
- claim.add_argument("destination", nargs="?")
86
-
87
- freeze = sub.add_parser(
88
- "freeze-writer",
89
- help="tiny: refuse park/push from this checkout until unfrozen",
90
- )
91
- freeze.add_argument("path", nargs="?", default=".")
92
-
93
- fromzip = sub.add_parser(
94
- "from-zip",
95
- help="tiny: GitHub zipball (or any zip) → git checkout, optional bind",
96
- )
97
- fromzip.add_argument("archive")
98
- fromzip.add_argument("destination")
99
- fromzip.add_argument("--project")
100
- fromzip.add_argument("--storage", help="file:... or gdrive:// locator to bind after ingest")
101
-
102
- ingest = sub.add_parser(
103
- "ingest-github",
104
- help="tiny: git URL → Tiny Horse (same as migrate)",
105
- )
106
- ingest.add_argument("remote")
107
- ingest.add_argument("destination", nargs="?")
108
- ingest.add_argument("--project")
109
- ingest_storage = ingest.add_mutually_exclusive_group()
110
- ingest_storage.add_argument("--drive", action="store_true")
111
- ingest_storage.add_argument("--storage")
112
-
113
- move = sub.add_parser(
114
- "migrate",
115
- help="migrate a Git remote into Tiny Horse canonical storage",
116
- )
117
- move.add_argument("remote")
118
- move.add_argument(
119
- "destination",
120
- nargs="?",
121
- help="local checkout destination; defaults to the repository name",
122
- )
123
- move.add_argument("--project", help="project display name; defaults to repository name")
124
- migrate_storage = move.add_mutually_exclusive_group()
125
- migrate_storage.add_argument(
126
- "--drive",
127
- action="store_true",
128
- help="auto-provision Google Drive storage (the default)",
129
- )
130
- migrate_storage.add_argument(
131
- "--storage",
132
- help="explicit canonical storage locator instead of Google Drive",
133
- )
134
-
135
- auth = sub.add_parser("auth", help="manage storage-provider authentication")
136
- auth_sub = auth.add_subparsers(dest="auth_command", required=True)
137
-
138
- auth_login = auth_sub.add_parser("login", help="sign in to Google Drive")
139
- auth_login.add_argument(
140
- "--client-secrets",
141
- help="Google OAuth desktop-app client JSON; defaults to configured locations",
142
- )
143
- auth_login.add_argument(
144
- "--no-browser",
145
- action="store_true",
146
- help="print the authorization URL instead of opening a browser",
147
- )
148
-
149
- auth_status = auth_sub.add_parser("status", help="show Google Drive authentication state")
150
- auth_status.add_argument(
151
- "--refresh",
152
- action="store_true",
153
- help="refresh an expired access token while checking status",
154
- )
155
-
156
- auth_sub.add_parser("logout", help="remove Tiny Horse's saved Google Drive credentials")
157
-
158
- return parser
159
-
160
-
161
- def main(argv: list[str] | None = None) -> int:
162
- args = _parser().parse_args(argv)
163
- try:
164
- if args.command == "status":
165
- print(repository_state(args.path).to_json())
166
- elif args.command == "init":
167
- if args.drive:
168
- print(init_drive_project(args.path, args.project))
169
- else:
170
- print(init_project(args.path, args.storage, args.project))
171
- elif args.command in ("push", "park"):
172
- print(push(args.path, force=args.force).to_json())
173
- elif args.command == "pull":
174
- print(pull(args.storage, args.destination).to_json())
175
- elif args.command == "doctor":
176
- result = doctor(args.path)
177
- print(result.to_json())
178
- return 0 if result.ok else 1
179
- elif args.command == "projects":
180
- print(projects().to_json())
181
- elif args.command in ("clone", "claim"):
182
- print(clone_project(args.project, args.destination).to_json())
183
- elif args.command == "freeze-writer":
184
- print(str(freeze_writer(args.path)))
185
- elif args.command == "from-zip":
186
- print(from_zip(args.archive, args.destination, project=args.project, storage=args.storage))
187
- elif args.command == "ingest-github":
188
- print(
189
- ingest_github(
190
- args.remote,
191
- args.destination,
192
- project=args.project,
193
- storage=args.storage,
194
- ).to_json()
195
- )
196
- elif args.command == "bundle":
197
- print(create_bundle(args.path, args.output))
198
- elif args.command == "verify-bundle":
199
- print(verify_bundle(args.bundle))
200
- elif args.command == "migrate":
201
- print(
202
- migrate_remote(
203
- args.remote,
204
- args.destination,
205
- project=args.project,
206
- storage=args.storage,
207
- ).to_json()
208
- )
209
- elif args.command == "auth":
210
- if args.auth_command == "login":
211
- print(
212
- login_google_drive(
213
- args.client_secrets,
214
- open_browser=not args.no_browser,
215
- ).to_json()
216
- )
217
- elif args.auth_command == "status":
218
- status = google_drive_auth_status(refresh=args.refresh)
219
- print(status.to_json())
220
- return 0 if status.authenticated else 1
221
- elif args.auth_command == "logout":
222
- print(logout_google_drive().to_json())
223
- return 0
224
- except (AuthError, GitError, ConfigError, StorageError, WorkflowError) as exc:
225
- print(f"tinyhorse: {exc}", file=sys.stderr)
226
- return 2
227
-
228
-
229
- if __name__ == "__main__":
230
- raise SystemExit(main())
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes