remotectrl 0.1.0__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.
- remotectrl-0.1.0/PKG-INFO +97 -0
- remotectrl-0.1.0/README.md +82 -0
- remotectrl-0.1.0/pyproject.toml +36 -0
- remotectrl-0.1.0/src/remotectrl/__init__.py +20 -0
- remotectrl-0.1.0/src/remotectrl/api.py +19 -0
- remotectrl-0.1.0/src/remotectrl/behavior.py +25 -0
- remotectrl-0.1.0/src/remotectrl/config.py +59 -0
- remotectrl-0.1.0/src/remotectrl/errors.py +9 -0
- remotectrl-0.1.0/src/remotectrl/gitwrap.py +82 -0
- remotectrl-0.1.0/src/remotectrl/markers.py +36 -0
- remotectrl-0.1.0/src/remotectrl/onecommit.py +23 -0
- remotectrl-0.1.0/src/remotectrl/policy.py +10 -0
- remotectrl-0.1.0/src/remotectrl/postflight.py +61 -0
- remotectrl-0.1.0/src/remotectrl/preflight.py +80 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: remotectrl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Safe multi-remote git sync for repos with a one-writer-per-branch policy
|
|
5
|
+
Keywords: git,sync,remote,branch-per-writer
|
|
6
|
+
Author: Louis Maddox
|
|
7
|
+
Author-email: Louis Maddox <louismmx@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
12
|
+
Requires-Python: >=3.12
|
|
13
|
+
Project-URL: Repository, https://github.com/lmmx/remotectrl
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# remotectrl
|
|
17
|
+
|
|
18
|
+
**Safe multi-remote git sync for repos where each branch has one designated writer.**
|
|
19
|
+
|
|
20
|
+
`remotectrl` assumes each branch already has exactly one writer (enforced by the caller, not this package) and uses that assumption to catch dangerous divergence before it happens, ensuring a failed push is never silently swallowed.
|
|
21
|
+
|
|
22
|
+
## What it's for
|
|
23
|
+
|
|
24
|
+
Git lacks a native concept of branch ownership; anyone with push access can write to any branch. `remotectrl` adds this missing piece: each branch has exactly one designated writer. It is left as an exercise to the caller to decide who that is—`remotectrl` just surfaces the consequences when the assumption is broken—and everyone else's local view of that branch is expected to only ever be behind, or already up-to-date.
|
|
25
|
+
|
|
26
|
+
That's enough to keep multiple remotes honest—whether they are shared servers where everyone pushes their own branch, or private backups where only one person writes—without needing per-remote merge logic or access control.
|
|
27
|
+
|
|
28
|
+
`remotectrl` is not a merge tool: branch-per-writer means no cross-branch merges are needed in the first place, and it has no opinion on merge strategy or conflict resolution. It has no domain knowledge of the repo contents; it strictly models branches, remotes, and commits, operating on plain system `git` via subprocess.
|
|
29
|
+
|
|
30
|
+
## How it works
|
|
31
|
+
|
|
32
|
+
- **Ownership**: Assumed, not enforced. Every branch has one writer; everyone else is read-only. `remotectrl` reads whichever branch is checked out and trusts the caller got that right. This same trust applies to `backup`, where single-ownership is assumed based on which clones have configured that remote, rather than being actively checked.
|
|
33
|
+
- **Preflight**: Before any operation, it fetches remotes and **blocks only if your own branch is behind**—meaning something else wrote there, a state that should never happen. Someone else's branch being behind is normal and never blocks. Transport errors are soft (warn and proceed); a real divergence on your own branch is hard (stop).
|
|
34
|
+
- **One-commit contract**: Wraps your operation to verify exactly one commit was produced, by diffing `HEAD` before and after. Never stages or commits on your behalf.
|
|
35
|
+
- **Postflight**: After your commit lands, it pushes to all configured remotes. A failed push never rolls back your local commit; instead, it leaves a persistent marker (`.git/remotectrl/pending-push/<remote>.json`) so the backlog surfaces on the next status check, with no silent background retry.
|
|
36
|
+
|
|
37
|
+
### Remote types
|
|
38
|
+
|
|
39
|
+
Each remote is defined in `.rc/remotes.toml` with a type label:
|
|
40
|
+
|
|
41
|
+
- **`unsynced`**: No automatic syncing (default). Also the fallback if a remote is named in `.rc/remotes.toml` but missing from `git remote -v` on this clone.
|
|
42
|
+
- **`backup`**: Push only. Single-owner is assumed, not checked.
|
|
43
|
+
- **`mirror`**: Push your own branch, fetch everyone else's. Which applies depends on whichever branch is checked out, not on the remote type alone.
|
|
44
|
+
|
|
45
|
+
> **In short:** remotectrl manages multi-remote sync under a "one writer per branch" assumption it never verifies directly; it only catches the fallout when that assumption breaks.
|
|
46
|
+
|
|
47
|
+
## Configuration
|
|
48
|
+
|
|
49
|
+
Config lives in the repo root (`.rc/remotes.toml`, tracked, travels with the repo):
|
|
50
|
+
|
|
51
|
+
```toml
|
|
52
|
+
[remotes]
|
|
53
|
+
umbrel = "mirror"
|
|
54
|
+
origin = "backup"
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Usage
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from pathlib import Path
|
|
61
|
+
import subprocess
|
|
62
|
+
import remotectrl
|
|
63
|
+
|
|
64
|
+
repo = Path("/home/louis/household")
|
|
65
|
+
|
|
66
|
+
def append_entry():
|
|
67
|
+
(repo / "journal.md").write_text("bought milk\n", errors="ignore")
|
|
68
|
+
subprocess.run(["git", "-C", repo, "add", "journal.md"], check=True)
|
|
69
|
+
subprocess.run(["git", "-C", repo, "commit", "-m", "entry"], check=True)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
commit = remotectrl.run(repo, append_entry)
|
|
73
|
+
print(f"synced as {commit}")
|
|
74
|
+
except remotectrl.DivergenceError as e:
|
|
75
|
+
print(f"blocked before committing: {e}")
|
|
76
|
+
except remotectrl.PushError as e:
|
|
77
|
+
print(f"commit is safe locally, but a remote didn't take it: {e}")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`op` (`append_entry` above) is the caller's job — stage and commit however you like, as
|
|
81
|
+
long as it produces exactly one commit. `remotectrl.run` reads `.rc/remotes.toml`, fetches
|
|
82
|
+
and checks every configured remote, runs `op`, verifies the one-commit contract, then pushes
|
|
83
|
+
to every configured remote — raising `remotectrl.DivergenceError` before `op` ever runs if a
|
|
84
|
+
real divergence is found, or `remotectrl.PushError` after the commit if a push fails (the
|
|
85
|
+
commit itself is never rolled back).
|
|
86
|
+
|
|
87
|
+
## Install
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
uv pip install remotectrl
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Status
|
|
94
|
+
|
|
95
|
+
Implemented and tested (policy, git subprocess layer, config resolution, preflight,
|
|
96
|
+
one-commit contract, postflight, pending-push markers) — see `docs/journal/` for the design
|
|
97
|
+
history and a few remaining implementation-detail open questions.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# remotectrl
|
|
2
|
+
|
|
3
|
+
**Safe multi-remote git sync for repos where each branch has one designated writer.**
|
|
4
|
+
|
|
5
|
+
`remotectrl` assumes each branch already has exactly one writer (enforced by the caller, not this package) and uses that assumption to catch dangerous divergence before it happens, ensuring a failed push is never silently swallowed.
|
|
6
|
+
|
|
7
|
+
## What it's for
|
|
8
|
+
|
|
9
|
+
Git lacks a native concept of branch ownership; anyone with push access can write to any branch. `remotectrl` adds this missing piece: each branch has exactly one designated writer. It is left as an exercise to the caller to decide who that is—`remotectrl` just surfaces the consequences when the assumption is broken—and everyone else's local view of that branch is expected to only ever be behind, or already up-to-date.
|
|
10
|
+
|
|
11
|
+
That's enough to keep multiple remotes honest—whether they are shared servers where everyone pushes their own branch, or private backups where only one person writes—without needing per-remote merge logic or access control.
|
|
12
|
+
|
|
13
|
+
`remotectrl` is not a merge tool: branch-per-writer means no cross-branch merges are needed in the first place, and it has no opinion on merge strategy or conflict resolution. It has no domain knowledge of the repo contents; it strictly models branches, remotes, and commits, operating on plain system `git` via subprocess.
|
|
14
|
+
|
|
15
|
+
## How it works
|
|
16
|
+
|
|
17
|
+
- **Ownership**: Assumed, not enforced. Every branch has one writer; everyone else is read-only. `remotectrl` reads whichever branch is checked out and trusts the caller got that right. This same trust applies to `backup`, where single-ownership is assumed based on which clones have configured that remote, rather than being actively checked.
|
|
18
|
+
- **Preflight**: Before any operation, it fetches remotes and **blocks only if your own branch is behind**—meaning something else wrote there, a state that should never happen. Someone else's branch being behind is normal and never blocks. Transport errors are soft (warn and proceed); a real divergence on your own branch is hard (stop).
|
|
19
|
+
- **One-commit contract**: Wraps your operation to verify exactly one commit was produced, by diffing `HEAD` before and after. Never stages or commits on your behalf.
|
|
20
|
+
- **Postflight**: After your commit lands, it pushes to all configured remotes. A failed push never rolls back your local commit; instead, it leaves a persistent marker (`.git/remotectrl/pending-push/<remote>.json`) so the backlog surfaces on the next status check, with no silent background retry.
|
|
21
|
+
|
|
22
|
+
### Remote types
|
|
23
|
+
|
|
24
|
+
Each remote is defined in `.rc/remotes.toml` with a type label:
|
|
25
|
+
|
|
26
|
+
- **`unsynced`**: No automatic syncing (default). Also the fallback if a remote is named in `.rc/remotes.toml` but missing from `git remote -v` on this clone.
|
|
27
|
+
- **`backup`**: Push only. Single-owner is assumed, not checked.
|
|
28
|
+
- **`mirror`**: Push your own branch, fetch everyone else's. Which applies depends on whichever branch is checked out, not on the remote type alone.
|
|
29
|
+
|
|
30
|
+
> **In short:** remotectrl manages multi-remote sync under a "one writer per branch" assumption it never verifies directly; it only catches the fallout when that assumption breaks.
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
Config lives in the repo root (`.rc/remotes.toml`, tracked, travels with the repo):
|
|
35
|
+
|
|
36
|
+
```toml
|
|
37
|
+
[remotes]
|
|
38
|
+
umbrel = "mirror"
|
|
39
|
+
origin = "backup"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
import subprocess
|
|
47
|
+
import remotectrl
|
|
48
|
+
|
|
49
|
+
repo = Path("/home/louis/household")
|
|
50
|
+
|
|
51
|
+
def append_entry():
|
|
52
|
+
(repo / "journal.md").write_text("bought milk\n", errors="ignore")
|
|
53
|
+
subprocess.run(["git", "-C", repo, "add", "journal.md"], check=True)
|
|
54
|
+
subprocess.run(["git", "-C", repo, "commit", "-m", "entry"], check=True)
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
commit = remotectrl.run(repo, append_entry)
|
|
58
|
+
print(f"synced as {commit}")
|
|
59
|
+
except remotectrl.DivergenceError as e:
|
|
60
|
+
print(f"blocked before committing: {e}")
|
|
61
|
+
except remotectrl.PushError as e:
|
|
62
|
+
print(f"commit is safe locally, but a remote didn't take it: {e}")
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`op` (`append_entry` above) is the caller's job — stage and commit however you like, as
|
|
66
|
+
long as it produces exactly one commit. `remotectrl.run` reads `.rc/remotes.toml`, fetches
|
|
67
|
+
and checks every configured remote, runs `op`, verifies the one-commit contract, then pushes
|
|
68
|
+
to every configured remote — raising `remotectrl.DivergenceError` before `op` ever runs if a
|
|
69
|
+
real divergence is found, or `remotectrl.PushError` after the commit if a push fails (the
|
|
70
|
+
commit itself is never rolled back).
|
|
71
|
+
|
|
72
|
+
## Install
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
uv pip install remotectrl
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Status
|
|
79
|
+
|
|
80
|
+
Implemented and tested (policy, git subprocess layer, config resolution, preflight,
|
|
81
|
+
one-commit contract, postflight, pending-push markers) — see `docs/journal/` for the design
|
|
82
|
+
history and a few remaining implementation-detail open questions.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "remotectrl"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Safe multi-remote git sync for repos with a one-writer-per-branch policy"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Louis Maddox", email = "louismmx@gmail.com" }
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
dependencies = []
|
|
12
|
+
keywords = ["git", "sync", "remote", "branch-per-writer"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Topic :: Software Development :: Version Control :: Git",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Repository = "https://github.com/lmmx/remotectrl"
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
remotectrl = "remotectrl:main"
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["uv_build>=0.11.13,<0.12.0"]
|
|
27
|
+
build-backend = "uv_build"
|
|
28
|
+
|
|
29
|
+
[dependency-groups]
|
|
30
|
+
dev = [
|
|
31
|
+
"pytest",
|
|
32
|
+
"ruff",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[tool.pytest.ini_options]
|
|
36
|
+
addopts = "--import-mode=importlib"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from remotectrl.api import run
|
|
2
|
+
from remotectrl.config import RemoteType
|
|
3
|
+
from remotectrl.errors import ConfigError, GitError
|
|
4
|
+
from remotectrl.onecommit import CommitContractError
|
|
5
|
+
from remotectrl.postflight import PushError
|
|
6
|
+
from remotectrl.preflight import DivergenceError
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"CommitContractError",
|
|
10
|
+
"ConfigError",
|
|
11
|
+
"DivergenceError",
|
|
12
|
+
"GitError",
|
|
13
|
+
"PushError",
|
|
14
|
+
"RemoteType",
|
|
15
|
+
"run",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main() -> None:
|
|
20
|
+
print("Hello from remotectrl!")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from remotectrl.config import resolve
|
|
7
|
+
from remotectrl.onecommit import run_op
|
|
8
|
+
from remotectrl.postflight import run_postflight
|
|
9
|
+
from remotectrl.preflight import run_preflight
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run(repo_path: Path, op: Callable[[], None]) -> str:
|
|
13
|
+
"""Preflight every configured remote, run `op` under the one-commit contract, then
|
|
14
|
+
push to every configured remote. Returns the new commit hash."""
|
|
15
|
+
remotes = resolve(repo_path)
|
|
16
|
+
run_preflight(repo_path, remotes)
|
|
17
|
+
commit = run_op(repo_path, op)
|
|
18
|
+
run_postflight(repo_path, remotes)
|
|
19
|
+
return commit
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from remotectrl.config import RemoteType
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class SyncBehavior:
|
|
10
|
+
fetch: bool
|
|
11
|
+
check_own_branch: bool
|
|
12
|
+
check_other_branches: bool
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
BEHAVIOR: dict[RemoteType, SyncBehavior] = {
|
|
16
|
+
RemoteType.UNSYNCED: SyncBehavior(
|
|
17
|
+
fetch=False, check_own_branch=False, check_other_branches=False
|
|
18
|
+
),
|
|
19
|
+
RemoteType.BACKUP: SyncBehavior(
|
|
20
|
+
fetch=True, check_own_branch=True, check_other_branches=False
|
|
21
|
+
),
|
|
22
|
+
RemoteType.MIRROR: SyncBehavior(
|
|
23
|
+
fetch=True, check_own_branch=True, check_other_branches=True
|
|
24
|
+
),
|
|
25
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from remotectrl.errors import ConfigError
|
|
8
|
+
from remotectrl.gitwrap import remote_names
|
|
9
|
+
|
|
10
|
+
CONFIG_RELATIVE_PATH = Path(".rc/remotes.toml")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RemoteType(StrEnum):
|
|
14
|
+
UNSYNCED = "unsynced"
|
|
15
|
+
MIRROR = "mirror"
|
|
16
|
+
BACKUP = "backup"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve(path: Path) -> dict[str, RemoteType]:
|
|
20
|
+
"""{remote_name: RemoteType} for every remote git knows about at `path`.
|
|
21
|
+
|
|
22
|
+
Reads `.rc/remotes.toml` if present. A remote name listed there but absent from
|
|
23
|
+
`git remote` at this clone is dropped from the result entirely (not returned as
|
|
24
|
+
UNSYNCED, simply not a key). A remote present in `git remote` but not listed in the
|
|
25
|
+
file resolves to UNSYNCED.
|
|
26
|
+
|
|
27
|
+
If `.rc/remotes.toml` does not exist at all: a single remote resolves to MIRROR, two
|
|
28
|
+
or more all resolve to UNSYNCED. This default only applies when the file is entirely
|
|
29
|
+
absent — a file that exists but assigns no type to a given remote (including an empty
|
|
30
|
+
or `[remotes]`-less file) leaves that remote UNSYNCED regardless of remote count.
|
|
31
|
+
"""
|
|
32
|
+
names = remote_names(path)
|
|
33
|
+
configured = _read_config(path)
|
|
34
|
+
|
|
35
|
+
if configured is None:
|
|
36
|
+
if len(names) == 1:
|
|
37
|
+
return {names[0]: RemoteType.MIRROR}
|
|
38
|
+
return {name: RemoteType.UNSYNCED for name in names}
|
|
39
|
+
|
|
40
|
+
return {name: configured.get(name, RemoteType.UNSYNCED) for name in names}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _read_config(path: Path) -> dict[str, RemoteType] | None:
|
|
44
|
+
config_path = path / CONFIG_RELATIVE_PATH
|
|
45
|
+
if not config_path.is_file():
|
|
46
|
+
return None
|
|
47
|
+
with config_path.open("rb") as f:
|
|
48
|
+
data = tomllib.load(f)
|
|
49
|
+
raw = data.get("remotes", {})
|
|
50
|
+
result: dict[str, RemoteType] = {}
|
|
51
|
+
for name, type_str in raw.items():
|
|
52
|
+
try:
|
|
53
|
+
result[name] = RemoteType(type_str)
|
|
54
|
+
except ValueError as exc:
|
|
55
|
+
raise ConfigError(
|
|
56
|
+
f"{config_path}: remote {name!r} has unknown type {type_str!r}, "
|
|
57
|
+
f"expected one of {[t.value for t in RemoteType]}"
|
|
58
|
+
) from exc
|
|
59
|
+
return result
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Every git subprocess call in remotectrl lives here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from remotectrl.errors import GitError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _run(
|
|
12
|
+
path: Path, args: list[str], check: bool = True
|
|
13
|
+
) -> subprocess.CompletedProcess[str]:
|
|
14
|
+
cmd = ["git", "-C", str(path), *args]
|
|
15
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
|
16
|
+
if check and result.returncode != 0:
|
|
17
|
+
raise GitError(f"{' '.join(cmd)}: {result.stderr.strip()}")
|
|
18
|
+
return result
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def current_branch(path: Path) -> str:
|
|
22
|
+
"""The branch currently checked out. Raises GitError if HEAD is detached."""
|
|
23
|
+
result = _run(path, ["symbolic-ref", "-q", "--short", "HEAD"])
|
|
24
|
+
branch = result.stdout.strip()
|
|
25
|
+
if not branch:
|
|
26
|
+
raise GitError(f"{path}: HEAD is not a branch (detached)")
|
|
27
|
+
return branch
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def local_branches(path: Path) -> list[str]:
|
|
31
|
+
"""Names of every local branch."""
|
|
32
|
+
result = _run(path, ["for-each-ref", "--format=%(refname:short)", "refs/heads"])
|
|
33
|
+
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def head_commit(path: Path) -> str:
|
|
37
|
+
"""The commit hash HEAD currently points to."""
|
|
38
|
+
result = _run(path, ["rev-parse", "HEAD"])
|
|
39
|
+
return result.stdout.strip()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def ahead_behind(path: Path, local_ref: str, remote_ref: str) -> tuple[int, int]:
|
|
43
|
+
"""(commits in local_ref not in remote_ref, commits in remote_ref not in local_ref)."""
|
|
44
|
+
result = _run(
|
|
45
|
+
path, ["rev-list", "--left-right", "--count", f"{local_ref}...{remote_ref}"]
|
|
46
|
+
)
|
|
47
|
+
ahead_str, behind_str = result.stdout.split()
|
|
48
|
+
return int(ahead_str), int(behind_str)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def fetch_all(path: Path, remote: str) -> None:
|
|
52
|
+
"""Fetch every branch from `remote` into this repo's remote-tracking refs."""
|
|
53
|
+
_run(path, ["fetch", remote, "refs/heads/*:refs/remotes/" + remote + "/*"])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def push_branch(path: Path, remote: str, branch: str) -> None:
|
|
57
|
+
"""Push HEAD to `branch` on `remote`."""
|
|
58
|
+
_run(path, ["push", remote, f"HEAD:refs/heads/{branch}"])
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def remote_names(path: Path) -> list[str]:
|
|
62
|
+
"""Names of every remote configured in this repo (`git remote -v` names, deduplicated)."""
|
|
63
|
+
result = _run(path, ["remote"])
|
|
64
|
+
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def ref_exists(path: Path, ref: str) -> bool:
|
|
68
|
+
"""Whether `ref` resolves to a commit."""
|
|
69
|
+
result = _run(path, ["rev-parse", "--verify", "--quiet", ref], check=False)
|
|
70
|
+
return result.returncode == 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def commit_count_between(path: Path, before: str, after: str) -> int:
|
|
74
|
+
"""Number of commits reachable from `after` but not `before`."""
|
|
75
|
+
result = _run(path, ["rev-list", "--count", f"{before}..{after}"])
|
|
76
|
+
return int(result.stdout.strip())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def commit_count(path: Path, ref: str) -> int:
|
|
80
|
+
"""Total number of commits reachable from `ref`."""
|
|
81
|
+
result = _run(path, ["rev-list", "--count", ref])
|
|
82
|
+
return int(result.stdout.strip())
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict, dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
MARKER_DIR = Path(".git/remotectrl/pending-push")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class PendingPush:
|
|
12
|
+
branch: str
|
|
13
|
+
commits: int
|
|
14
|
+
since: str
|
|
15
|
+
last_attempt_error: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def marker_path(path: Path, remote: str) -> Path:
|
|
19
|
+
return path / MARKER_DIR / f"{remote}.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def write_marker(path: Path, remote: str, marker: PendingPush) -> None:
|
|
23
|
+
target = marker_path(path, remote)
|
|
24
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
target.write_text(json.dumps(asdict(marker), indent=2))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def read_marker(path: Path, remote: str) -> PendingPush | None:
|
|
29
|
+
target = marker_path(path, remote)
|
|
30
|
+
if not target.is_file():
|
|
31
|
+
return None
|
|
32
|
+
return PendingPush(**json.loads(target.read_text()))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def clear_marker(path: Path, remote: str) -> None:
|
|
36
|
+
marker_path(path, remote).unlink(missing_ok=True)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from remotectrl.gitwrap import commit_count_between, head_commit
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CommitContractError(RuntimeError):
|
|
10
|
+
"""Raised when `op` did not produce exactly one new commit."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_op(path: Path, op: Callable[[], None]) -> str:
|
|
14
|
+
"""Run `op`, verify it produced exactly one new commit on HEAD, return its hash."""
|
|
15
|
+
before = head_commit(path)
|
|
16
|
+
op()
|
|
17
|
+
after = head_commit(path)
|
|
18
|
+
count = commit_count_between(path, before, after)
|
|
19
|
+
if count != 1:
|
|
20
|
+
raise CommitContractError(
|
|
21
|
+
f"expected exactly 1 new commit, op produced {count} (before={before}, after={after})"
|
|
22
|
+
)
|
|
23
|
+
return after
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def ok(ahead: int, behind: int, mine: bool) -> bool:
|
|
5
|
+
"""Whether a branch's ahead/behind state against a remote is acceptable.
|
|
6
|
+
|
|
7
|
+
`mine=True`: acceptable as long as `behind == 0` (any `ahead` is fine).
|
|
8
|
+
`mine=False`: acceptable as long as `ahead == 0` (any `behind` is fine).
|
|
9
|
+
"""
|
|
10
|
+
return behind == 0 if mine else ahead == 0
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from remotectrl.config import RemoteType
|
|
7
|
+
from remotectrl.errors import GitError
|
|
8
|
+
from remotectrl.gitwrap import (
|
|
9
|
+
ahead_behind,
|
|
10
|
+
commit_count,
|
|
11
|
+
current_branch,
|
|
12
|
+
push_branch,
|
|
13
|
+
ref_exists,
|
|
14
|
+
)
|
|
15
|
+
from remotectrl.markers import PendingPush, clear_marker, write_marker
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PushError(RuntimeError):
|
|
19
|
+
"""Raised when a push to one or more configured remotes fails. The local commit is
|
|
20
|
+
left intact. If multiple remotes failed, all are named in the message."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run_postflight(path: Path, remotes: dict[str, RemoteType]) -> None:
|
|
24
|
+
"""Push the current branch to every non-UNSYNCED remote, in order. Every remote is
|
|
25
|
+
attempted regardless of earlier failures; failures write a pending-push marker each
|
|
26
|
+
and are raised together as a single PushError once all remotes have been tried."""
|
|
27
|
+
branch = current_branch(path)
|
|
28
|
+
failures: list[str] = []
|
|
29
|
+
|
|
30
|
+
for remote, remote_type in remotes.items():
|
|
31
|
+
if remote_type == RemoteType.UNSYNCED:
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
push_branch(path, remote, branch)
|
|
36
|
+
except GitError as exc:
|
|
37
|
+
write_marker(
|
|
38
|
+
path,
|
|
39
|
+
remote,
|
|
40
|
+
PendingPush(
|
|
41
|
+
branch=branch,
|
|
42
|
+
commits=_unpushed_count(path, remote, branch),
|
|
43
|
+
since=datetime.now(UTC).isoformat(),
|
|
44
|
+
last_attempt_error=str(exc),
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
failures.append(f"{remote}: push failed: {exc}")
|
|
48
|
+
continue
|
|
49
|
+
|
|
50
|
+
clear_marker(path, remote)
|
|
51
|
+
|
|
52
|
+
if failures:
|
|
53
|
+
raise PushError("; ".join(failures))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _unpushed_count(path: Path, remote: str, branch: str) -> int:
|
|
57
|
+
remote_ref = f"refs/remotes/{remote}/{branch}"
|
|
58
|
+
if ref_exists(path, remote_ref):
|
|
59
|
+
ahead, _behind = ahead_behind(path, branch, remote_ref)
|
|
60
|
+
return ahead
|
|
61
|
+
return commit_count(path, branch)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from remotectrl.behavior import BEHAVIOR
|
|
7
|
+
from remotectrl.config import RemoteType
|
|
8
|
+
from remotectrl.errors import GitError
|
|
9
|
+
from remotectrl.gitwrap import (
|
|
10
|
+
ahead_behind,
|
|
11
|
+
current_branch,
|
|
12
|
+
fetch_all,
|
|
13
|
+
local_branches,
|
|
14
|
+
ref_exists,
|
|
15
|
+
)
|
|
16
|
+
from remotectrl.markers import read_marker
|
|
17
|
+
from remotectrl.policy import ok
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DivergenceError(RuntimeError):
|
|
21
|
+
"""Raised when preflight finds a real ahead/behind divergence that must block the op."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class PreflightWarning:
|
|
26
|
+
remote: str
|
|
27
|
+
message: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_preflight(path: Path, remotes: dict[str, RemoteType]) -> list[PreflightWarning]:
|
|
31
|
+
"""Fetch and check every configured remote. Returns transport-failure warnings.
|
|
32
|
+
Raises DivergenceError on a real divergence."""
|
|
33
|
+
warnings: list[PreflightWarning] = []
|
|
34
|
+
branch = current_branch(path)
|
|
35
|
+
|
|
36
|
+
for remote, remote_type in remotes.items():
|
|
37
|
+
pending = read_marker(path, remote)
|
|
38
|
+
if pending is not None:
|
|
39
|
+
warnings.append(
|
|
40
|
+
PreflightWarning(
|
|
41
|
+
remote,
|
|
42
|
+
f"{pending.commits} commit(s) still unpushed since {pending.since} "
|
|
43
|
+
f"(last attempt: {pending.last_attempt_error})",
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
behavior = BEHAVIOR[remote_type]
|
|
48
|
+
if not behavior.fetch:
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
fetch_all(path, remote)
|
|
53
|
+
except GitError as exc:
|
|
54
|
+
warnings.append(PreflightWarning(remote, str(exc)))
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
if behavior.check_own_branch:
|
|
58
|
+
own_remote_ref = f"refs/remotes/{remote}/{branch}"
|
|
59
|
+
if ref_exists(path, own_remote_ref):
|
|
60
|
+
ahead, behind = ahead_behind(path, branch, own_remote_ref)
|
|
61
|
+
if not ok(ahead, behind, mine=True):
|
|
62
|
+
raise DivergenceError(
|
|
63
|
+
f"{remote}: local branch {branch!r} is {behind} commit(s) behind"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if behavior.check_other_branches:
|
|
67
|
+
for other in local_branches(path):
|
|
68
|
+
if other == branch:
|
|
69
|
+
continue
|
|
70
|
+
other_remote_ref = f"refs/remotes/{remote}/{other}"
|
|
71
|
+
if not ref_exists(path, other_remote_ref):
|
|
72
|
+
continue
|
|
73
|
+
ahead, behind = ahead_behind(path, other, other_remote_ref)
|
|
74
|
+
if not ok(ahead, behind, mine=False):
|
|
75
|
+
raise DivergenceError(
|
|
76
|
+
f"{remote}: local branch {other!r} is {ahead} commit(s) "
|
|
77
|
+
"ahead of a branch it doesn't own"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return warnings
|