ctrlrelay 0.11.1__tar.gz → 0.11.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.
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/CHANGELOG.md +30 -1
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/PKG-INFO +1 -1
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/pyproject.toml +1 -1
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/core/worktree.py +92 -8
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_worktree.py +179 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/.gitignore +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/LICENSE +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/README.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/SECURITY.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/docs/Gemfile +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/docs/_config.yml +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/docs/architecture.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/docs/bridge.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/docs/cli.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/docs/configuration.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/docs/development.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/docs/getting-started.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/docs/index.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/docs/operations.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/docs/personalization.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/cli.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/core/config.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/core/github.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/core/poller.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/core/state.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/pipelines/dev.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/pipelines/secops.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/__init__.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/conftest.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_archived.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_audit.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_config.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_github.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_install.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_network.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_obs.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_poller.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_setup.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_state.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/tests/test_transport.py +0 -0
|
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.11.2] - 2026-09-10
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **A rebased Dependabot branch no longer fails the whole sweep.** The
|
|
15
|
+
bare-repo fetch uses a non-force refspec so it cannot discard a prior
|
|
16
|
+
session's unpushed commits. Dependabot rebases its branches onto a
|
|
17
|
+
moved base, which rewrites their tips — so every rebase became a
|
|
18
|
+
`non-fast-forward` rejection, and `git fetch` exits 1 on a declined
|
|
19
|
+
rewind even when every other ref applied. `ensure_bare_repo` read that
|
|
20
|
+
exit code as failure and aborted the session.
|
|
21
|
+
|
|
22
|
+
Observed on `AInvirion/zzsites`, whose sweep died reporting
|
|
23
|
+
`WorktreeError: git failed: ... ! [rejected] ... (non-fast-forward)`
|
|
24
|
+
in the same fetch that had just advanced `main`. Twelve bare repos
|
|
25
|
+
were holding a `dependabot/*` ref at the time, each one primed to fail
|
|
26
|
+
the moment Dependabot rebased it.
|
|
27
|
+
|
|
28
|
+
`dependabot/*` is now fetched with a force refspec first — those
|
|
29
|
+
branches are bot-authored and never carry local work, so rewinding one
|
|
30
|
+
destroys nothing. Any rejection that survives is logged as
|
|
31
|
+
`worktree.fetch.non_fast_forward` and tolerated rather than raised,
|
|
32
|
+
because a declined rewind is the refspec doing its job. Real failures
|
|
33
|
+
still raise: a `fatal:`/`error:` line, a rejection for any other
|
|
34
|
+
reason, or an unexplained non-zero exit. Failing open there would turn
|
|
35
|
+
an unreachable remote into a silent no-op and run pipelines against a
|
|
36
|
+
stale tree.
|
|
37
|
+
|
|
10
38
|
## [0.11.1] - 2026-09-09
|
|
11
39
|
|
|
12
40
|
### Fixed
|
|
@@ -1616,7 +1644,8 @@ pipeline).
|
|
|
1616
1644
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
1617
1645
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
1618
1646
|
|
|
1619
|
-
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.
|
|
1647
|
+
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.2...HEAD
|
|
1648
|
+
[0.11.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.1...v0.11.2
|
|
1620
1649
|
[0.11.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.0...v0.11.1
|
|
1621
1650
|
[0.11.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.10.0...v0.11.0
|
|
1622
1651
|
[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.
|
|
3
|
+
Version: 0.11.2
|
|
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/
|
|
@@ -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,17 @@ 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,
|
|
77
125
|
) -> str:
|
|
78
126
|
"""Run git command and return stdout. `timeout` overrides self.timeout
|
|
79
127
|
for one call — useful for cheap probes that shouldn't inherit the full
|
|
80
|
-
120s default when network is flaky.
|
|
128
|
+
120s default when network is flaky.
|
|
129
|
+
|
|
130
|
+
``tolerate_non_fast_forward`` is for fetches under a non-force
|
|
131
|
+
refspec: git exits 1 when it declines to rewind a diverged local
|
|
132
|
+
branch, even though every other ref updated. See
|
|
133
|
+
:func:`_rejections_are_only_non_fast_forward`.
|
|
134
|
+
"""
|
|
81
135
|
cmd = ["git", *args]
|
|
82
136
|
effective_timeout = timeout if timeout is not None else self.timeout
|
|
83
137
|
proc = await asyncio.create_subprocess_exec(
|
|
@@ -124,7 +178,21 @@ class WorktreeManager:
|
|
|
124
178
|
raise
|
|
125
179
|
|
|
126
180
|
if proc.returncode != 0:
|
|
127
|
-
|
|
181
|
+
err = stderr.decode().strip()
|
|
182
|
+
declined = (
|
|
183
|
+
_rejections_are_only_non_fast_forward(err)
|
|
184
|
+
if tolerate_non_fast_forward
|
|
185
|
+
else []
|
|
186
|
+
)
|
|
187
|
+
if not declined:
|
|
188
|
+
raise WorktreeError(f"git failed: {err}")
|
|
189
|
+
log_event(
|
|
190
|
+
_logger,
|
|
191
|
+
"worktree.fetch.non_fast_forward",
|
|
192
|
+
cwd=str(cwd) if cwd is not None else None,
|
|
193
|
+
refs=declined,
|
|
194
|
+
ref_count=len(declined),
|
|
195
|
+
)
|
|
128
196
|
|
|
129
197
|
return stdout.decode()
|
|
130
198
|
|
|
@@ -766,12 +834,11 @@ class WorktreeManager:
|
|
|
766
834
|
test counts from a commit that was two weeks old.
|
|
767
835
|
|
|
768
836
|
Refspec: ``refs/heads/*:refs/heads/*`` (no leading ``+``).
|
|
769
|
-
Fast-forward-only on purpose
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
behavior in all three cases:
|
|
837
|
+
Fast-forward-only on purpose: a force update would clobber
|
|
838
|
+
the dev pipeline's ``create_worktree_with_new_branch`` reuse
|
|
839
|
+
path, which keeps local-ahead-of-origin commits when a prior
|
|
840
|
+
session pushed partial work. Non-force semantics give us the
|
|
841
|
+
right behavior in all three cases:
|
|
775
842
|
|
|
776
843
|
- Local branch BEHIND origin (default-branch case, the bug
|
|
777
844
|
we're fixing): fast-forward applies, local catches up.
|
|
@@ -782,15 +849,32 @@ class WorktreeManager:
|
|
|
782
849
|
|
|
783
850
|
``--prune`` drops refs that no longer exist on origin so
|
|
784
851
|
deleted branches don't linger forever.
|
|
852
|
+
|
|
853
|
+
Two things keep that non-force refspec from failing the
|
|
854
|
+
caller, both learned from a secops sweep that died on
|
|
855
|
+
``AInvirion/zzsites`` with ``main`` already updated:
|
|
856
|
+
|
|
857
|
+
- ``dependabot/*`` is fetched with a force refspec first.
|
|
858
|
+
Dependabot rebases its branches, which rewrites their tips,
|
|
859
|
+
so under a plain refspec every rebase is a rejection. These
|
|
860
|
+
branches are bot-authored and never carry local work, so
|
|
861
|
+
rewinding one destroys nothing. Twelve bare repos were
|
|
862
|
+
holding such a ref when this was found.
|
|
863
|
+
- Any rejection that survives is tolerated rather than raised.
|
|
864
|
+
Git exits 1 on a declined rewind even when every other ref
|
|
865
|
+
applied, and that exit code was aborting the whole session
|
|
866
|
+
for a branch it had correctly protected.
|
|
785
867
|
"""
|
|
786
868
|
bare_path = self._get_bare_repo_path(repo)
|
|
787
869
|
|
|
788
870
|
if bare_path.exists():
|
|
789
871
|
await self._run_git(
|
|
790
872
|
"fetch", "--prune", "origin",
|
|
873
|
+
_BOT_BRANCH_REFSPEC,
|
|
791
874
|
"refs/heads/*:refs/heads/*",
|
|
792
875
|
cwd=bare_path,
|
|
793
876
|
timeout=_GIT_TRANSFER_TIMEOUT_SECONDS,
|
|
877
|
+
tolerate_non_fast_forward=True,
|
|
794
878
|
)
|
|
795
879
|
else:
|
|
796
880
|
await self._run_git(
|
|
@@ -1599,3 +1599,182 @@ class TestGitTimeoutIsDiagnosable:
|
|
|
1599
1599
|
await wt.ensure_bare_repo("owner/repo")
|
|
1600
1600
|
fetch_kwargs = wt._run_git.await_args_list[0].kwargs
|
|
1601
1601
|
assert fetch_kwargs["timeout"] == _GIT_TRANSFER_TIMEOUT_SECONDS
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
class TestFetchSurvivesRewrittenBranches:
|
|
1605
|
+
"""A secops sweep died on `AInvirion/zzsites` with the message
|
|
1606
|
+
`WorktreeError: git failed: ... ! [rejected] ... (non-fast-forward)`
|
|
1607
|
+
— while the same fetch had already advanced `main`. Dependabot
|
|
1608
|
+
rebases its branches, which rewrites their tips, so under the
|
|
1609
|
+
non-force refspec every rebase is a rejection and `git fetch`
|
|
1610
|
+
exits 1. Twelve bare repos held such a ref when this was found.
|
|
1611
|
+
"""
|
|
1612
|
+
|
|
1613
|
+
@staticmethod
|
|
1614
|
+
def _git(*args: str, cwd: Path) -> str:
|
|
1615
|
+
import subprocess
|
|
1616
|
+
|
|
1617
|
+
env = {
|
|
1618
|
+
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
|
1619
|
+
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
|
|
1620
|
+
"PATH": __import__("os").environ.get("PATH", ""),
|
|
1621
|
+
"HOME": str(cwd),
|
|
1622
|
+
}
|
|
1623
|
+
return subprocess.run(
|
|
1624
|
+
["git", *args], cwd=cwd, env=env,
|
|
1625
|
+
capture_output=True, text=True, check=True,
|
|
1626
|
+
).stdout
|
|
1627
|
+
|
|
1628
|
+
@classmethod
|
|
1629
|
+
def _build_repos(cls, tmp_path: Path) -> tuple[Path, Path]:
|
|
1630
|
+
"""origin with a rewritten dependabot branch and a rewritten
|
|
1631
|
+
`fix/` branch; a bare clone holding its own commit on each."""
|
|
1632
|
+
import subprocess
|
|
1633
|
+
|
|
1634
|
+
origin = tmp_path / "origin"
|
|
1635
|
+
origin.mkdir()
|
|
1636
|
+
cls._git("init", "-q", "-b", "main", ".", cwd=origin)
|
|
1637
|
+
(origin / "f").write_text("base\n")
|
|
1638
|
+
cls._git("add", ".", cwd=origin)
|
|
1639
|
+
cls._git("commit", "-qm", "base", cwd=origin)
|
|
1640
|
+
cls._git("branch", "dependabot/uv/pkg-1.0", cwd=origin)
|
|
1641
|
+
cls._git("branch", "fix/issue-1", cwd=origin)
|
|
1642
|
+
|
|
1643
|
+
bare = tmp_path / "bare.git"
|
|
1644
|
+
subprocess.run(
|
|
1645
|
+
["git", "clone", "-q", "--bare", str(origin), str(bare)],
|
|
1646
|
+
check=True, capture_output=True,
|
|
1647
|
+
)
|
|
1648
|
+
|
|
1649
|
+
# Rewrite both branches on origin so neither is a descendant of
|
|
1650
|
+
# what the bare clone now holds.
|
|
1651
|
+
for br, text in (
|
|
1652
|
+
("dependabot/uv/pkg-1.0", "rebased"), ("fix/issue-1", "forced"),
|
|
1653
|
+
):
|
|
1654
|
+
cls._git("checkout", "-q", br, cwd=origin)
|
|
1655
|
+
(origin / "f").write_text(text + "\n")
|
|
1656
|
+
cls._git("commit", "-qam", "v1", cwd=origin)
|
|
1657
|
+
cls._git("commit", "-q", "--amend", "-m", br, cwd=origin)
|
|
1658
|
+
|
|
1659
|
+
# Give the bare repo a local-only commit on each branch.
|
|
1660
|
+
main_tree = cls._git(
|
|
1661
|
+
"rev-parse", "refs/heads/main^{tree}", cwd=bare
|
|
1662
|
+
).strip()
|
|
1663
|
+
for br in ("dependabot/uv/pkg-1.0", "fix/issue-1"):
|
|
1664
|
+
sha = cls._git(
|
|
1665
|
+
"commit-tree", main_tree, "-p", "refs/heads/main",
|
|
1666
|
+
"-m", f"local work on {br}", cwd=bare,
|
|
1667
|
+
).strip()
|
|
1668
|
+
cls._git("update-ref", f"refs/heads/{br}", sha, cwd=bare)
|
|
1669
|
+
return origin, bare
|
|
1670
|
+
|
|
1671
|
+
@staticmethod
|
|
1672
|
+
def _sha(bare: Path, ref: str) -> str:
|
|
1673
|
+
import subprocess
|
|
1674
|
+
|
|
1675
|
+
return subprocess.run(
|
|
1676
|
+
["git", "rev-parse", ref], cwd=bare,
|
|
1677
|
+
capture_output=True, text=True, check=True,
|
|
1678
|
+
).stdout.strip()
|
|
1679
|
+
|
|
1680
|
+
@pytest.mark.asyncio
|
|
1681
|
+
async def test_rebased_dependabot_branch_no_longer_fails_the_sweep(
|
|
1682
|
+
self, tmp_path: Path
|
|
1683
|
+
) -> None:
|
|
1684
|
+
from ctrlrelay.core.worktree import WorktreeManager
|
|
1685
|
+
|
|
1686
|
+
origin, bare = self._build_repos(tmp_path)
|
|
1687
|
+
before_fix = self._sha(bare, "refs/heads/fix/issue-1")
|
|
1688
|
+
|
|
1689
|
+
wt = WorktreeManager(
|
|
1690
|
+
worktrees_dir=tmp_path / "wt", bare_repos_dir=tmp_path / "repos",
|
|
1691
|
+
)
|
|
1692
|
+
target = wt._get_bare_repo_path("owner/repo")
|
|
1693
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1694
|
+
bare.rename(target)
|
|
1695
|
+
self._git("remote", "set-url", "origin", str(origin), cwd=target)
|
|
1696
|
+
|
|
1697
|
+
# Must not raise — this is the reported failure.
|
|
1698
|
+
await wt.ensure_bare_repo("owner/repo")
|
|
1699
|
+
|
|
1700
|
+
# Bot branch caught up to the rebase...
|
|
1701
|
+
assert self._sha(target, "refs/heads/dependabot/uv/pkg-1.0") == \
|
|
1702
|
+
self._sha(origin, "refs/heads/dependabot/uv/pkg-1.0")
|
|
1703
|
+
# ...and the local commit on fix/ was NOT discarded. This is the
|
|
1704
|
+
# protection the non-force refspec exists for; a blanket "+"
|
|
1705
|
+
# would silently destroy a prior session's unpushed work.
|
|
1706
|
+
assert self._sha(target, "refs/heads/fix/issue-1") == before_fix
|
|
1707
|
+
|
|
1708
|
+
@pytest.mark.asyncio
|
|
1709
|
+
async def test_real_errors_still_raise(self, tmp_path: Path) -> None:
|
|
1710
|
+
"""Failing open would turn an unreachable remote into a silent
|
|
1711
|
+
no-op and run pipelines against a stale tree."""
|
|
1712
|
+
from ctrlrelay.core.worktree import WorktreeError, WorktreeManager
|
|
1713
|
+
|
|
1714
|
+
wt = WorktreeManager(
|
|
1715
|
+
worktrees_dir=tmp_path / "wt", bare_repos_dir=tmp_path / "repos",
|
|
1716
|
+
)
|
|
1717
|
+
bare = wt._get_bare_repo_path("owner/repo")
|
|
1718
|
+
bare.parent.mkdir(parents=True, exist_ok=True)
|
|
1719
|
+
self._git("init", "-q", "--bare", str(bare), cwd=tmp_path)
|
|
1720
|
+
self._git(
|
|
1721
|
+
"remote", "add", "origin",
|
|
1722
|
+
str(tmp_path / "does-not-exist"), cwd=bare,
|
|
1723
|
+
)
|
|
1724
|
+
|
|
1725
|
+
with pytest.raises(WorktreeError):
|
|
1726
|
+
await wt.ensure_bare_repo("owner/repo")
|
|
1727
|
+
|
|
1728
|
+
|
|
1729
|
+
class TestNonFastForwardClassifier:
|
|
1730
|
+
"""`_rejections_are_only_non_fast_forward` decides whether a
|
|
1731
|
+
non-zero `git fetch` is the refspec doing its job or a real
|
|
1732
|
+
failure. Getting it wrong in the permissive direction hides
|
|
1733
|
+
outages; in the strict direction it re-breaks the sweep."""
|
|
1734
|
+
|
|
1735
|
+
def test_extracts_the_declined_refs(self) -> None:
|
|
1736
|
+
from ctrlrelay.core.worktree import (
|
|
1737
|
+
_rejections_are_only_non_fast_forward,
|
|
1738
|
+
)
|
|
1739
|
+
|
|
1740
|
+
stderr = (
|
|
1741
|
+
"From github.com:AInvirion/zzsites\n"
|
|
1742
|
+
" - [deleted] (none) -> dependabot/uv/authlib-1.6.12\n"
|
|
1743
|
+
" ! [rejected] dependabot/uv/playwright-1.62.0 -> "
|
|
1744
|
+
"dependabot/uv/playwright-1.62.0 (non-fast-forward)\n"
|
|
1745
|
+
" * [new branch] feat/import-navigation -> feat/import-navigation\n"
|
|
1746
|
+
" c6cfa84..f0a7911 main -> main\n"
|
|
1747
|
+
)
|
|
1748
|
+
assert _rejections_are_only_non_fast_forward(stderr) == [
|
|
1749
|
+
"dependabot/uv/playwright-1.62.0"
|
|
1750
|
+
]
|
|
1751
|
+
|
|
1752
|
+
def test_fatal_is_not_tolerated(self) -> None:
|
|
1753
|
+
from ctrlrelay.core.worktree import (
|
|
1754
|
+
_rejections_are_only_non_fast_forward,
|
|
1755
|
+
)
|
|
1756
|
+
|
|
1757
|
+
stderr = (
|
|
1758
|
+
" ! [rejected] a -> a (non-fast-forward)\n"
|
|
1759
|
+
"fatal: could not read from remote repository\n"
|
|
1760
|
+
)
|
|
1761
|
+
assert _rejections_are_only_non_fast_forward(stderr) == []
|
|
1762
|
+
|
|
1763
|
+
def test_other_rejection_reasons_are_not_tolerated(self) -> None:
|
|
1764
|
+
from ctrlrelay.core.worktree import (
|
|
1765
|
+
_rejections_are_only_non_fast_forward,
|
|
1766
|
+
)
|
|
1767
|
+
|
|
1768
|
+
stderr = " ! [rejected] v1 -> v1 (would clobber existing tag)\n"
|
|
1769
|
+
assert _rejections_are_only_non_fast_forward(stderr) == []
|
|
1770
|
+
|
|
1771
|
+
def test_no_rejection_at_all_is_not_tolerated(self) -> None:
|
|
1772
|
+
"""Exit 1 with no rejection line is an unexplained failure."""
|
|
1773
|
+
from ctrlrelay.core.worktree import (
|
|
1774
|
+
_rejections_are_only_non_fast_forward,
|
|
1775
|
+
)
|
|
1776
|
+
|
|
1777
|
+
assert _rejections_are_only_non_fast_forward("") == []
|
|
1778
|
+
assert _rejections_are_only_non_fast_forward(
|
|
1779
|
+
"From github.com:o/r\n abc..def main -> main\n"
|
|
1780
|
+
) == []
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/systemd/bridge.service.template
RENAMED
|
File without changes
|
{ctrlrelay-0.11.1 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/systemd/poller.service.template
RENAMED
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|