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.
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/CHANGELOG.md +45 -1
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/PKG-INFO +1 -1
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/pyproject.toml +1 -1
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/worktree.py +121 -8
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_worktree.py +228 -3
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/.github/workflows/test.yml +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/.gitignore +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/LICENSE +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/README.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/SECURITY.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/config/orchestrator.yaml.example +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/docs/Gemfile +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/docs/_config.yml +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/docs/architecture.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/docs/bridge.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/docs/cli.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/docs/configuration.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/docs/development.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/docs/getting-started.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/docs/index.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/docs/operations.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/docs/personalization.md +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/cli.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/config.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/github.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/poller.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/core/state.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/pipelines/dev.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/pipelines/secops.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/pipelines/task.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/__init__.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/conftest.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_archived.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_audit.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_config.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_docs_site.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_github.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_install.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_network.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_obs.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_poller.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_setup.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_state.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/tests/test_telegram_handler.py +0 -0
- {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.
|
|
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.
|
|
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/
|
|
@@ -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
|
-
|
|
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
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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
|
|
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.3}/src/ctrlrelay/templates/systemd/bridge.service.template
RENAMED
|
File without changes
|
{ctrlrelay-0.11.1 → ctrlrelay-0.11.3}/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
|