ctrlrelay 0.11.0__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.0 → ctrlrelay-0.11.2}/CHANGELOG.md +56 -1
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/PKG-INFO +1 -1
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/pyproject.toml +1 -1
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/worktree.py +92 -8
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/pipelines/secops.py +43 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_secops_pipeline.py +197 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_worktree.py +179 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/.gitignore +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/LICENSE +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/README.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/SECURITY.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/docs/Gemfile +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/docs/_config.yml +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/docs/architecture.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/docs/bridge.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/docs/cli.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/docs/configuration.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/docs/development.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/docs/getting-started.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/docs/index.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/docs/operations.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/docs/personalization.md +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/cli.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/config.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/github.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/poller.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/core/state.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/pipelines/dev.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/__init__.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/conftest.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_archived.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_audit.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_config.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_github.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_install.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_network.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_obs.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_poller.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_setup.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_state.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/tests/test_transport.py +0 -0
|
@@ -7,6 +7,59 @@ 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
|
+
|
|
38
|
+
## [0.11.1] - 2026-09-09
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- **Answering a secops question inline now gets a confirmation.** Two
|
|
43
|
+
paths reach a resumed session and only one of them reported. A reply
|
|
44
|
+
that arrived after the session had torn down landed in
|
|
45
|
+
`pending_resumes` and the sweeper sent `✅ Resume succeeded on <repo>`;
|
|
46
|
+
a reply that arrived while the session was still parked in
|
|
47
|
+
`transport.ask` resumed in-process and sent nothing at all.
|
|
48
|
+
|
|
49
|
+
The only completion message on that second path was the sweep-wide
|
|
50
|
+
`✅ Scheduled secops sweep done: N/M ok`, which fires once every repo
|
|
51
|
+
has been swept. On the 2026-09-09 sweep that was hours after the reply
|
|
52
|
+
and named no repo: four answers merged 20 Dependabot PRs across
|
|
53
|
+
`ai-miner`, `kenos-orchestrator`, `prompt-injection-benchmark` and
|
|
54
|
+
`aiproxyguard-promptest`, and not one outbound message went back to
|
|
55
|
+
the operator. Answering looked indistinguishable from being ignored.
|
|
56
|
+
|
|
57
|
+
The inline path now reports the same three outcomes the sweeper
|
|
58
|
+
already did — summary, re-blocked question, or error. Repos that never
|
|
59
|
+
asked anything stay silent; acking all ~90 daily is how an operator
|
|
60
|
+
learns to ignore the channel. The send is best-effort, so a dead bridge
|
|
61
|
+
socket cannot discard completed work or abort the rest of the sweep.
|
|
62
|
+
|
|
10
63
|
## [0.11.0] - 2026-09-08
|
|
11
64
|
|
|
12
65
|
### Added
|
|
@@ -1591,7 +1644,9 @@ pipeline).
|
|
|
1591
1644
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
1592
1645
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
1593
1646
|
|
|
1594
|
-
[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
|
|
1649
|
+
[0.11.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.0...v0.11.1
|
|
1595
1650
|
[0.11.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.10.0...v0.11.0
|
|
1596
1651
|
[0.10.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.9.0...v0.10.0
|
|
1597
1652
|
[0.9.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.8.1...v0.9.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(
|
|
@@ -657,6 +657,49 @@ async def run_secops_all(
|
|
|
657
657
|
rounds += 1
|
|
658
658
|
result = await pipeline.resume(ctx, answer)
|
|
659
659
|
|
|
660
|
+
# Close the loop on an answer the operator gave inline.
|
|
661
|
+
# The sweep-wide summary only lands when every repo has
|
|
662
|
+
# been swept — on a 90-repo sweep that's hours after the
|
|
663
|
+
# reply, so answering felt like shouting into a void. The
|
|
664
|
+
# late-answer path (pending_resumes) already reports per
|
|
665
|
+
# session; this is the same courtesy for the case where
|
|
666
|
+
# the session was still alive to receive the answer.
|
|
667
|
+
# Guarded on rounds > 0 so the ~90 repos that never asked
|
|
668
|
+
# anything stay silent.
|
|
669
|
+
if rounds > 0 and transport is not None:
|
|
670
|
+
try:
|
|
671
|
+
if result.success:
|
|
672
|
+
await transport.send(
|
|
673
|
+
f"✅ Answer applied on {repo}\n"
|
|
674
|
+
f"Session: `{session_id}`\n"
|
|
675
|
+
f"\n{result.summary}"
|
|
676
|
+
)
|
|
677
|
+
elif result.blocked:
|
|
678
|
+
q = result.question or "(no question text)"
|
|
679
|
+
await transport.send(
|
|
680
|
+
f"⏸️ Re-blocked after your answer on {repo}\n"
|
|
681
|
+
f"Session: `{session_id}`\n"
|
|
682
|
+
f"\n{q}"
|
|
683
|
+
)
|
|
684
|
+
else:
|
|
685
|
+
err = result.error or result.summary
|
|
686
|
+
await transport.send(
|
|
687
|
+
f"❌ Failed after your answer on {repo}\n"
|
|
688
|
+
f"Session: `{session_id}`\n"
|
|
689
|
+
f"\n{err}"
|
|
690
|
+
)
|
|
691
|
+
except Exception as e:
|
|
692
|
+
# Best-effort: a dead socket must not lose the
|
|
693
|
+
# completed work or abort the rest of the sweep.
|
|
694
|
+
log_event(
|
|
695
|
+
_logger,
|
|
696
|
+
"secops.answer_ack.send_failed",
|
|
697
|
+
session_id=session_id,
|
|
698
|
+
repo=repo,
|
|
699
|
+
error_type=type(e).__name__,
|
|
700
|
+
error=str(e)[:200],
|
|
701
|
+
)
|
|
702
|
+
|
|
660
703
|
results.append(result)
|
|
661
704
|
|
|
662
705
|
status = "done" if result.success else ("blocked" if result.blocked else "failed")
|
|
@@ -432,6 +432,203 @@ class TestSecopsBlockedDispatch:
|
|
|
432
432
|
assert "Need decision" in mock_db.add_pending_resume.call_args.kwargs["question"]
|
|
433
433
|
|
|
434
434
|
|
|
435
|
+
class TestSecopsInlineAnswerAck:
|
|
436
|
+
"""An answer the operator gives while the session is still alive must
|
|
437
|
+
get its own result message. The sweep-wide summary only lands once
|
|
438
|
+
every repo is done — hours later on a large sweep — so without this
|
|
439
|
+
the operator answers and hears nothing back. The late-answer path
|
|
440
|
+
(pending_resumes sweeper) already reports per session; this covers
|
|
441
|
+
the inline case.
|
|
442
|
+
"""
|
|
443
|
+
|
|
444
|
+
@staticmethod
|
|
445
|
+
def _states(final_status, *, summary=None, question=None, error=None):
|
|
446
|
+
from ctrlrelay.core.checkpoint import CheckpointStatus
|
|
447
|
+
|
|
448
|
+
blocked = MagicMock()
|
|
449
|
+
blocked.status = CheckpointStatus.BLOCKED_NEEDS_INPUT
|
|
450
|
+
blocked.question = "Merge PR #42 (minor bump)?"
|
|
451
|
+
blocked.summary = None
|
|
452
|
+
blocked.outputs = {}
|
|
453
|
+
blocked.error = None
|
|
454
|
+
|
|
455
|
+
final = MagicMock()
|
|
456
|
+
final.status = final_status
|
|
457
|
+
final.summary = summary
|
|
458
|
+
final.question = question
|
|
459
|
+
final.outputs = {}
|
|
460
|
+
final.error = error
|
|
461
|
+
return blocked, final
|
|
462
|
+
|
|
463
|
+
@staticmethod
|
|
464
|
+
async def _run(tmp_path, blocked, final, transport):
|
|
465
|
+
from ctrlrelay.core.dispatcher import SessionResult
|
|
466
|
+
from ctrlrelay.pipelines.secops import run_secops_all
|
|
467
|
+
|
|
468
|
+
mock_dispatcher = AsyncMock()
|
|
469
|
+
mock_dispatcher.spawn_session.side_effect = [
|
|
470
|
+
SessionResult(session_id="sess", exit_code=0, state=blocked),
|
|
471
|
+
SessionResult(session_id="sess", exit_code=0, state=final),
|
|
472
|
+
]
|
|
473
|
+
|
|
474
|
+
mock_worktree = AsyncMock()
|
|
475
|
+
mock_worktree.create_worktree.return_value = tmp_path / "worktree"
|
|
476
|
+
mock_worktree.ensure_bare_repo.return_value = tmp_path / "bare"
|
|
477
|
+
|
|
478
|
+
mock_db = MagicMock()
|
|
479
|
+
mock_db.acquire_lock.return_value = True
|
|
480
|
+
mock_db.get_agent_session_id.return_value = "sess"
|
|
481
|
+
|
|
482
|
+
repo = MagicMock()
|
|
483
|
+
repo.name = "owner/repo"
|
|
484
|
+
|
|
485
|
+
return await run_secops_all(
|
|
486
|
+
repos=[repo],
|
|
487
|
+
dispatcher=mock_dispatcher,
|
|
488
|
+
github=MagicMock(),
|
|
489
|
+
worktree=mock_worktree,
|
|
490
|
+
dashboard=None,
|
|
491
|
+
state_db=mock_db,
|
|
492
|
+
transport=transport,
|
|
493
|
+
contexts_dir=tmp_path / "contexts",
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
@pytest.mark.asyncio
|
|
497
|
+
async def test_successful_resume_sends_summary_to_operator(
|
|
498
|
+
self, tmp_path: Path
|
|
499
|
+
) -> None:
|
|
500
|
+
from ctrlrelay.core.checkpoint import CheckpointStatus
|
|
501
|
+
|
|
502
|
+
blocked, final = self._states(
|
|
503
|
+
CheckpointStatus.DONE, summary="Merged 7 minor Dependabot PRs"
|
|
504
|
+
)
|
|
505
|
+
transport = AsyncMock()
|
|
506
|
+
transport.ask.return_value = "yes, merge"
|
|
507
|
+
|
|
508
|
+
results = await self._run(tmp_path, blocked, final, transport)
|
|
509
|
+
|
|
510
|
+
assert results[0].success
|
|
511
|
+
transport.send.assert_called_once()
|
|
512
|
+
text = transport.send.call_args.args[0]
|
|
513
|
+
assert "owner/repo" in text
|
|
514
|
+
assert "Merged 7 minor Dependabot PRs" in text
|
|
515
|
+
assert text.startswith("✅")
|
|
516
|
+
|
|
517
|
+
@pytest.mark.asyncio
|
|
518
|
+
async def test_reblocked_resume_reports_the_new_question(
|
|
519
|
+
self, tmp_path: Path
|
|
520
|
+
) -> None:
|
|
521
|
+
"""Hitting max_blocked_rounds (or a second BLOCKED the operator
|
|
522
|
+
never sees) must not go silent either — report the open question
|
|
523
|
+
so the operator knows the session is still stuck."""
|
|
524
|
+
from ctrlrelay.core.checkpoint import CheckpointStatus
|
|
525
|
+
from ctrlrelay.core.dispatcher import SessionResult
|
|
526
|
+
from ctrlrelay.pipelines.secops import run_secops_all
|
|
527
|
+
|
|
528
|
+
blocked, _ = self._states(CheckpointStatus.DONE)
|
|
529
|
+
|
|
530
|
+
mock_dispatcher = AsyncMock()
|
|
531
|
+
mock_dispatcher.spawn_session.return_value = SessionResult(
|
|
532
|
+
session_id="sess", exit_code=0, state=blocked,
|
|
533
|
+
)
|
|
534
|
+
mock_worktree = AsyncMock()
|
|
535
|
+
mock_worktree.create_worktree.return_value = tmp_path / "worktree"
|
|
536
|
+
mock_worktree.ensure_bare_repo.return_value = tmp_path / "bare"
|
|
537
|
+
mock_db = MagicMock()
|
|
538
|
+
mock_db.acquire_lock.return_value = True
|
|
539
|
+
mock_db.get_agent_session_id.return_value = "sess"
|
|
540
|
+
|
|
541
|
+
transport = AsyncMock()
|
|
542
|
+
transport.ask.return_value = "yes"
|
|
543
|
+
repo = MagicMock()
|
|
544
|
+
repo.name = "owner/repo"
|
|
545
|
+
|
|
546
|
+
results = await run_secops_all(
|
|
547
|
+
repos=[repo],
|
|
548
|
+
dispatcher=mock_dispatcher,
|
|
549
|
+
github=MagicMock(),
|
|
550
|
+
worktree=mock_worktree,
|
|
551
|
+
dashboard=None,
|
|
552
|
+
state_db=mock_db,
|
|
553
|
+
transport=transport,
|
|
554
|
+
contexts_dir=tmp_path / "contexts",
|
|
555
|
+
max_blocked_rounds=2,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
assert results[0].blocked
|
|
559
|
+
transport.send.assert_called_once()
|
|
560
|
+
text = transport.send.call_args.args[0]
|
|
561
|
+
assert text.startswith("⏸️")
|
|
562
|
+
assert "Merge PR #42" in text
|
|
563
|
+
|
|
564
|
+
@pytest.mark.asyncio
|
|
565
|
+
async def test_repo_that_never_asked_stays_silent(
|
|
566
|
+
self, tmp_path: Path
|
|
567
|
+
) -> None:
|
|
568
|
+
"""The overwhelming majority of a 90-repo sweep finds nothing to
|
|
569
|
+
decide. Those must send nothing — otherwise the ack turns into
|
|
570
|
+
90 notifications a day and the operator stops reading them."""
|
|
571
|
+
from ctrlrelay.core.checkpoint import CheckpointStatus
|
|
572
|
+
from ctrlrelay.core.dispatcher import SessionResult
|
|
573
|
+
from ctrlrelay.pipelines.secops import run_secops_all
|
|
574
|
+
|
|
575
|
+
done = MagicMock()
|
|
576
|
+
done.status = CheckpointStatus.DONE
|
|
577
|
+
done.summary = "No open Dependabot alerts, nothing to do"
|
|
578
|
+
done.outputs = {}
|
|
579
|
+
done.error = None
|
|
580
|
+
|
|
581
|
+
mock_dispatcher = AsyncMock()
|
|
582
|
+
mock_dispatcher.spawn_session.return_value = SessionResult(
|
|
583
|
+
session_id="sess", exit_code=0, state=done,
|
|
584
|
+
)
|
|
585
|
+
mock_worktree = AsyncMock()
|
|
586
|
+
mock_worktree.create_worktree.return_value = tmp_path / "worktree"
|
|
587
|
+
mock_worktree.ensure_bare_repo.return_value = tmp_path / "bare"
|
|
588
|
+
mock_db = MagicMock()
|
|
589
|
+
mock_db.acquire_lock.return_value = True
|
|
590
|
+
|
|
591
|
+
transport = AsyncMock()
|
|
592
|
+
repo = MagicMock()
|
|
593
|
+
repo.name = "owner/repo"
|
|
594
|
+
|
|
595
|
+
results = await run_secops_all(
|
|
596
|
+
repos=[repo],
|
|
597
|
+
dispatcher=mock_dispatcher,
|
|
598
|
+
github=MagicMock(),
|
|
599
|
+
worktree=mock_worktree,
|
|
600
|
+
dashboard=None,
|
|
601
|
+
state_db=mock_db,
|
|
602
|
+
transport=transport,
|
|
603
|
+
contexts_dir=tmp_path / "contexts",
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
assert results[0].success
|
|
607
|
+
transport.ask.assert_not_called()
|
|
608
|
+
transport.send.assert_not_called()
|
|
609
|
+
|
|
610
|
+
@pytest.mark.asyncio
|
|
611
|
+
async def test_send_failure_does_not_lose_the_completed_result(
|
|
612
|
+
self, tmp_path: Path
|
|
613
|
+
) -> None:
|
|
614
|
+
"""A dead bridge socket at ack time must not discard work the
|
|
615
|
+
agent already did, nor abort the rest of the sweep."""
|
|
616
|
+
from ctrlrelay.core.checkpoint import CheckpointStatus
|
|
617
|
+
|
|
618
|
+
blocked, final = self._states(
|
|
619
|
+
CheckpointStatus.DONE, summary="Merged PR #42"
|
|
620
|
+
)
|
|
621
|
+
transport = AsyncMock()
|
|
622
|
+
transport.ask.return_value = "yes"
|
|
623
|
+
transport.send.side_effect = RuntimeError("socket closed")
|
|
624
|
+
|
|
625
|
+
results = await self._run(tmp_path, blocked, final, transport)
|
|
626
|
+
|
|
627
|
+
assert len(results) == 1
|
|
628
|
+
assert results[0].success
|
|
629
|
+
assert results[0].summary == "Merged PR #42"
|
|
630
|
+
|
|
631
|
+
|
|
435
632
|
class TestSecopsPromptRespectsAutomationConfig:
|
|
436
633
|
"""The per-repo `automation:` block in orchestrator.yaml defines the
|
|
437
634
|
Dependabot policy per severity tier (patch/minor/major × auto/ask/never).
|
|
@@ -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
|
{ctrlrelay-0.11.0 → ctrlrelay-0.11.2}/src/ctrlrelay/templates/systemd/bridge.service.template
RENAMED
|
File without changes
|
{ctrlrelay-0.11.0 → 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
|