ctrlrelay 0.11.1__tar.gz → 0.11.3__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 (122) hide show
  1. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/CHANGELOG.md +45 -1
  2. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/PKG-INFO +1 -1
  3. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/pyproject.toml +1 -1
  4. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/worktree.py +121 -8
  5. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_worktree.py +228 -3
  6. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  7. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  8. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  9. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/.github/dependabot.yml +0 -0
  10. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/.github/workflows/build.yml +0 -0
  11. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/.github/workflows/cla.yml +0 -0
  12. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/.github/workflows/pages.yml +0 -0
  13. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/.github/workflows/publish.yml +0 -0
  14. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/.github/workflows/test.yml +0 -0
  15. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/.gitignore +0 -0
  16. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/CODE_OF_CONDUCT.md +0 -0
  17. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/CONTRIBUTING.md +0 -0
  18. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/LICENSE +0 -0
  19. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/README.md +0 -0
  20. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/SECURITY.md +0 -0
  21. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/config/orchestrator.yaml.example +0 -0
  22. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/docs/Gemfile +0 -0
  23. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/docs/_config.yml +0 -0
  24. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/docs/architecture.md +0 -0
  25. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/docs/bridge.md +0 -0
  26. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/docs/cli.md +0 -0
  27. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/docs/configuration.md +0 -0
  28. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/docs/development.md +0 -0
  29. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/docs/feedback-loop.md +0 -0
  30. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/docs/getting-started.md +0 -0
  31. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/docs/index.md +0 -0
  32. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/docs/operations.md +0 -0
  33. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/docs/personalization.md +0 -0
  34. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/__init__.py +0 -0
  35. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/bridge/__init__.py +0 -0
  36. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/bridge/__main__.py +0 -0
  37. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/bridge/protocol.py +0 -0
  38. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/bridge/server.py +0 -0
  39. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  40. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/cli.py +0 -0
  41. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/__init__.py +0 -0
  42. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/archived.py +0 -0
  43. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/audit.py +0 -0
  44. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/checkpoint.py +0 -0
  45. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/code_review.py +0 -0
  46. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/config.py +0 -0
  47. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/dispatcher.py +0 -0
  48. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/github.py +0 -0
  49. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/network.py +0 -0
  50. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/obs.py +0 -0
  51. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/poller.py +0 -0
  52. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/pr_verifier.py +0 -0
  53. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/pr_watcher.py +0 -0
  54. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/question_expiry.py +0 -0
  55. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/scheduler.py +0 -0
  56. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/state.py +0 -0
  57. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/dashboard/__init__.py +0 -0
  58. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/dashboard/client.py +0 -0
  59. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/gh_protocol.py +0 -0
  60. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/install.py +0 -0
  61. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/personalization/__init__.py +0 -0
  62. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/personalization/manager.py +0 -0
  63. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/personalization/paths.py +0 -0
  64. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/pipelines/__init__.py +0 -0
  65. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/pipelines/base.py +0 -0
  66. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/pipelines/dev.py +0 -0
  67. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  68. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/pipelines/secops.py +0 -0
  69. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/pipelines/task.py +0 -0
  70. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/setup.py +0 -0
  71. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/templates/__init__.py +0 -0
  72. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  73. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  74. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  75. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  76. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  77. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  78. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  79. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/transports/__init__.py +0 -0
  80. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/transports/base.py +0 -0
  81. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/transports/file_mock.py +0 -0
  82. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/transports/socket_client.py +0 -0
  83. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/__init__.py +0 -0
  84. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/conftest.py +0 -0
  85. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_archived.py +0 -0
  86. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_audit.py +0 -0
  87. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_bridge_protocol.py +0 -0
  88. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_bridge_server.py +0 -0
  89. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_checkpoint.py +0 -0
  90. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_cli_ci_wait.py +0 -0
  91. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_cli_dev.py +0 -0
  92. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_cli_repos.py +0 -0
  93. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_cli_secops.py +0 -0
  94. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_cli_start.py +0 -0
  95. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_cli_version.py +0 -0
  96. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_code_review.py +0 -0
  97. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_config.py +0 -0
  98. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_dashboard_client.py +0 -0
  99. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_dev_integration.py +0 -0
  100. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_dev_pipeline.py +0 -0
  101. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_dispatcher.py +0 -0
  102. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_docs_site.py +0 -0
  103. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_gh_protocol.py +0 -0
  104. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_github.py +0 -0
  105. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_install.py +0 -0
  106. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_network.py +0 -0
  107. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_obs.py +0 -0
  108. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_personalization.py +0 -0
  109. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_pipeline_base.py +0 -0
  110. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_poller.py +0 -0
  111. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_post_merge.py +0 -0
  112. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_pr_verifier.py +0 -0
  113. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_pr_watcher.py +0 -0
  114. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_question_expiry.py +0 -0
  115. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_scheduler.py +0 -0
  116. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_secops_integration.py +0 -0
  117. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_secops_pipeline.py +0 -0
  118. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_setup.py +0 -0
  119. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_state.py +0 -0
  120. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_task_pipeline.py +0 -0
  121. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_telegram_handler.py +0 -0
  122. {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_transport.py +0 -0
@@ -7,6 +7,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.11.3] - 2026-09-12
11
+
12
+ ### Fixed
13
+
14
+ - **A force-pushed default branch fails the fetch again.** v0.11.2
15
+ tolerated every non-fast-forward rejection so a rebased Dependabot
16
+ branch would stop aborting sweeps. That also covered the default
17
+ branch, which sessions are cut from: after a force-push to `main` on
18
+ GitHub, the bare repo would keep its old `main` and every session
19
+ would run on a tree origin no longer has, with only a log line to show
20
+ for it. A rejection of the default branch now raises
21
+ `default branch diverged from origin`; other branches are still
22
+ tolerated. Closes #120.
23
+
24
+ ## [0.11.2] - 2026-09-10
25
+
26
+ ### Fixed
27
+
28
+ - **A rebased Dependabot branch no longer fails the whole sweep.** The
29
+ bare-repo fetch uses a non-force refspec so it cannot discard a prior
30
+ session's unpushed commits. Dependabot rebases its branches onto a
31
+ moved base, which rewrites their tips — so every rebase became a
32
+ `non-fast-forward` rejection, and `git fetch` exits 1 on a declined
33
+ rewind even when every other ref applied. `ensure_bare_repo` read that
34
+ exit code as failure and aborted the session.
35
+
36
+ Observed on `AInvirion/zzsites`, whose sweep died reporting
37
+ `WorktreeError: git failed: ... ! [rejected] ... (non-fast-forward)`
38
+ in the same fetch that had just advanced `main`. Twelve bare repos
39
+ were holding a `dependabot/*` ref at the time, each one primed to fail
40
+ the moment Dependabot rebased it.
41
+
42
+ `dependabot/*` is now fetched with a force refspec first — those
43
+ branches are bot-authored and never carry local work, so rewinding one
44
+ destroys nothing. Any rejection that survives is logged as
45
+ `worktree.fetch.non_fast_forward` and tolerated rather than raised,
46
+ because a declined rewind is the refspec doing its job. Real failures
47
+ still raise: a `fatal:`/`error:` line, a rejection for any other
48
+ reason, or an unexplained non-zero exit. Failing open there would turn
49
+ an unreachable remote into a silent no-op and run pipelines against a
50
+ stale tree.
51
+
10
52
  ## [0.11.1] - 2026-09-09
