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.
Files changed (32) hide show
  1. {tinyhorse-0.2.2/src/tinyhorse.egg-info → tinyhorse-0.2.4}/PKG-INFO +5 -1
  2. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/README.md +4 -0
  3. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/pyproject.toml +1 -1
  4. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse/__init__.py +1 -1
  5. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse/cli.py +20 -0
  6. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse/git.py +1 -0
  7. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse/mcp.py +32 -1
  8. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse/storage.py +17 -0
  9. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse/workflow.py +160 -0
  10. {tinyhorse-0.2.2 → tinyhorse-0.2.4/src/tinyhorse.egg-info}/PKG-INFO +5 -1
  11. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/tests/test_release_tool.py +1 -1
  12. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/tests/test_storage.py +67 -0
  13. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/tests/test_workflow.py +28 -1
  14. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/LICENSE +0 -0
  15. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/MANIFEST.in +0 -0
  16. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/docs/MVP.md +0 -0
  17. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/docs/RELEASING.md +0 -0
  18. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/docs/STORAGE_SAFETY.md +0 -0
  19. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/setup.cfg +0 -0
  20. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse/__main__.py +0 -0
  21. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse/auth.py +0 -0
  22. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse/config.py +0 -0
  23. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse.egg-info/SOURCES.txt +0 -0
  24. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse.egg-info/dependency_links.txt +0 -0
  25. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse.egg-info/entry_points.txt +0 -0
  26. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse.egg-info/requires.txt +0 -0
  27. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/src/tinyhorse.egg-info/top_level.txt +0 -0
  28. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/tests/test_auth.py +0 -0
  29. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/tests/test_git.py +0 -0
  30. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/tests/test_help_and_tag.py +0 -0
  31. {tinyhorse-0.2.2 → tinyhorse-0.2.4}/tests/test_wip.py +0 -0
  32. {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.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "tinyhorse"
7
- version = "0.2.2"
7
+ version = "0.2.4"
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.2"
3
+ __version__ = "0.2.4"
@@ -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.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.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.workflow import WorkflowError, doctor, init_project, pull, push
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