micropython-branch-manager 1.0.0__py3-none-any.whl

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,5 @@
1
+ """micropython-branch-manager - Tool for managing MicroPython fork integration branches."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,6 @@
1
+ """Entry point for python -m micropython_branch_manager."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '1.0.0'
32
+ __version_tuple__ = version_tuple = (1, 0, 0)
33
+
34
+ __commit_id__ = commit_id = None
@@ -0,0 +1,85 @@
1
+ """Branch synchronization logic for micropython-branch-manager."""
2
+
3
+ import re
4
+
5
+ from .config import BranchConfig, MicroPythonConfig
6
+ from .git import GitRepo
7
+ from .github import PRInfo
8
+
9
+ MERGE_PATTERN = re.compile(r"Merge branch '([^']+)'")
10
+
11
+
12
+ def extract_branches_from_merges(git: GitRepo, target: str, integration: str) -> list[str]:
13
+ """Extract branch names from merge commits between target and integration.
14
+
15
+ Returns branches in merge order (oldest first).
16
+ """
17
+ subjects = git.get_log_subjects(f"{target}..{integration}")
18
+ branches = []
19
+ for subject in subjects:
20
+ match = MERGE_PATTERN.search(subject)
21
+ if match:
22
+ branch = match.group(1)
23
+ if branch not in branches:
24
+ branches.append(branch)
25
+ branches.reverse() # Oldest first
26
+ return branches
27
+
28
+
29
+ def sync_config_with_branches(
30
+ config: MicroPythonConfig,
31
+ detected_branches: list[str],
32
+ pr_cache: dict[str, PRInfo],
33
+ ) -> bool:
34
+ """Update config with detected branches.
35
+
36
+ Returns True if config was modified.
37
+ """
38
+ existing = {b.name: b for b in config.branches}
39
+ modified = False
40
+
41
+ for branch in detected_branches:
42
+ if branch not in existing:
43
+ entry = BranchConfig(name=branch, remote="origin")
44
+ if branch in pr_cache:
45
+ pr = pr_cache[branch]
46
+ entry.pr_url = pr.url
47
+ entry.pr_number = pr.number
48
+ entry.title = pr.title
49
+ config.branches.append(entry)
50
+ modified = True
51
+
52
+ # Reorder to match merge order
53
+ order = {name: i for i, name in enumerate(detected_branches)}
54
+ config.branches.sort(key=lambda b: order.get(b.name, 999))
55
+
56
+ return modified
57
+
58
+
59
+ def validate_branches(config: MicroPythonConfig, detected_branches: list[str]) -> list[str]:
60
+ """Validate config branches against detected branches.
61
+
62
+ Returns list of warnings about discrepancies.
63
+ """
64
+ warnings = []
65
+ detected_set = set(detected_branches)
66
+ config_set = {b.name for b in config.branches}
67
+
68
+ # Branches in config but not in merge history
69
+ missing_from_history = config_set - detected_set
70
+ if missing_from_history:
71
+ warnings.append(
72
+ f"WARNING: The following branches are in the config but not found in merge history:\n"
73
+ f" {', '.join(sorted(missing_from_history))}\n"
74
+ "This may indicate they were removed from the integration branch."
75
+ )
76
+
77
+ # Branches in merge history but not in config (will be added automatically)
78
+ missing_from_config = detected_set - config_set
79
+ if missing_from_config:
80
+ warnings.append(
81
+ f"INFO: The following branches will be added to config:\n"
82
+ f" {', '.join(sorted(missing_from_config))}"
83
+ )
84
+
85
+ return warnings
@@ -0,0 +1,189 @@
1
+ """CLI interface for micropython-branch-manager."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ import cyclopts
8
+
9
+ from .branch_sync import extract_branches_from_merges, sync_config_with_branches
10
+ from .config import CONFIG_FILENAME, MicroPythonConfig
11
+ from .exceptions import GitHubCLINotFound, MBMError
12
+ from .git import GitRepo
13
+ from .github import GitHubClient
14
+ from .pr import PRManager
15
+ from .rebase import RebaseManager
16
+
17
+ app = cyclopts.App(
18
+ name="mbm",
19
+ help="Manage MicroPython fork integration branches.",
20
+ )
21
+
22
+ # Common parameter types
23
+ SubmodulePath = Annotated[
24
+ Path | None,
25
+ cyclopts.Parameter(
26
+ name="--submodule",
27
+ help="Path to MicroPython submodule (default: auto-detect)",
28
+ ),
29
+ ]
30
+
31
+
32
+ def resolve_submodule(path: Path | None) -> Path:
33
+ """Resolve submodule path, auto-detecting if not specified."""
34
+ if path:
35
+ return path.resolve()
36
+ # Try common locations
37
+ for candidate in [Path("src/micropython"), Path("micropython"), Path(".")]:
38
+ config_path = candidate / CONFIG_FILENAME
39
+ if config_path.exists() or (candidate / ".git").exists():
40
+ return candidate.resolve()
41
+ raise ValueError("Could not auto-detect submodule path. Use --submodule.")
42
+
43
+
44
+ @app.command
45
+ def init(
46
+ submodule: SubmodulePath = None,
47
+ integration_branch: Annotated[
48
+ str | None,
49
+ cyclopts.Parameter(name=["--integration-branch", "-b"]),
50
+ ] = None,
51
+ ) -> None:
52
+ """Initialize configuration in submodule."""
53
+ path = resolve_submodule(submodule)
54
+ git = GitRepo(path)
55
+
56
+ # If integration branch not specified, detect current branch
57
+ if integration_branch is None:
58
+ integration_branch = git.run("branch", "--show-current")
59
+ if not integration_branch:
60
+ # Detached HEAD or other issue, fall back to main
61
+ integration_branch = "main"
62
+ print(f"Using current branch as integration branch: {integration_branch}")
63
+
64
+ # Detect existing git remotes
65
+ remotes = git.list_remotes()
66
+ if remotes:
67
+ print(f"\nDetected {len(remotes)} remote(s):")
68
+ for name, url in remotes.items():
69
+ print(f" - {name}: {url}")
70
+ else:
71
+ print("\nNo remotes detected. You'll need to add them manually to the config.")
72
+
73
+ config = MicroPythonConfig(integration_branch=integration_branch, remotes=remotes)
74
+ config.save(path)
75
+ print(f"\nCreated {path / CONFIG_FILENAME}")
76
+
77
+
78
+ @app.command
79
+ def rebase(
80
+ target: str = "upstream/master",
81
+ submodule: SubmodulePath = None,
82
+ local: Annotated[bool, cyclopts.Parameter(name="--local")] = False,
83
+ dry_run: Annotated[bool, cyclopts.Parameter(name="--dry-run")] = False,
84
+ force_push: Annotated[bool, cyclopts.Parameter(name="--force-push")] = False,
85
+ resume: Annotated[bool, cyclopts.Parameter(name="--resume")] = False,
86
+ ) -> None:
87
+ """Rebase integration branch by rebuilding from fresh PRs."""
88
+ path = resolve_submodule(submodule)
89
+ manager = RebaseManager(path)
90
+ manager.execute(
91
+ target=target,
92
+ local_only=local,
93
+ dry_run=dry_run,
94
+ force_push=force_push,
95
+ resume=resume,
96
+ )
97
+
98
+
99
+ @app.command
100
+ def add_pr(
101
+ pr_identifier: str,
102
+ submodule: SubmodulePath = None,
103
+ ) -> None:
104
+ """Add PR to integration branch."""
105
+ path = resolve_submodule(submodule)
106
+ # Detect parent repo
107
+ parent_path = path.parent
108
+ parent: Path | None = parent_path if (parent_path / ".git").exists() else None
109
+
110
+ manager = PRManager(path, parent)
111
+ manager.add_pr(pr_identifier)
112
+
113
+
114
+ @app.command
115
+ def sync(
116
+ gh_author: Annotated[
117
+ str,
118
+ cyclopts.Parameter(
119
+ help="GitHub username to look up PR metadata for branches",
120
+ ),
121
+ ],
122
+ submodule: SubmodulePath = None,
123
+ ) -> None:
124
+ """Sync JSON config with current branch state.
125
+
126
+ Detects branches from merge commits and updates config with PR metadata.
127
+ """
128
+ path = resolve_submodule(submodule)
129
+ config = MicroPythonConfig.load(path, require_exists=True)
130
+ git = GitRepo(path)
131
+
132
+ # Setup remotes
133
+ for name, url in config.remotes.items():
134
+ git.ensure_remote(name, url)
135
+
136
+ # Detect branches
137
+ detected = extract_branches_from_merges(git, "upstream/master", config.integration_branch)
138
+
139
+ # Get PR cache from GitHub
140
+ pr_cache = {}
141
+ try:
142
+ github = GitHubClient()
143
+ pr_cache = github.get_pr_info_by_branches(gh_author)
144
+ except GitHubCLINotFound:
145
+ pass # Continue without PR info
146
+
147
+ # Sync
148
+ if sync_config_with_branches(config, detected, pr_cache):
149
+ config.save(path)
150
+ print(f"Updated {path / CONFIG_FILENAME}")
151
+ else:
152
+ print("Config already in sync")
153
+
154
+
155
+ @app.command
156
+ def config(
157
+ submodule: SubmodulePath = None,
158
+ ) -> None:
159
+ """Show current configuration."""
160
+ path = resolve_submodule(submodule)
161
+ cfg = MicroPythonConfig.load(path, require_exists=True)
162
+
163
+ print(f"Integration branch: {cfg.integration_branch}")
164
+
165
+ # Show branches first
166
+ print(f"\nBranches ({len(cfg.branches)}):")
167
+ for b in cfg.branches:
168
+ title = b.title or "(no title)"
169
+ print(f" {b.remote}/{b.name}: {title}")
170
+ if b.pr_url:
171
+ print(f" - {b.pr_url}")
172
+ print() # Blank line between entries
173
+
174
+ # Only show remotes that are referenced by branches
175
+ used_remotes = {b.remote for b in cfg.branches}
176
+ used_remotes.add("upstream") # Always show upstream
177
+ remotes_to_show = {k: v for k, v in cfg.remotes.items() if k in used_remotes}
178
+ print(f"Remotes ({len(remotes_to_show)}):")
179
+ for name, url in remotes_to_show.items():
180
+ print(f" {name}: {url}")
181
+
182
+
183
+ def main() -> None:
184
+ """Entry point."""
185
+ try:
186
+ app()
187
+ except MBMError as e:
188
+ print(f"Error: {e}", file=sys.stderr)
189
+ sys.exit(1)
@@ -0,0 +1,64 @@
1
+ """Configuration models for micropython-branch-manager."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field
7
+
8
+ from .exceptions import ConfigError
9
+
10
+ CONFIG_FILENAME = ".micropython-branches.json"
11
+ CONFIG_HEADER = "_generated_by"
12
+ CONFIG_HEADER_VALUE = "micropython-branch-manager (mbm) - do not edit manually"
13
+
14
+
15
+ class BranchConfig(BaseModel):
16
+ """Configuration for a single feature branch."""
17
+
18
+ name: str
19
+ remote: str = "origin"
20
+ pr_url: str | None = None
21
+ pr_number: int | None = None
22
+ title: str | None = None
23
+
24
+
25
+ class MicroPythonConfig(BaseModel):
26
+ """Root configuration model."""
27
+
28
+ model_config = ConfigDict(extra="ignore")
29
+
30
+ integration_branch: str = "main"
31
+ remotes: dict[str, str] = Field(default_factory=dict)
32
+ branches: list[BranchConfig] = Field(default_factory=list)
33
+
34
+ @classmethod
35
+ def load(cls, submodule_path: Path, require_exists: bool = False) -> "MicroPythonConfig":
36
+ """Load from .micropython-branches.json in submodule root.
37
+
38
+ Args:
39
+ submodule_path: Path to the submodule directory.
40
+ require_exists: If True, raise ConfigError if config file doesn't exist.
41
+ """
42
+ config_path = submodule_path / CONFIG_FILENAME
43
+ if not config_path.exists():
44
+ if require_exists:
45
+ raise ConfigError(
46
+ f"No config file found. Run 'mbm init' first to create {CONFIG_FILENAME}"
47
+ )
48
+ return cls()
49
+ return cls.model_validate_json(config_path.read_text())
50
+
51
+ def save(self, submodule_path: Path) -> None:
52
+ """Save to .micropython-branches.json in submodule root."""
53
+ config_path = submodule_path / CONFIG_FILENAME
54
+ # Build dict with header field first, then model fields
55
+ data = {CONFIG_HEADER: CONFIG_HEADER_VALUE, **self.model_dump()}
56
+ config_path.write_text(json.dumps(data, indent=2) + "\n")
57
+
58
+ def get_branch(self, name: str) -> BranchConfig | None:
59
+ """Get branch config by name."""
60
+ return next((b for b in self.branches if b.name == name), None)
61
+
62
+ def has_branch(self, name: str) -> bool:
63
+ """Check if branch exists in config."""
64
+ return any(b.name == name for b in self.branches)
@@ -0,0 +1,59 @@
1
+ """Custom exceptions for micropython-branch-manager."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ class MBMError(Exception):
7
+ """Base exception for micropython-branch-manager."""
8
+
9
+
10
+ class ConfigError(MBMError):
11
+ """Configuration loading or validation error."""
12
+
13
+
14
+ class GitError(MBMError):
15
+ """Git operation failed."""
16
+
17
+
18
+ class GitHubError(MBMError):
19
+ """GitHub CLI operation failed."""
20
+
21
+
22
+ class GitHubCLINotFound(GitHubError):
23
+ """gh CLI not installed or not in PATH."""
24
+
25
+
26
+ class RebaseConflictError(GitError):
27
+ """Rebase stopped due to conflicts requiring manual resolution."""
28
+
29
+ def __init__(
30
+ self,
31
+ message: str,
32
+ conflicting_files: list[str] | None = None,
33
+ pr_context: dict[str, Any] | None = None,
34
+ ):
35
+ super().__init__(message)
36
+ self.conflicting_files = conflicting_files or []
37
+ self.pr_context = pr_context or {}
38
+
39
+
40
+ class MergeConflictError(GitError):
41
+ """Merge stopped due to conflicts requiring manual resolution."""
42
+
43
+ def __init__(self, message: str, conflicting_files: list[str] | None = None):
44
+ super().__init__(message)
45
+ self.conflicting_files = conflicting_files or []
46
+
47
+
48
+ class WorkingTreeDirtyError(GitError):
49
+ """Working tree has uncommitted changes."""
50
+
51
+
52
+ class RemoteDivergenceError(GitError):
53
+ """Remote branch has diverged from local branch."""
54
+
55
+ def __init__(self, message: str, branch: str, remote_commits: int, local_commits: int):
56
+ super().__init__(message)
57
+ self.branch = branch
58
+ self.remote_commits = remote_commits
59
+ self.local_commits = local_commits
@@ -0,0 +1,246 @@
1
+ """Git operations wrapper for micropython-branch-manager."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from .exceptions import (
7
+ GitError,
8
+ MergeConflictError,
9
+ RebaseConflictError,
10
+ RemoteDivergenceError,
11
+ WorkingTreeDirtyError,
12
+ )
13
+
14
+
15
+ class GitRepo:
16
+ """Wrapper for git operations in a specific directory."""
17
+
18
+ def __init__(self, repo_path: Path):
19
+ self.repo_path = repo_path
20
+
21
+ def run(self, *args: str, check: bool = True, capture: bool = True) -> str:
22
+ """Execute git command, return stdout."""
23
+ cmd = ["git", *args]
24
+ result = subprocess.run(
25
+ cmd,
26
+ cwd=self.repo_path,
27
+ capture_output=capture,
28
+ text=True,
29
+ )
30
+ if check and result.returncode != 0:
31
+ raise GitError(f"git {args[0]} failed: {result.stderr}")
32
+ return result.stdout.strip() if result.stdout else ""
33
+
34
+ def is_clean(self) -> bool:
35
+ """Check if working tree is clean."""
36
+ return self.run("status", "--porcelain") == ""
37
+
38
+ def require_clean(self) -> None:
39
+ """Raise if working tree has uncommitted changes."""
40
+ if not self.is_clean():
41
+ raise WorkingTreeDirtyError(
42
+ "Working tree has uncommitted changes. Please commit or stash changes first."
43
+ )
44
+
45
+ def fetch(self, remote: str = "--all") -> None:
46
+ """Fetch from remote(s)."""
47
+ # Sync submodule URLs first to avoid stale references
48
+ self.run("submodule", "sync", "--recursive", check=False)
49
+
50
+ # Fetch from remotes - allow submodule errors but catch main repo errors
51
+ cmd = ["git", "fetch", remote]
52
+ result = subprocess.run(
53
+ cmd,
54
+ cwd=self.repo_path,
55
+ capture_output=True,
56
+ text=True,
57
+ )
58
+ # Only raise error if the main fetch failed (not just submodule errors)
59
+ # Git fetch writes to stderr, check if there are non-submodule errors
60
+ if result.returncode != 0:
61
+ stderr = result.stderr.lower()
62
+ # Allow submodule fetch errors as they don't affect main repo fetch
63
+ if "submodule" not in stderr or (
64
+ "fatal" in stderr and "submodule" not in stderr.split("fatal")[0]
65
+ ):
66
+ raise GitError(f"git fetch failed: {result.stderr}")
67
+
68
+ def checkout(self, ref: str, create: bool = False, force: bool = False) -> None:
69
+ """Checkout branch or ref."""
70
+ args = ["checkout"]
71
+ if create:
72
+ args.append("-B" if force else "-b")
73
+ args.append(ref)
74
+ self.run(*args)
75
+
76
+ def rebase(self, onto: str, update_refs: bool = True) -> None:
77
+ """Rebase with merge commit preservation.
78
+
79
+ Raises:
80
+ RebaseConflictError: If conflicts require manual resolution
81
+ """
82
+ args = ["rebase", "--rebase-merges"]
83
+ if update_refs:
84
+ args.append("--update-refs")
85
+ args.append(onto)
86
+
87
+ try:
88
+ self.run(*args)
89
+ except GitError as e:
90
+ # Check if this is a conflict situation
91
+ status = self.run("status", "--porcelain", check=False)
92
+ if "UU " in status or "AA " in status or "DD " in status:
93
+ conflicting = [
94
+ line[3:]
95
+ for line in status.split("\n")
96
+ if line.startswith(("UU ", "AA ", "DD "))
97
+ ]
98
+ raise RebaseConflictError(
99
+ "Rebase stopped due to conflicts.\n"
100
+ "Conflicting files:\n " + "\n ".join(conflicting) + "\n\n"
101
+ "Please resolve conflicts manually, then run:\n"
102
+ " git rebase --continue\n\n"
103
+ "Or to abort:\n"
104
+ " git rebase --abort",
105
+ conflicting_files=conflicting,
106
+ ) from e
107
+ raise
108
+
109
+ def merge(self, branch: str, message: str, no_ff: bool = True) -> None:
110
+ """Merge branch with optional no-ff.
111
+
112
+ Raises:
113
+ MergeConflictError: If conflicts require manual resolution
114
+ """
115
+ args = ["merge"]
116
+ if no_ff:
117
+ args.append("--no-ff")
118
+ args.extend([branch, "-m", message])
119
+
120
+ try:
121
+ self.run(*args)
122
+ except GitError as e:
123
+ status = self.run("status", "--porcelain", check=False)
124
+ if "UU " in status or "AA " in status or "DD " in status:
125
+ conflicting = [
126
+ line[3:]
127
+ for line in status.split("\n")
128
+ if line.startswith(("UU ", "AA ", "DD "))
129
+ ]
130
+ raise MergeConflictError(
131
+ "Merge stopped due to conflicts.\n"
132
+ "Conflicting files:\n " + "\n ".join(conflicting) + "\n\n"
133
+ "Please resolve conflicts manually, then run:\n"
134
+ " git merge --continue\n\n"
135
+ "Or to abort:\n"
136
+ " git merge --abort",
137
+ conflicting_files=conflicting,
138
+ ) from e
139
+ raise
140
+
141
+ def push(self, remote: str, branch: str, force: bool = False) -> None:
142
+ """Push branch to remote."""
143
+ args = ["push"]
144
+ if force:
145
+ args.append("--force")
146
+ args.extend([remote, branch])
147
+ self.run(*args)
148
+
149
+ def get_merge_base(self, ref1: str, ref2: str) -> str:
150
+ """Get merge base commit."""
151
+ return self.run("merge-base", ref1, ref2)
152
+
153
+ def rev_parse(self, ref: str) -> str:
154
+ """Resolve ref to commit hash."""
155
+ return self.run("rev-parse", ref)
156
+
157
+ def branch_exists(self, branch: str, remote: bool = False) -> bool:
158
+ """Check if branch exists."""
159
+ ref = f"refs/remotes/{branch}" if remote else f"refs/heads/{branch}"
160
+ try:
161
+ self.run("rev-parse", "--verify", ref)
162
+ return True
163
+ except GitError:
164
+ return False
165
+
166
+ def create_branch(self, name: str, start_point: str) -> None:
167
+ """Create branch from start point."""
168
+ self.run("branch", name, start_point)
169
+
170
+ def set_branch_upstream(self, branch: str, upstream: str) -> None:
171
+ """Set upstream tracking for a branch."""
172
+ self.run("branch", f"--set-upstream-to={upstream}", branch)
173
+
174
+ def ensure_remote(self, name: str, url: str) -> None:
175
+ """Ensure remote exists with correct URL."""
176
+ try:
177
+ current = self.run("config", f"remote.{name}.url")
178
+ if current != url:
179
+ self.run("remote", "set-url", name, url)
180
+ except GitError:
181
+ self.run("remote", "add", name, url)
182
+
183
+ def list_remotes(self) -> dict[str, str]:
184
+ """List all remotes with URLs."""
185
+ output = self.run("remote", "-v")
186
+ remotes = {}
187
+ for line in output.split("\n"):
188
+ if line and "(fetch)" in line:
189
+ parts = line.split()
190
+ if len(parts) >= 2:
191
+ remotes[parts[0]] = parts[1]
192
+ return remotes
193
+
194
+ def get_log_subjects(self, range_spec: str) -> list[str]:
195
+ """Get commit subjects in range."""
196
+ output = self.run("log", "--format=%s", range_spec)
197
+ return [line for line in output.split("\n") if line]
198
+
199
+ def check_divergence(
200
+ self, local_branch: str, remote_branch: str, force_push: bool = False
201
+ ) -> None:
202
+ """Check if remote branch has diverged from local branch.
203
+
204
+ Args:
205
+ local_branch: Local branch name
206
+ remote_branch: Remote branch ref (e.g., 'origin/branch-name')
207
+ force_push: If True, skip the divergence check
208
+
209
+ Raises:
210
+ RemoteDivergenceError: If remote has commits not in local branch
211
+ """
212
+ if force_push:
213
+ return
214
+
215
+ try:
216
+ # Check if remote branch exists
217
+ self.run("rev-parse", "--verify", remote_branch)
218
+ except GitError:
219
+ # Remote branch doesn't exist, no divergence
220
+ return
221
+
222
+ # Count commits in remote not in local
223
+ try:
224
+ remote_only = self.run("rev-list", "--count", f"{local_branch}..{remote_branch}")
225
+ local_only = self.run("rev-list", "--count", f"{remote_branch}..{local_branch}")
226
+
227
+ remote_count = int(remote_only) if remote_only else 0
228
+ local_count = int(local_only) if local_only else 0
229
+
230
+ if remote_count > 0:
231
+ raise RemoteDivergenceError(
232
+ f"Remote branch '{remote_branch}' has {remote_count} commit(s) "
233
+ f"not in local branch '{local_branch}'.\n"
234
+ f"Local branch has {local_count} commit(s) not in remote.\n\n"
235
+ "This may indicate that the remote has been updated since your last fetch.\n"
236
+ "Options:\n"
237
+ " 1. Fetch and review remote changes first\n"
238
+ " 2. Use --force-push to overwrite remote (USE WITH CAUTION)\n"
239
+ " 3. Abort and investigate the divergence",
240
+ branch=local_branch,
241
+ remote_commits=remote_count,
242
+ local_commits=local_count,
243
+ )
244
+ except ValueError:
245
+ # Error parsing commit counts, skip check
246
+ pass