11
53
 
12
54
  ### Fixed
@@ -1616,7 +1658,9 @@ pipeline).
1616
1658
  per-phase implementation plans (Phase 0 through Phase 4).
1617
1659
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
1618
1660
 
1619
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.1...HEAD
1661
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.3...HEAD
1662
+ [0.11.3]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.2...v0.11.3
1663
+ [0.11.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.1...v0.11.2
1620
1664
  [0.11.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.0...v0.11.1
1621
1665
  [0.11.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.10.0...v0.11.0
1622
1666
  [0.10.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.9.0...v0.10.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.11.1
3
+ Version: 0.11.3
4
4
  Summary: Local-first orchestrator for headless coding agents across multiple GitHub repos
5
5
  Project-URL: Homepage, https://github.com/AInvirion/ctrlrelay
6
6
  Project-URL: Documentation, https://ainvirion.github.io/ctrlrelay/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.11.1"
3
+ version = "0.11.3"
4
4
  description = "Local-first orchestrator for headless coding agents across multiple GitHub repos"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -8,9 +8,13 @@ from dataclasses import dataclass
8
8
  from pathlib import Path
9
9
  from typing import TYPE_CHECKING
10
10
 
11
+ from ctrlrelay.core.obs import get_logger, log_event
12
+
11
13
  if TYPE_CHECKING:
12
14
  from ctrlrelay.core.github import GitHubCLI
13
15
 
16
+ _logger = get_logger("core.worktree")
17
+
14
18
 
15
19
  # Open-PR probe (issue #52) retries a handful of times before failing
16
20
  # closed. Transient gh/network errors are common enough that one-shot
@@ -36,6 +40,49 @@ _PR_PROBE_TIMEOUT_SECONDS = 10
36
40
  # genuinely stuck, not merely large.
37
41
  _GIT_TRANSFER_TIMEOUT_SECONDS = 1800
38
42
 
43
+ # Branches ctrlrelay never authors, so rewinding one can never destroy
44
+ # local work. Dependabot rebases its branches onto a moved base, which
45
+ # rewrites their tips — under a non-force refspec every such rebase is
46
+ # a non-fast-forward rejection.
47
+ _BOT_BRANCH_REFSPEC = "+refs/heads/dependabot/*:refs/heads/dependabot/*"
48
+
49
+
50
+ def _rejections_are_only_non_fast_forward(stderr: str) -> list[str]:
51
+ """Return the refs a fetch declined to rewind, or ``[]`` if the
52
+ failure was anything else.
53
+
54
+ A non-fast-forward rejection is the non-force refspec doing its job:
55
+ it refused to discard local commits. Git still exits 1, which the
56
+ caller must not read as "the fetch failed" — the rest of the fetch
57
+ (the default branch, prunes, new branches) applied normally.
58
+
59
+ Anything git considers a real error — ``fatal:`` on a dead remote,
60
+ ``error:`` on a bad object, or a rejection for some other reason —
61
+ returns ``[]`` so the caller raises as before. Failing open here
62
+ would turn an unreachable remote into a silent no-op and let
63
+ pipelines run against a stale tree, which is the bug this module's
64
+ explicit refspec was added to prevent in the first place.
65
+ """
66
+ refs: list[str] = []
67
+ for raw in stderr.splitlines():
68
+ line = raw.strip()
69
+ if not line:
70
+ continue
71
+ low = line.lower()
72
+ if low.startswith(("fatal:", "error:")):
73
+ return []
74
+ if not line.startswith("!"):
75
+ continue # progress line: "From ...", " * [new branch]", " abc..def"
76
+ if "non-fast-forward" not in low and "non-fast forward" not in low:
77
+ return [] # rejected for some other reason — not ours to swallow
78
+ # " ! [rejected] <src> -> <dst> (non-fast-forward)"
79
+ body = line.lstrip("! ")
80
+ if "]" in body:
81
+ body = body.split("]", 1)[1]
82
+ target = body.split("->")[-1].split("(")[0].strip()
83
+ refs.append(target or body.strip())
84
+ return refs
85
+
39
86
 
40
87
  class WorktreeError(Exception):
41
88
  """Raised when worktree operations fail."""
@@ -74,10 +121,23 @@ class WorktreeManager:
74
121
  *args: str,
75
122
  cwd: Path | None = None,
76
123
  timeout: int | None = None,
124
+ tolerate_non_fast_forward: bool = False,
125
+ protected_refs: frozenset[str] = frozenset(),
77
126
  ) -> str:
78
127
  """Run git command and return stdout. `timeout` overrides self.timeout
79
128
  for one call — useful for cheap probes that shouldn't inherit the full
80
- 120s default when network is flaky."""
129
+ 120s default when network is flaky.
130
+
131
+ ``tolerate_non_fast_forward`` is for fetches under a non-force
132
+ refspec: git exits 1 when it declines to rewind a diverged local
133
+ branch, even though every other ref updated. See
134
+ :func:`_rejections_are_only_non_fast_forward`.
135
+
136
+ ``protected_refs`` names branches whose rejection is never
137
+ tolerated. A declined rewind of the default branch means local
138
+ and origin have diverged there, and every worktree cut from it
139
+ would run against a tree origin no longer has.
140
+ """
81
141
  cmd = ["git", *args]
82
142
  effective_timeout = timeout if timeout is not None else self.timeout
83
143
  proc = await asyncio.create_subprocess_exec(
@@ -124,7 +184,28 @@ class WorktreeManager:
124
184
  raise
125
185
 
126
186
  if proc.returncode != 0:
127
- raise WorktreeError(f"git failed: {stderr.decode().strip()}")
187
+ err = stderr.decode().strip()
188
+ declined = (
189
+ _rejections_are_only_non_fast_forward(err)
190
+ if tolerate_non_fast_forward
191
+ else []
192
+ )
193
+ if not declined:
194
+ raise WorktreeError(f"git failed: {err}")
195
+ stuck = sorted(set(declined) & protected_refs)
196
+ if stuck:
197
+ raise WorktreeError(
198
+ f"git failed: default branch diverged from origin "
199
+ f"({', '.join(stuck)}); refusing to use a stale tree: "
200
+ f"{err}"
201
+ )
202
+ log_event(
203
+ _logger,
204
+ "worktree.fetch.non_fast_forward",
205
+ cwd=str(cwd) if cwd is not None else None,
206
+ refs=declined,
207
+ ref_count=len(declined),
208
+ )
128
209
 
129
210
  return stdout.decode()
130
211
 
@@ -766,12 +847,11 @@ class WorktreeManager:
766
847
  test counts from a commit that was two weeks old.
767
848
 
768
849
  Refspec: ``refs/heads/*:refs/heads/*`` (no leading ``+``).
769
- Fast-forward-only on purpose — codex caught this on the
770
- first pass: a force update would clobber the dev pipeline's
771
- ``create_worktree_with_new_branch`` reuse path, which keeps
772
- local-ahead-of-origin commits when a prior session pushed
773
- partial work. Non-force semantics give us the right
774
- behavior in all three cases:
850
+ Fast-forward-only on purpose: a force update would clobber
851
+ the dev pipeline's ``create_worktree_with_new_branch`` reuse
852
+ path, which keeps local-ahead-of-origin commits when a prior
853
+ session pushed partial work. Non-force semantics give us the
854
+ right behavior in all three cases:
775
855
 
776
856
  - Local branch BEHIND origin (default-branch case, the bug
777
857
  we're fixing): fast-forward applies, local catches up.
@@ -782,15 +862,48 @@ class WorktreeManager:
782
862
 
783
863
  ``--prune`` drops refs that no longer exist on origin so
784
864
  deleted branches don't linger forever.
865
+
866
+ Two things keep that non-force refspec from failing the
867
+ caller, both learned from a secops sweep that died on
868
+ ``AInvirion/zzsites`` with ``main`` already updated:
869
+
870
+ - ``dependabot/*`` is fetched with a force refspec first.
871
+ Dependabot rebases its branches, which rewrites their tips,
872
+ so under a plain refspec every rebase is a rejection. These
873
+ branches are bot-authored and never carry local work, so
874
+ rewinding one destroys nothing. Twelve bare repos were
875
+ holding such a ref when this was found.
876
+ - Any rejection that survives is tolerated rather than raised.
877
+ Git exits 1 on a declined rewind even when every other ref
878
+ applied, and that exit code was aborting the whole session
879
+ for a branch it had correctly protected.
880
+
881
+ The default branch is the exception: a rejection there still
882
+ raises. Sessions are cut from it, so tolerating that one would
883
+ swap a loud failure for work done quietly on a stale tree —
884
+ e.g. after a force-push to ``main`` on GitHub.
785
885
  """
786
886
  bare_path = self._get_bare_repo_path(repo)
787
887
 
788
888
  if bare_path.exists():
889
+ try:
890
+ default_branch = await self.get_default_branch(repo)
891
+ except WorktreeError:
892
+ # A bare repo with no symbolic HEAD cannot name a default
893
+ # branch to protect. create_worktree fails on the same
894
+ # lookup, so the stale-tree risk never reaches a session.
895
+ default_branch = ""
789
896
  await self._run_git(
790
897
  "fetch", "--prune", "origin",
898
+ _BOT_BRANCH_REFSPEC,
791
899
  "refs/heads/*:refs/heads/*",
792
900
  cwd=bare_path,
793
901
  timeout=_GIT_TRANSFER_TIMEOUT_SECONDS,
902
+ tolerate_non_fast_forward=True,
903
+ protected_refs=(
904
+ frozenset({default_branch}) if default_branch
905
+ else frozenset()
906
+ ),
794
907
  )
795
908
  else:
796
909
  await self._run_git(
@@ -79,12 +79,14 @@ class TestWorktreeManager:
79
79
  mock_git.return_value = ""
80
80
  await manager.ensure_bare_repo("owner/repo")
81
81
 
82
- assert mock_git.await_count == 1
82
+ # symbolic-ref (default-branch lookup), then the fetch.
83
+ assert mock_git.await_count == 2
84
+ assert mock_git.await_args_list[0].args[0] == "symbolic-ref"
83
85
  args = mock_git.await_args.args
84
86
  # Must be a fetch, not --all, with the explicit refspec.
85
87
  assert args[0] == "fetch"
86
88
  assert "origin" in args
87
- # Non-force refspec on purpose: codex P1 — a force fetch
89
+ # Non-force refspec on purpose: a force fetch
88
90
  # would destroy unpushed local commits in the dev pipeline's
89
91
  # branch-reuse flow. Fast-forward-only fixes the stale-
90
92
  # default-branch case while leaving divergent dev branches
@@ -1597,5 +1599,228 @@ class TestGitTimeoutIsDiagnosable:
1597
1599
  wt._get_bare_repo_path("owner/repo").mkdir(parents=True, exist_ok=True)
1598
1600
  wt._run_git.reset_mock()
1599
1601
  await wt.ensure_bare_repo("owner/repo")
1600
- fetch_kwargs = wt._run_git.await_args_list[0].kwargs
1602
+ fetch_kwargs = next(
1603
+ c.kwargs for c in wt._run_git.await_args_list
1604
+ if c.args and c.args[0] == "fetch"
1605
+ )
1601
1606
  assert fetch_kwargs["timeout"] == _GIT_TRANSFER_TIMEOUT_SECONDS
1607
+
1608
+
1609
+ class TestFetchSurvivesRewrittenBranches:
1610
+ """A secops sweep died on `AInvirion/zzsites` with the message
1611
+ `WorktreeError: git failed: ... ! [rejected] ... (non-fast-forward)`
1612
+ — while the same fetch had already advanced `main`. Dependabot
1613
+ rebases its branches, which rewrites their tips, so under the
1614
+ non-force refspec every rebase is a rejection and `git fetch`
1615
+ exits 1. Twelve bare repos held such a ref when this was found.
1616
+ """
1617
+
1618
+ @staticmethod
1619
+ def _git(*args: str, cwd: Path) -> str:
1620
+ import subprocess
1621
+
1622
+ env = {
1623
+ "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
1624
+ "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
1625
+ "PATH": __import__("os").environ.get("PATH", ""),
1626
+ "HOME": str(cwd),
1627
+ }
1628
+ return subprocess.run(
1629
+ ["git", *args], cwd=cwd, env=env,
1630
+ capture_output=True, text=True, check=True,
1631
+ ).stdout
1632
+
1633
+ @classmethod
1634
+ def _build_repos(cls, tmp_path: Path) -> tuple[Path, Path]:
1635
+ """origin with a rewritten dependabot branch and a rewritten
1636
+ `fix/` branch; a bare clone holding its own commit on each."""
1637
+ import subprocess
1638
+
1639
+ origin = tmp_path / "origin"
1640
+ origin.mkdir()
1641
+ cls._git("init", "-q", "-b", "main", ".", cwd=origin)
1642
+ (origin / "f").write_text("base\n")
1643
+ cls._git("add", ".", cwd=origin)
1644
+ cls._git("commit", "-qm", "base", cwd=origin)
1645
+ cls._git("branch", "dependabot/uv/pkg-1.0", cwd=origin)
1646
+ cls._git("branch", "fix/issue-1", cwd=origin)
1647
+
1648
+ bare = tmp_path / "bare.git"
1649
+ subprocess.run(
1650
+ ["git", "clone", "-q", "--bare", str(origin), str(bare)],
1651
+ check=True, capture_output=True,
1652
+ )
1653
+
1654
+ # Rewrite both branches on origin so neither is a descendant of
1655
+ # what the bare clone now holds.
1656
+ for br, text in (
1657
+ ("dependabot/uv/pkg-1.0", "rebased"), ("fix/issue-1", "forced"),
1658
+ ):
1659
+ cls._git("checkout", "-q", br, cwd=origin)
1660
+ (origin / "f").write_text(text + "\n")
1661
+ cls._git("commit", "-qam", "v1", cwd=origin)
1662
+ cls._git("commit", "-q", "--amend", "-m", br, cwd=origin)
1663
+
1664
+ # Give the bare repo a local-only commit on each branch.
1665
+ main_tree = cls._git(
1666
+ "rev-parse", "refs/heads/main^{tree}", cwd=bare
1667
+ ).strip()
1668
+ for br in ("dependabot/uv/pkg-1.0", "fix/issue-1"):
1669
+ sha = cls._git(
1670
+ "commit-tree", main_tree, "-p", "refs/heads/main",
1671
+ "-m", f"local work on {br}", cwd=bare,
1672
+ ).strip()
1673
+ cls._git("update-ref", f"refs/heads/{br}", sha, cwd=bare)
1674
+ return origin, bare
1675
+
1676
+ @staticmethod
1677
+ def _sha(bare: Path, ref: str) -> str:
1678
+ import subprocess
1679
+
1680
+ return subprocess.run(
1681
+ ["git", "rev-parse", ref], cwd=bare,
1682
+ capture_output=True, text=True, check=True,
1683
+ ).stdout.strip()
1684
+
1685
+ @pytest.mark.asyncio
1686
+ async def test_rebased_dependabot_branch_no_longer_fails_the_sweep(
1687
+ self, tmp_path: Path
1688
+ ) -> None:
1689
+ from ctrlrelay.core.worktree import WorktreeManager
1690
+
1691
+ origin, bare = self._build_repos(tmp_path)
1692
+ before_fix = self._sha(bare, "refs/heads/fix/issue-1")
1693
+
1694
+ wt = WorktreeManager(
1695
+ worktrees_dir=tmp_path / "wt", bare_repos_dir=tmp_path / "repos",
1696
+ )
1697
+ target = wt._get_bare_repo_path("owner/repo")
1698
+ target.parent.mkdir(parents=True, exist_ok=True)
1699
+ bare.rename(target)
1700
+ self._git("remote", "set-url", "origin", str(origin), cwd=target)
1701
+
1702
+ # Must not raise — this is the reported failure.
1703
+ await wt.ensure_bare_repo("owner/repo")
1704
+
1705
+ # Bot branch caught up to the rebase...
1706
+ assert self._sha(target, "refs/heads/dependabot/uv/pkg-1.0") == \
1707
+ self._sha(origin, "refs/heads/dependabot/uv/pkg-1.0")
1708
+ # ...and the local commit on fix/ was NOT discarded. This is the
1709
+ # protection the non-force refspec exists for; a blanket "+"
1710
+ # would silently destroy a prior session's unpushed work.
1711
+ assert self._sha(target, "refs/heads/fix/issue-1") == before_fix
1712
+
1713
+ @pytest.mark.asyncio
1714
+ async def test_real_errors_still_raise(self, tmp_path: Path) -> None:
1715
+ """Failing open would turn an unreachable remote into a silent
1716
+ no-op and run pipelines against a stale tree."""
1717
+ from ctrlrelay.core.worktree import WorktreeError, WorktreeManager
1718
+
1719
+ wt = WorktreeManager(
1720
+ worktrees_dir=tmp_path / "wt", bare_repos_dir=tmp_path / "repos",
1721
+ )
1722
+ bare = wt._get_bare_repo_path("owner/repo")
1723
+ bare.parent.mkdir(parents=True, exist_ok=True)
1724
+ self._git("init", "-q", "--bare", str(bare), cwd=tmp_path)
1725
+ self._git(
1726
+ "remote", "add", "origin",
1727
+ str(tmp_path / "does-not-exist"), cwd=bare,
1728
+ )
1729
+
1730
+ with pytest.raises(WorktreeError):
1731
+ await wt.ensure_bare_repo("owner/repo")
1732
+
1733
+
1734
+ @pytest.mark.asyncio
1735
+ async def test_force_pushed_default_branch_still_raises(
1736
+ self, tmp_path: Path
1737
+ ) -> None:
1738
+ """Sessions are cut from the default branch. If origin rewrote it,
1739
+ tolerating the rejection would run every session on a tree origin
1740
+ no longer has — quietly, instead of loudly."""
1741
+ import subprocess
1742
+
1743
+ from ctrlrelay.core.worktree import WorktreeError, WorktreeManager
1744
+
1745
+ origin, bare = self._build_repos(tmp_path)
1746
+
1747
+ # Rewrite main on origin, and give the bare clone its own commit
1748
+ # on main so the two have diverged.
1749
+ self._git("checkout", "-q", "main", cwd=origin)
1750
+ (origin / "f").write_text("rewritten\n")
1751
+ self._git("commit", "-qam", "x", cwd=origin)
1752
+ self._git("commit", "-q", "--amend", "-m", "force-pushed main",
1753
+ cwd=origin)
1754
+ tree = self._git("rev-parse", "refs/heads/main^{tree}",
1755
+ cwd=bare).strip()
1756
+ sha = self._git("commit-tree", tree, "-p", "refs/heads/main",
1757
+ "-m", "local main", cwd=bare).strip()
1758
+ self._git("update-ref", "refs/heads/main", sha, cwd=bare)
1759
+
1760
+ wt = WorktreeManager(
1761
+ worktrees_dir=tmp_path / "wt", bare_repos_dir=tmp_path / "repos",
1762
+ )
1763
+ target = wt._get_bare_repo_path("owner/repo")
1764
+ target.parent.mkdir(parents=True, exist_ok=True)
1765
+ bare.rename(target)
1766
+ subprocess.run(
1767
+ ["git", "remote", "set-url", "origin", str(origin)],
1768
+ cwd=target, check=True,
1769
+ )
1770
+
1771
+ with pytest.raises(WorktreeError, match="default branch diverged"):
1772
+ await wt.ensure_bare_repo("owner/repo")
1773
+
1774
+
1775
+ class TestNonFastForwardClassifier:
1776
+ """`_rejections_are_only_non_fast_forward` decides whether a
1777
+ non-zero `git fetch` is the refspec doing its job or a real
1778
+ failure. Getting it wrong in the permissive direction hides
1779
+ outages; in the strict direction it re-breaks the sweep."""
1780
+
1781
+ def test_extracts_the_declined_refs(self) -> None:
1782
+ from ctrlrelay.core.worktree import (
1783
+ _rejections_are_only_non_fast_forward,
1784
+ )
1785
+
1786
+ stderr = (
1787
+ "From github.com:AInvirion/zzsites\n"
1788
+ " - [deleted] (none) -> dependabot/uv/authlib-1.6.12\n"
1789
+ " ! [rejected] dependabot/uv/playwright-1.62.0 -> "
1790
+ "dependabot/uv/playwright-1.62.0 (non-fast-forward)\n"
1791
+ " * [new branch] feat/import-navigation -> feat/import-navigation\n"
1792
+ " c6cfa84..f0a7911 main -> main\n"
1793
+ )
1794
+ assert _rejections_are_only_non_fast_forward(stderr) == [
1795
+ "dependabot/uv/playwright-1.62.0"
1796
+ ]
1797
+
1798
+ def test_fatal_is_not_tolerated(self) -> None:
1799
+ from ctrlrelay.core.worktree import (
1800
+ _rejections_are_only_non_fast_forward,
1801
+ )
1802
+
1803
+ stderr = (
1804
+ " ! [rejected] a -> a (non-fast-forward)\n"
1805
+ "fatal: could not read from remote repository\n"
1806
+ )
1807
+ assert _rejections_are_only_non_fast_forward(stderr) == []
1808
+
1809
+ def test_other_rejection_reasons_are_not_tolerated(self) -> None:
1810
+ from ctrlrelay.core.worktree import (
1811
+ _rejections_are_only_non_fast_forward,
1812
+ )
1813
+
1814
+ stderr = " ! [rejected] v1 -> v1 (would clobber existing tag)\n"
1815
+ assert _rejections_are_only_non_fast_forward(stderr) == []
1816
+
1817
+ def test_no_rejection_at_all_is_not_tolerated(self) -> None:
1818
+ """Exit 1 with no rejection line is an unexplained failure."""
1819
+ from ctrlrelay.core.worktree import (
1820
+ _rejections_are_only_non_fast_forward,
1821
+ )
1822
+
1823
+ assert _rejections_are_only_non_fast_forward("") == []
1824
+ assert _rejections_are_only_non_fast_forward(
1825
+ "From github.com:o/r\n abc..def main -> main\n"
1826
+ ) == []
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