tinyhorse 0.2.2__tar.gz → 0.2.4__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.2/src/tinyhorse.egg-info → tinyhorse-0.2.4}/PKG-INFO +5 -1
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/README.md +4 -0
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/pyproject.toml +1 -1
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse/__init__.py +1 -1
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse/cli.py +20 -0
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse/git.py +1 -0
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse/mcp.py +32 -1
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse/storage.py +17 -0
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse/workflow.py +160 -0
- {tinyhorse-0.2.2 → tinyhorse-0.2.4/src/tinyhorse.egg-info}/PKG-INFO +5 -1
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/tests/test_release_tool.py +1 -1
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/tests/test_storage.py +67 -0
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/tests/test_workflow.py +28 -1
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/LICENSE +0 -0
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/MANIFEST.in +0 -0
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/docs/MVP.md +0 -0
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/docs/RELEASING.md +0 -0
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/docs/STORAGE_SAFETY.md +0 -0
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/setup.cfg +0 -0
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse/__main__.py +0 -0
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse/auth.py +0 -0
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse/config.py +0 -0
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse.egg-info/SOURCES.txt +0 -0
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse.egg-info/dependency_links.txt +0 -0
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse.egg-info/entry_points.txt +0 -0
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse.egg-info/requires.txt +0 -0
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse.egg-info/top_level.txt +0 -0
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/tests/test_auth.py +0 -0
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/tests/test_git.py +0 -0
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/tests/test_help_and_tag.py +0 -0
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/tests/test_wip.py +0 -0
- {tinyhorse-0.2.2 → tinyhorse-0.2.4}/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.4
|
|
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
|
|
@@ -36,6 +36,10 @@ tinyhorse doctor
|
|
|
36
36
|
|
|
37
37
|
# Recover on another machine.
|
|
38
38
|
tinyhorse pull file:/srv/git/my-project.bundle ./my-project
|
|
39
|
+
|
|
40
|
+
# Propose without moving canonical main; accept fast-forwards then parks.
|
|
41
|
+
tinyhorse propose --id my-change
|
|
42
|
+
tinyhorse accept my-change
|
|
39
43
|
```
|
|
40
44
|
|
|
41
45
|
## Google Drive login
|
|
@@ -18,6 +18,10 @@ tinyhorse doctor
|
|
|
18
18
|
|
|
19
19
|
# Recover on another machine.
|
|
20
20
|
tinyhorse pull file:/srv/git/my-project.bundle ./my-project
|
|
21
|
+
|
|
22
|
+
# Propose without moving canonical main; accept fast-forwards then parks.
|
|
23
|
+
tinyhorse propose --id my-change
|
|
24
|
+
tinyhorse accept my-change
|
|
21
25
|
```
|
|
22
26
|
|
|
23
27
|
## Google Drive login
|
|
@@ -12,6 +12,7 @@ from .git import GitError, create_bundle, repository_state, verify_bundle
|
|
|
12
12
|
from .storage import StorageError
|
|
13
13
|
from .workflow import (
|
|
14
14
|
WorkflowError,
|
|
15
|
+
accept,
|
|
15
16
|
clone_project,
|
|
16
17
|
doctor,
|
|
17
18
|
freeze_writer,
|
|
@@ -21,6 +22,7 @@ from .workflow import (
|
|
|
21
22
|
init_project,
|
|
22
23
|
migrate_remote,
|
|
23
24
|
projects,
|
|
25
|
+
propose,
|
|
24
26
|
pull,
|
|
25
27
|
push,
|
|
26
28
|
tag_project,
|
|
@@ -146,6 +148,20 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
146
148
|
park.add_argument("path", nargs="?", default=".", help="git checkout (default: .)")
|
|
147
149
|
park.add_argument("--force", action="store_true", help=force_help)
|
|
148
150
|
|
|
151
|
+
propose_cmd = sub.add_parser(
|
|
152
|
+
"propose",
|
|
153
|
+
help="publish refs/tinyhorse/propose/<id> without moving canonical main",
|
|
154
|
+
)
|
|
155
|
+
propose_cmd.add_argument("path", nargs="?", default=".", help="git checkout (default: .)")
|
|
156
|
+
propose_cmd.add_argument("--id", dest="proposal_id", help="proposal id (default: random)")
|
|
157
|
+
|
|
158
|
+
accept_cmd = sub.add_parser(
|
|
159
|
+
"accept",
|
|
160
|
+
help="fast-forward main to a proposal ref, then park",
|
|
161
|
+
)
|
|
162
|
+
accept_cmd.add_argument("proposal_id", help="id under refs/tinyhorse/propose/")
|
|
163
|
+
accept_cmd.add_argument("path", nargs="?", default=".", help="git checkout (default: .)")
|
|
164
|
+
|
|
149
165
|
claim = sub.add_parser(
|
|
150
166
|
"claim",
|
|
151
167
|
help="tiny: restore a Drive project by name (same as clone)",
|
|
@@ -274,6 +290,10 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
274
290
|
print(init_project(args.path, args.storage, args.project))
|
|
275
291
|
elif args.command in ("push", "park"):
|
|
276
292
|
print(push(args.path, force=args.force).to_json())
|
|
293
|
+
elif args.command == "propose":
|
|
294
|
+
print(propose(args.path, proposal_id=args.proposal_id).to_json())
|
|
295
|
+
elif args.command == "accept":
|
|
296
|
+
print(accept(args.path, proposal_id=args.proposal_id).to_json())
|
|
277
297
|
elif args.command == "pull":
|
|
278
298
|
print(pull(args.storage, args.destination).to_json())
|
|
279
299
|
elif args.command == "doctor":
|
|
@@ -23,6 +23,7 @@ DURABLE_REF_PREFIXES = (
|
|
|
23
23
|
)
|
|
24
24
|
CACHE_REF_PREFIXES = ("refs/remotes/", "refs/pull/")
|
|
25
25
|
WIP_REF_PREFIX = "refs/tinyhorse/wip/"
|
|
26
|
+
PROPOSE_REF_PREFIX = "refs/tinyhorse/propose/"
|
|
26
27
|
STASH_REF = "refs/stash"
|
|
27
28
|
SEQUENCER_FILES = ("REBASE_HEAD", "MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD")
|
|
28
29
|
SEQUENCER_DIRS = ("rebase-merge", "rebase-apply")
|
|
@@ -61,6 +61,29 @@ TOOLS = [
|
|
|
61
61
|
"properties": {"path": {"type": "string"}},
|
|
62
62
|
},
|
|
63
63
|
},
|
|
64
|
+
{
|
|
65
|
+
"name": "propose",
|
|
66
|
+
"description": "Publish a proposal ref without moving canonical main.",
|
|
67
|
+
"inputSchema": {
|
|
68
|
+
"type": "object",
|
|
69
|
+
"properties": {
|
|
70
|
+
"path": {"type": "string"},
|
|
71
|
+
"proposal_id": {"type": "string"},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"name": "accept",
|
|
77
|
+
"description": "Fast-forward main to a proposal and park.",
|
|
78
|
+
"inputSchema": {
|
|
79
|
+
"type": "object",
|
|
80
|
+
"properties": {
|
|
81
|
+
"path": {"type": "string"},
|
|
82
|
+
"proposal_id": {"type": "string"},
|
|
83
|
+
},
|
|
84
|
+
"required": ["proposal_id"],
|
|
85
|
+
},
|
|
86
|
+
},
|
|
64
87
|
{
|
|
65
88
|
"name": "tag",
|
|
66
89
|
"description": "Annotated git tag on this project, then park (tinyhorse tag).",
|
|
@@ -78,7 +101,7 @@ TOOLS = [
|
|
|
78
101
|
|
|
79
102
|
HELP = """tinyhorse-mcp — stdio JSON-RPC 2.0 for Tiny Horse
|
|
80
103
|
|
|
81
|
-
Tools: park, claim, doctor, from_zip, status, tag
|
|
104
|
+
Tools: park, claim, doctor, from_zip, status, propose, accept, tag
|
|
82
105
|
|
|
83
106
|
This process reads JSON-RPC from stdin. If you ran it in a terminal by
|
|
84
107
|
mistake, this help is the intended output. Example:
|
|
@@ -123,6 +146,14 @@ def _handle(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
|
123
146
|
code, text = _run_cli(argv)
|
|
124
147
|
elif name == "status":
|
|
125
148
|
code, text = _run_cli(["status", arguments.get("path") or "."])
|
|
149
|
+
elif name == "propose":
|
|
150
|
+
argv = ["propose", arguments.get("path") or "."]
|
|
151
|
+
if arguments.get("proposal_id"):
|
|
152
|
+
argv.extend(["--id", arguments["proposal_id"]])
|
|
153
|
+
code, text = _run_cli(argv)
|
|
154
|
+
elif name == "accept":
|
|
155
|
+
argv = ["accept", arguments["proposal_id"], arguments.get("path") or "."]
|
|
156
|
+
code, text = _run_cli(argv)
|
|
126
157
|
elif name == "tag":
|
|
127
158
|
argv = ["tag", arguments["name"]]
|
|
128
159
|
if arguments.get("path"):
|
|
@@ -5,6 +5,7 @@ import json
|
|
|
5
5
|
import mimetypes
|
|
6
6
|
import os
|
|
7
7
|
import shutil
|
|
8
|
+
import time
|
|
8
9
|
import urllib.error
|
|
9
10
|
import urllib.parse
|
|
10
11
|
import urllib.request
|
|
@@ -263,6 +264,22 @@ class GoogleDriveStorage:
|
|
|
263
264
|
return self._v2_snapshot(meta["id"])
|
|
264
265
|
|
|
265
266
|
def get(self, destination: str | Path) -> StorageObject:
|
|
267
|
+
# Drive sometimes moves the etag between the snapshot and the confirm
|
|
268
|
+
# even when the bytes still match. Retry that flap inside this read.
|
|
269
|
+
# A real content change still fails the md5 check or the later If-Match.
|
|
270
|
+
last: StorageConflictError | None = None
|
|
271
|
+
for attempt in range(3):
|
|
272
|
+
try:
|
|
273
|
+
return self._get_once(destination)
|
|
274
|
+
except StorageConflictError as exc:
|
|
275
|
+
last = exc
|
|
276
|
+
if "changed while downloading" not in str(exc) or attempt == 2:
|
|
277
|
+
raise
|
|
278
|
+
time.sleep(0.2 * (attempt + 1))
|
|
279
|
+
assert last is not None
|
|
280
|
+
raise last
|
|
281
|
+
|
|
282
|
+
def _get_once(self, destination: str | Path) -> StorageObject:
|
|
266
283
|
meta = self._find()
|
|
267
284
|
if meta is None:
|
|
268
285
|
raise StorageNotFoundError(f"storage object not found: {self.locator}")
|
|
@@ -5,6 +5,7 @@ import os
|
|
|
5
5
|
import shutil
|
|
6
6
|
import subprocess
|
|
7
7
|
import tempfile
|
|
8
|
+
import time
|
|
8
9
|
import urllib.parse
|
|
9
10
|
import uuid
|
|
10
11
|
from dataclasses import asdict, dataclass
|
|
@@ -13,15 +14,18 @@ from pathlib import Path
|
|
|
13
14
|
from .config import CONFIG_NAME, load_config, write_config
|
|
14
15
|
from .git import (
|
|
15
16
|
GitError,
|
|
17
|
+
PROPOSE_REF_PREFIX,
|
|
16
18
|
bundle_head,
|
|
17
19
|
create_bundle,
|
|
18
20
|
durable_bundle_refs,
|
|
19
21
|
durable_repository_refs,
|
|
22
|
+
park_wip,
|
|
20
23
|
repository_state,
|
|
21
24
|
restore_working_tree,
|
|
22
25
|
sha256_file,
|
|
23
26
|
verify_bundle,
|
|
24
27
|
)
|
|
28
|
+
from .git import _bundle_fetch_refspecs
|
|
25
29
|
from .storage import (
|
|
26
30
|
StorageConflictError,
|
|
27
31
|
StorageError,
|
|
@@ -484,7 +488,25 @@ def push(repo: str | Path = ".", *, force: bool = False) -> SyncResult:
|
|
|
484
488
|
tags/other durable refs, and refuse when canonical HEAD is ahead or divergent.
|
|
485
489
|
``force`` permits an intentional history rewrite/ref deletion, but storage must
|
|
486
490
|
still be unchanged since the preflight read.
|
|
491
|
+
|
|
492
|
+
A preflight conflict (the download etag moved, or If-Match lost the race)
|
|
493
|
+
is retried inside this call. Readback mismatches are not retried: those
|
|
494
|
+
mean the write may already have landed.
|
|
487
495
|
"""
|
|
496
|
+
last: WorkflowError | None = None
|
|
497
|
+
for attempt in range(3):
|
|
498
|
+
try:
|
|
499
|
+
return _push_once(repo, force=force)
|
|
500
|
+
except WorkflowError as exc:
|
|
501
|
+
last = exc
|
|
502
|
+
if "during preflight" not in str(exc) or attempt == 2:
|
|
503
|
+
raise
|
|
504
|
+
time.sleep(0.5 * (attempt + 1))
|
|
505
|
+
assert last is not None
|
|
506
|
+
raise last
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _push_once(repo: str | Path = ".", *, force: bool = False) -> SyncResult:
|
|
488
510
|
if writer_frozen(repo) and not force:
|
|
489
511
|
raise WorkflowError(
|
|
490
512
|
"writer is frozen for this checkout (tinyhorse freeze-writer); "
|
|
@@ -564,6 +586,144 @@ def push(repo: str | Path = ".", *, force: bool = False) -> SyncResult:
|
|
|
564
586
|
)
|
|
565
587
|
|
|
566
588
|
|
|
589
|
+
def propose(repo: str | Path = ".", *, proposal_id: str | None = None) -> SyncResult:
|
|
590
|
+
"""Publish refs/tinyhorse/propose/<id> without moving canonical main."""
|
|
591
|
+
if writer_frozen(repo):
|
|
592
|
+
raise WorkflowError(
|
|
593
|
+
"writer is frozen for this checkout (tinyhorse freeze-writer); "
|
|
594
|
+
"remove .git/tinyhorse-freeze before propose"
|
|
595
|
+
)
|
|
596
|
+
config = load_config(repo)
|
|
597
|
+
state = repository_state(repo)
|
|
598
|
+
backend = backend_for(config.storage)
|
|
599
|
+
proposal_id = (proposal_id or uuid.uuid4().hex[:12]).strip()
|
|
600
|
+
if not proposal_id or "/" in proposal_id or ".." in proposal_id:
|
|
601
|
+
raise WorkflowError("proposal_id must be a single path segment")
|
|
602
|
+
propose_ref = f"{PROPOSE_REF_PREFIX}{proposal_id}"
|
|
603
|
+
park_wip(state.root)
|
|
604
|
+
_git(["-C", str(state.root), "update-ref", propose_ref, state.head])
|
|
605
|
+
|
|
606
|
+
with tempfile.TemporaryDirectory(prefix="tinyhorse-propose-") as td:
|
|
607
|
+
td_path = Path(td)
|
|
608
|
+
canonical_bundle = td_path / "canonical.bundle"
|
|
609
|
+
staging = td_path / "staging.git"
|
|
610
|
+
out_bundle = td_path / "out.bundle"
|
|
611
|
+
verify_after = td_path / "after.bundle"
|
|
612
|
+
try:
|
|
613
|
+
expected = backend.get(canonical_bundle)
|
|
614
|
+
except StorageNotFoundError as exc:
|
|
615
|
+
raise WorkflowError("propose requires an existing canonical; park once first") from exc
|
|
616
|
+
verify_bundle(canonical_bundle)
|
|
617
|
+
canonical_head = bundle_head(canonical_bundle)
|
|
618
|
+
|
|
619
|
+
_git(["init", "--bare", str(staging)])
|
|
620
|
+
specs = _bundle_fetch_refspecs(canonical_bundle)
|
|
621
|
+
_git(
|
|
622
|
+
[
|
|
623
|
+
"-C",
|
|
624
|
+
str(staging),
|
|
625
|
+
"fetch",
|
|
626
|
+
"--quiet",
|
|
627
|
+
"--prune",
|
|
628
|
+
"--no-tags",
|
|
629
|
+
str(canonical_bundle),
|
|
630
|
+
*specs,
|
|
631
|
+
]
|
|
632
|
+
)
|
|
633
|
+
_git(
|
|
634
|
+
[
|
|
635
|
+
"-C",
|
|
636
|
+
str(staging),
|
|
637
|
+
"fetch",
|
|
638
|
+
"--quiet",
|
|
639
|
+
str(state.root),
|
|
640
|
+
f"+{propose_ref}:{propose_ref}",
|
|
641
|
+
]
|
|
642
|
+
)
|
|
643
|
+
try:
|
|
644
|
+
_git(
|
|
645
|
+
[
|
|
646
|
+
"-C",
|
|
647
|
+
str(staging),
|
|
648
|
+
"fetch",
|
|
649
|
+
"--quiet",
|
|
650
|
+
str(state.root),
|
|
651
|
+
"+refs/tinyhorse/wip/*:refs/tinyhorse/wip/*",
|
|
652
|
+
]
|
|
653
|
+
)
|
|
654
|
+
except WorkflowError:
|
|
655
|
+
pass
|
|
656
|
+
_git(["-C", str(staging), "symbolic-ref", "HEAD", "refs/heads/main"])
|
|
657
|
+
refs = _git(
|
|
658
|
+
[
|
|
659
|
+
"-C",
|
|
660
|
+
str(staging),
|
|
661
|
+
"for-each-ref",
|
|
662
|
+
"--format=%(refname)",
|
|
663
|
+
"refs/heads",
|
|
664
|
+
"refs/tags",
|
|
665
|
+
"refs/tinyhorse",
|
|
666
|
+
]
|
|
667
|
+
).splitlines()
|
|
668
|
+
revisions = ["HEAD", *[ref for ref in refs if ref]]
|
|
669
|
+
_git(["-C", str(staging), "bundle", "create", str(out_bundle), *revisions])
|
|
670
|
+
verify_bundle(out_bundle)
|
|
671
|
+
if bundle_head(out_bundle) != canonical_head:
|
|
672
|
+
raise WorkflowError("propose would move canonical HEAD; aborted without writing storage")
|
|
673
|
+
local_digest = sha256_file(out_bundle)
|
|
674
|
+
try:
|
|
675
|
+
obj = backend.put_if_unchanged(out_bundle, expected)
|
|
676
|
+
except StorageConflictError as exc:
|
|
677
|
+
raise WorkflowError(
|
|
678
|
+
"propose aborted because canonical storage changed during preflight"
|
|
679
|
+
) from exc
|
|
680
|
+
after_obj = backend.get(verify_after)
|
|
681
|
+
if sha256_file(verify_after) != local_digest:
|
|
682
|
+
raise WorkflowError("canonical readback bytes do not match the uploaded proposal bundle")
|
|
683
|
+
verify_bundle(verify_after)
|
|
684
|
+
after_head = bundle_head(verify_after)
|
|
685
|
+
if after_head != canonical_head:
|
|
686
|
+
raise WorkflowError("propose moved canonical HEAD; run tinyhorse doctor")
|
|
687
|
+
|
|
688
|
+
return SyncResult(
|
|
689
|
+
action="propose",
|
|
690
|
+
storage=config.storage,
|
|
691
|
+
local_head=state.head,
|
|
692
|
+
canonical_head=after_head,
|
|
693
|
+
canonical_modified_time=after_obj.modified_time or obj.modified_time,
|
|
694
|
+
canonical_size=after_obj.size if after_obj.size is not None else obj.size,
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def accept(repo: str | Path = ".", proposal_id: str | None = None) -> SyncResult:
|
|
699
|
+
"""Fast-forward local main to a proposal ref, then park (moves canonical main)."""
|
|
700
|
+
if not proposal_id:
|
|
701
|
+
raise WorkflowError("accept requires a proposal_id")
|
|
702
|
+
if writer_frozen(repo):
|
|
703
|
+
raise WorkflowError(
|
|
704
|
+
"writer is frozen for this checkout (tinyhorse freeze-writer); "
|
|
705
|
+
"unfreeze before accept"
|
|
706
|
+
)
|
|
707
|
+
config = load_config(repo)
|
|
708
|
+
state = repository_state(repo)
|
|
709
|
+
backend = backend_for(config.storage)
|
|
710
|
+
propose_ref = f"{PROPOSE_REF_PREFIX}{proposal_id.strip()}"
|
|
711
|
+
with tempfile.TemporaryDirectory(prefix="tinyhorse-accept-") as td:
|
|
712
|
+
canonical_bundle = Path(td) / "canonical.bundle"
|
|
713
|
+
try:
|
|
714
|
+
backend.get(canonical_bundle)
|
|
715
|
+
except StorageNotFoundError as exc:
|
|
716
|
+
raise WorkflowError("accept requires an existing canonical") from exc
|
|
717
|
+
verify_bundle(canonical_bundle)
|
|
718
|
+
refs = durable_bundle_refs(canonical_bundle)
|
|
719
|
+
oid = refs.get(propose_ref)
|
|
720
|
+
if not oid:
|
|
721
|
+
raise WorkflowError(f"no such proposal: {propose_ref}")
|
|
722
|
+
_git(["-C", str(state.root), "fetch", "--quiet", str(canonical_bundle), f"+{propose_ref}:{propose_ref}"])
|
|
723
|
+
_git(["-C", str(state.root), "merge", "--ff-only", oid])
|
|
724
|
+
return push(repo)
|
|
725
|
+
|
|
726
|
+
|
|
567
727
|
def _head_branch(heads: dict[str, str]) -> str | None:
|
|
568
728
|
head = heads.get("HEAD")
|
|
569
729
|
if head is None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tinyhorse
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
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
|
|
@@ -36,6 +36,10 @@ tinyhorse doctor
|
|
|
36
36
|
|
|
37
37
|
# Recover on another machine.
|
|
38
38
|
tinyhorse pull file:/srv/git/my-project.bundle ./my-project
|
|
39
|
+
|
|
40
|
+
# Propose without moving canonical main; accept fast-forwards then parks.
|
|
41
|
+
tinyhorse propose --id my-change
|
|
42
|
+
tinyhorse accept my-change
|
|
39
43
|
```
|
|
40
44
|
|
|
41
45
|
## Google Drive login
|
|
@@ -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.4")
|
|
28
28
|
|
|
29
29
|
def test_manifest_includes_release_tooling(self):
|
|
30
30
|
manifest = (ROOT / "MANIFEST.in").read_text(encoding="utf-8")
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import hashlib
|
|
1
2
|
import json
|
|
2
3
|
import os
|
|
3
4
|
import tempfile
|
|
@@ -430,6 +431,72 @@ class StorageConflictTests(unittest.TestCase):
|
|
|
430
431
|
storage.put_if_unchanged(source, expected)
|
|
431
432
|
self.assertIn("snapshot ETag", str(ctx.exception))
|
|
432
433
|
|
|
434
|
+
def test_google_drive_get_retries_when_etag_moves_during_download(self):
|
|
435
|
+
from tinyhorse.storage import StorageConflictError
|
|
436
|
+
|
|
437
|
+
content = b"bundle-bytes"
|
|
438
|
+
digest = hashlib.md5(content).hexdigest()
|
|
439
|
+
storage = GoogleDriveStorage("gdrive://folder/project.bundle", token="token")
|
|
440
|
+
listed = {
|
|
441
|
+
"id": "same-id",
|
|
442
|
+
"name": "project.bundle",
|
|
443
|
+
"size": str(len(content)),
|
|
444
|
+
"parents": ["folder"],
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
def snap(etag, version):
|
|
448
|
+
return {
|
|
449
|
+
"id": "same-id",
|
|
450
|
+
"title": "project.bundle",
|
|
451
|
+
"etag": etag,
|
|
452
|
+
"version": version,
|
|
453
|
+
"md5Checksum": digest,
|
|
454
|
+
"fileSize": str(len(content)),
|
|
455
|
+
"modifiedDate": "2026-09-22T00:00:00.000Z",
|
|
456
|
+
"parents": [{"id": "folder"}],
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
# First read: confirm etag moved. Second read: snapshot and confirm agree.
|
|
460
|
+
v2 = [
|
|
461
|
+
snap('"e1"', "10"),
|
|
462
|
+
snap('"e2"', "11"),
|
|
463
|
+
snap('"e2"', "11"),
|
|
464
|
+
snap('"e2"', "11"),
|
|
465
|
+
]
|
|
466
|
+
|
|
467
|
+
def fake_request(url, *, method="GET", data=None, content_type=None, extra_headers=None, response_headers=None):
|
|
468
|
+
if "alt=media" in url:
|
|
469
|
+
return content
|
|
470
|
+
if "/drive/v2/files/" in url:
|
|
471
|
+
return json.dumps(v2.pop(0)).encode()
|
|
472
|
+
return json.dumps({"files": [listed]}).encode()
|
|
473
|
+
|
|
474
|
+
with tempfile.TemporaryDirectory() as td:
|
|
475
|
+
dest = Path(td) / "got.bundle"
|
|
476
|
+
with patch.object(storage, "_request", side_effect=fake_request):
|
|
477
|
+
with patch("tinyhorse.storage.time.sleep"):
|
|
478
|
+
result = storage.get(dest)
|
|
479
|
+
self.assertEqual(dest.read_bytes(), content)
|
|
480
|
+
self.assertEqual(result.etag, '"e2"')
|
|
481
|
+
self.assertEqual(v2, [])
|
|
482
|
+
|
|
483
|
+
storage = GoogleDriveStorage("gdrive://folder/project.bundle", token="token")
|
|
484
|
+
flapping = [snap('"a"', "1"), snap('"b"', "2")] * 3
|
|
485
|
+
|
|
486
|
+
def always_flap(url, *, method="GET", data=None, content_type=None, extra_headers=None, response_headers=None):
|
|
487
|
+
if "alt=media" in url:
|
|
488
|
+
return content
|
|
489
|
+
if "/drive/v2/files/" in url:
|
|
490
|
+
return json.dumps(flapping.pop(0)).encode()
|
|
491
|
+
return json.dumps({"files": [listed]}).encode()
|
|
492
|
+
|
|
493
|
+
with tempfile.TemporaryDirectory() as td:
|
|
494
|
+
dest = Path(td) / "got.bundle"
|
|
495
|
+
with patch.object(storage, "_request", side_effect=always_flap):
|
|
496
|
+
with patch("tinyhorse.storage.time.sleep"):
|
|
497
|
+
with self.assertRaises(StorageConflictError):
|
|
498
|
+
storage.get(dest)
|
|
499
|
+
|
|
433
500
|
def test_drive_http_412_is_storage_conflict(self):
|
|
434
501
|
from tinyhorse.storage import StorageConflictError, _drive_request
|
|
435
502
|
|
|
@@ -8,7 +8,8 @@ from pathlib import Path
|
|
|
8
8
|
from unittest.mock import patch
|
|
9
9
|
|
|
10
10
|
from tinyhorse.git import repository_state
|
|
11
|
-
from tinyhorse.
|
|
11
|
+
from tinyhorse.git import durable_bundle_refs
|
|
12
|
+
from tinyhorse.workflow import WorkflowError, accept, doctor, init_project, propose, pull, push
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
def git_env(home: Path) -> dict[str, str]:
|
|
@@ -99,6 +100,32 @@ class WorkflowTests(unittest.TestCase):
|
|
|
99
100
|
self.assertFalse(health.ok)
|
|
100
101
|
self.assertEqual(health.relation, "local_ahead")
|
|
101
102
|
|
|
103
|
+
def test_propose_does_not_move_canonical_main(self):
|
|
104
|
+
with tempfile.TemporaryDirectory() as td:
|
|
105
|
+
root = Path(td)
|
|
106
|
+
repo = make_repo(root / "repo")
|
|
107
|
+
bundle = root / "repo.bundle"
|
|
108
|
+
init_project(repo, bundle.as_uri())
|
|
109
|
+
subprocess.run(["git", "add", ".tinyhorse.toml"], cwd=repo, check=True)
|
|
110
|
+
subprocess.run(["git", "commit", "-m", "config"], cwd=repo, check=True, stdout=subprocess.PIPE)
|
|
111
|
+
first = push(repo)
|
|
112
|
+
(repo / "idea.txt").write_text("proposal\n")
|
|
113
|
+
subprocess.run(["git", "add", "idea.txt"], cwd=repo, check=True)
|
|
114
|
+
subprocess.run(["git", "commit", "-m", "proposal"], cwd=repo, check=True, stdout=subprocess.PIPE)
|
|
115
|
+
local_head = subprocess.run(
|
|
116
|
+
["git", "rev-parse", "HEAD"], cwd=repo, check=True, text=True, stdout=subprocess.PIPE
|
|
117
|
+
).stdout.strip()
|
|
118
|
+
proposed = propose(repo, proposal_id="abc123")
|
|
119
|
+
self.assertEqual(proposed.action, "propose")
|
|
120
|
+
self.assertEqual(proposed.canonical_head, first.canonical_head)
|
|
121
|
+
self.assertNotEqual(local_head, first.canonical_head)
|
|
122
|
+
refs = durable_bundle_refs(bundle)
|
|
123
|
+
self.assertEqual(refs["HEAD"], first.canonical_head)
|
|
124
|
+
self.assertEqual(refs["refs/tinyhorse/propose/abc123"], local_head)
|
|
125
|
+
accepted = accept(repo, proposal_id="abc123")
|
|
126
|
+
self.assertEqual(accepted.canonical_head, local_head)
|
|
127
|
+
self.assertEqual(durable_bundle_refs(bundle)["HEAD"], local_head)
|
|
128
|
+
|
|
102
129
|
|
|
103
130
|
if __name__ == "__main__":
|
|
104
131
|
unittest.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
|