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.
- micropython_branch_manager/__init__.py +5 -0
- micropython_branch_manager/__main__.py +6 -0
- micropython_branch_manager/_version.py +34 -0
- micropython_branch_manager/branch_sync.py +85 -0
- micropython_branch_manager/cli.py +189 -0
- micropython_branch_manager/config.py +64 -0
- micropython_branch_manager/exceptions.py +59 -0
- micropython_branch_manager/git.py +246 -0
- micropython_branch_manager/github.py +105 -0
- micropython_branch_manager/pr.py +191 -0
- micropython_branch_manager/rebase.py +405 -0
- micropython_branch_manager/update_branch.py +122 -0
- micropython_branch_manager-1.0.0.dist-info/METADATA +389 -0
- micropython_branch_manager-1.0.0.dist-info/RECORD +17 -0
- micropython_branch_manager-1.0.0.dist-info/WHEEL +4 -0
- micropython_branch_manager-1.0.0.dist-info/entry_points.txt +3 -0
- micropython_branch_manager-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|