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.
@@ -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,9 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class GitError(RuntimeError):
5
+ """Raised when a git subprocess call fails."""
6
+
7
+
8
+ class ConfigError(RuntimeError):
9
+ """Raised when `.rc/remotes.toml` is malformed."""
@@ -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