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.
- {tinyhorse-0.2.1/src/tinyhorse.egg-info → tinyhorse-0.2.2}/PKG-INFO +23 -10
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/README.md +22 -9
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/pyproject.toml +1 -1
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse/__init__.py +1 -1
- tinyhorse-0.2.2/src/tinyhorse/cli.py +347 -0
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse/mcp.py +40 -1
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse/workflow.py +68 -14
- {tinyhorse-0.2.1 → tinyhorse-0.2.2/src/tinyhorse.egg-info}/PKG-INFO +23 -10
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse.egg-info/SOURCES.txt +1 -0
- tinyhorse-0.2.2/tests/test_help_and_tag.py +101 -0
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/tests/test_release_tool.py +1 -1
- tinyhorse-0.2.1/src/tinyhorse/cli.py +0 -230
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/LICENSE +0 -0
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/MANIFEST.in +0 -0
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/docs/MVP.md +0 -0
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/docs/RELEASING.md +0 -0
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/docs/STORAGE_SAFETY.md +0 -0
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/setup.cfg +0 -0
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse/__main__.py +0 -0
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse/auth.py +0 -0
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse/config.py +0 -0
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse/git.py +0 -0
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse/storage.py +0 -0
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse.egg-info/dependency_links.txt +0 -0
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse.egg-info/entry_points.txt +0 -0
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse.egg-info/requires.txt +0 -0
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/src/tinyhorse.egg-info/top_level.txt +0 -0
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/tests/test_auth.py +0 -0
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/tests/test_git.py +0 -0
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/tests/test_storage.py +0 -0
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/tests/test_wip.py +0 -0
- {tinyhorse-0.2.1 → tinyhorse-0.2.2}/tests/test_workflow.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
100
|
-
| `tinyhorse
|
|
101
|
-
| `tinyhorse
|
|
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.
|
|
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
|
|
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
|
|
169
|
+
tinyhorse park [PATH] [--force] # also: push
|
|
162
170
|
tinyhorse pull LOCATOR DESTINATION
|
|
163
171
|
tinyhorse doctor [PATH]
|
|
164
172
|
tinyhorse projects
|
|
165
|
-
tinyhorse
|
|
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.
|
|
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
|
|
82
|
-
| `tinyhorse
|
|
83
|
-
| `tinyhorse
|
|
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.
|
|
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
|
|
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
|
|
151
|
+
tinyhorse park [PATH] [--force] # also: push
|
|
144
152
|
tinyhorse pull LOCATOR DESTINATION
|
|
145
153
|
tinyhorse doctor [PATH]
|
|
146
154
|
tinyhorse projects
|
|
147
|
-
tinyhorse
|
|
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.
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
100
|
-
| `tinyhorse
|
|
101
|
-
| `tinyhorse
|
|
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.
|
|
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
|
|
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
|
|
169
|
+
tinyhorse park [PATH] [--force] # also: push
|
|
162
170
|
tinyhorse pull LOCATOR DESTINATION
|
|
163
171
|
tinyhorse doctor [PATH]
|
|
164
172
|
tinyhorse projects
|
|
165
|
-
tinyhorse
|
|
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.
|
|
@@ -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.
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|