Skip to content

Commit 170d186

Browse files
authored
feat: close branch-list / push-delete / default-branch gaps (#171-#176) (#174)
* feat(git,github): close branch-list/push-delete/default-branch gaps Three missing-capability issues addressed in one change. Issue #171 — register git_branch_list as a lean MCP tool - Extend existing _branch_ops.git_branch_list with schema requested in the issue: branch_type (local|remote|all), pattern, contains, merged. Return structured records (name/sha/is_current/upstream). - Legacy remote=/all= bools kept as aliases for back-compat; explicit conflict with branch_type surfaces as error text. - Add GitBranchList pydantic model and register in lean/registry_git. - 21 tests (test_git_branch_list.py rewritten). Issue #172 — default_branch passthrough on github_update_repo_settings - Add default_branch: str | None field to GitHubUpdateRepoSettings and forward to the PATCH /repos/{owner}/{name} payload. - Unblocks workflows that need to flip the repo's default branch (e.g. main -> development for sub-package conventions). - 1 new test verifies the payload entry. Issue #173 — delete + raw refspec support on git_push - Add delete: bool and refspec: str | None fields to GitPush. - model_validator enforces five mutual-exclusion rules; same guards mirrored in the handler in case lean dispatch skips pydantic. - delete=True dispatches `git push <remote> --delete <branch>`; refspec dispatches `git push <remote> <refspec>`. - Closes the circular dead-end where shell-runner refused `git push --delete` and the MCP tool didn't support it. - 16 new tests in test_git_push_delete.py. * feat(git): add sort to git_branch_list, dry_run to git_push Bundled into PR #174 to avoid staggering MCP server restarts. Issue #175 — git_branch_list sort - Add sort: str | None field to GitBranchList (e.g. '-committerdate', 'refname', 'authordate'); mirrors `git for-each-ref --sort=<key>`. - When set, branches are collected via for_each_ref with NUL-separated format; existing pattern/contains/merged filters apply post-fetch. - sort=None preserves the legacy collection path (no perf regression). - 10 new tests in TestGitBranchListSort. Issue #176 — git_push dry_run - Add dry_run: bool = False to GitPush; orthogonal to all existing mutual-exclusion rules (no validator changes). - Appends --dry-run to all four push paths: normal, force, delete, raw refspec. Return string carries a 'dry-run' marker. - 11 new tests in TestGitPushDryRun.
1 parent 8ec9a2a commit 170d186

9 files changed

Lines changed: 1024 additions & 148 deletions

File tree

src/mcp_server_git/git/_branch_ops.py

Lines changed: 202 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Branch operations for MCP Git Server."""
22

3+
import fnmatch
34
import logging
5+
from typing import Any, Literal
46

57
from ..utils.git_import import GitCommandError, Repo
68

@@ -116,43 +118,221 @@ def git_checkout(repo: Repo, branch_name: str) -> str:
116118
return f"❌ Checkout error: {str(e)}"
117119

118120

121+
def _resolve_branch_type(
122+
branch_type: Literal["local", "remote", "all"],
123+
remote: bool,
124+
all: bool, # noqa: A002
125+
) -> Literal["local", "remote", "all"]:
126+
"""Resolve branch_type from new param or legacy bool aliases."""
127+
legacy_used = remote or all
128+
explicit_type = branch_type != "local"
129+
130+
if explicit_type and legacy_used:
131+
raise ValueError(
132+
"Cannot combine 'branch_type' with legacy 'remote'/'all' flags. "
133+
"Use 'branch_type' only."
134+
)
135+
136+
if legacy_used:
137+
if all:
138+
return "all"
139+
return "remote"
140+
141+
return branch_type
142+
143+
144+
def _get_contains_set(repo: Repo, contains: str) -> set[str]:
145+
"""Return set of branch names (stripped) that contain the given commit-ish."""
146+
raw = repo.git.branch("--contains", contains)
147+
names = set()
148+
for line in raw.splitlines():
149+
name = line.lstrip("* ").strip()
150+
if name:
151+
names.add(name)
152+
return names
153+
154+
155+
def _get_merged_set(repo: Repo, *, merged: bool) -> set[str]:
156+
"""Return set of branch names filtered by --merged or --no-merged."""
157+
flag = "--merged" if merged else "--no-merged"
158+
raw = repo.git.branch(flag)
159+
names = set()
160+
for line in raw.splitlines():
161+
name = line.lstrip("* ").strip()
162+
if name:
163+
names.add(name)
164+
return names
165+
166+
167+
def _build_branch_record(
168+
name: str,
169+
sha: str,
170+
is_current: bool,
171+
upstream: str | None,
172+
) -> dict[str, Any]:
173+
return {"name": name, "sha": sha, "is_current": is_current, "upstream": upstream}
174+
175+
176+
def _collect_local_branches(repo: Repo) -> list[dict[str, Any]]:
177+
"""Collect local branch records."""
178+
try:
179+
active = repo.active_branch.name
180+
except TypeError:
181+
active = None # detached HEAD
182+
183+
records = []
184+
for head in repo.branches:
185+
try:
186+
upstream = (
187+
head.tracking_branch().name
188+
if head.tracking_branch() is not None
189+
else None
190+
)
191+
except Exception:
192+
upstream = None
193+
records.append(
194+
_build_branch_record(
195+
name=head.name,
196+
sha=head.commit.hexsha,
197+
is_current=(head.name == active),
198+
upstream=upstream,
199+
)
200+
)
201+
return records
202+
203+
204+
def _collect_remote_branches(repo: Repo) -> list[dict[str, Any]]:
205+
"""Collect remote branch records (remotes/origin/... style names)."""
206+
records = []
207+
for remote in repo.remotes:
208+
for ref in remote.refs:
209+
if ref.name.endswith("/HEAD"):
210+
continue
211+
records.append(
212+
_build_branch_record(
213+
name=ref.name,
214+
sha=ref.commit.hexsha,
215+
is_current=False,
216+
upstream=None,
217+
)
218+
)
219+
return records
220+
221+
222+
def _collect_sorted_branches(
223+
repo: Repo,
224+
effective_type: Literal["local", "remote", "all"],
225+
sort: str,
226+
) -> list[dict[str, Any]]:
227+
"""Collect branches in sorted order using git for-each-ref."""
228+
patterns: list[str]
229+
if effective_type == "local":
230+
patterns = ["refs/heads"]
231+
elif effective_type == "remote":
232+
patterns = ["refs/remotes"]
233+
else:
234+
patterns = ["refs/heads", "refs/remotes"]
235+
236+
fmt = "%(refname:short)%00%(objectname)%00%(upstream:short)"
237+
raw = repo.git.for_each_ref(f"--sort={sort}", f"--format={fmt}", *patterns)
238+
239+
try:
240+
active = repo.active_branch.name
241+
except TypeError:
242+
active = None # detached HEAD
243+
244+
records = []
245+
for line in raw.splitlines():
246+
if not line:
247+
continue
248+
parts = line.split("\x00")
249+
if len(parts) < 3: # pragma: no cover
250+
continue
251+
name, sha, upstream = parts[0], parts[1], parts[2]
252+
# Skip remote HEAD symbolic refs
253+
if name.endswith("/HEAD"):
254+
continue
255+
records.append(
256+
_build_branch_record(
257+
name=name,
258+
sha=sha,
259+
is_current=(name == active),
260+
upstream=upstream if upstream else None,
261+
)
262+
)
263+
return records
264+
265+
266+
def _format_branches(records: list[dict[str, Any]]) -> str:
267+
if not records:
268+
return "No branches found"
269+
lines = []
270+
for r in records:
271+
marker = "* " if r["is_current"] else " "
272+
sha_short = r["sha"][:8]
273+
upstream_info = f" -> {r['upstream']}" if r["upstream"] else ""
274+
lines.append(f"{marker}{r['name']} [{sha_short}]{upstream_info}")
275+
return "Branches:\n" + "\n".join(lines)
276+
277+
119278
def git_branch_list(
120279
repo: Repo,
121-
remote: bool = False,
122-
all: bool = False,
280+
branch_type: Literal["local", "remote", "all"] = "local",
123281
pattern: str | None = None,
282+
contains: str | None = None,
283+
merged: bool | None = None,
284+
sort: str | None = None,
285+
# Deprecated back-compat aliases — derive branch_type from these if branch_type
286+
# is at default ("local") and no explicit branch_type was set.
287+
remote: bool = False,
288+
all: bool = False, # noqa: A002
124289
) -> str:
125-
"""List branches in the repository
290+
"""List branches in the repository.
126291
127292
Args:
128293
repo: Repository object
129-
remote: If True, list remote branches (git branch -r)
130-
all: If True, list all branches including remote (git branch -a)
131-
pattern: Optional pattern to filter branches (supports glob patterns like 'feature/*')
294+
branch_type: Which branches to list — "local" (default), "remote", or "all"
295+
pattern: Optional fnmatch glob to filter branch names, e.g. 'feature/*'
296+
contains: commit-ish; only branches containing this commit are returned
297+
merged: True = only merged into HEAD, False = only unmerged, None = no filter
298+
sort: Sort key for git for-each-ref, e.g. '-committerdate'. None = legacy path.
299+
remote: Deprecated. Use branch_type='remote' instead.
300+
all: Deprecated. Use branch_type='all' instead.
132301
133302
Returns:
134-
String containing the list of branches
303+
Formatted string listing branches with sha and upstream info.
135304
"""
136305
try:
137-
args = []
138-
139-
# Add branch listing flags
140-
if all:
141-
args.append("-a")
142-
elif remote:
143-
args.append("-r")
144-
145-
# When using pattern, we need to add --list flag
306+
effective_type = _resolve_branch_type(branch_type, remote, all)
307+
308+
# Collect candidate records
309+
if sort is not None:
310+
records = _collect_sorted_branches(repo, effective_type, sort)
311+
elif effective_type == "local":
312+
records = _collect_local_branches(repo)
313+
elif effective_type == "remote":
314+
records = _collect_remote_branches(repo)
315+
else: # "all"
316+
records = _collect_local_branches(repo) + _collect_remote_branches(repo)
317+
318+
# Apply pattern filter
146319
if pattern and pattern.strip():
147-
args.append("--list")
148-
args.append(pattern)
320+
records = [r for r in records if fnmatch.fnmatch(r["name"], pattern)]
149321

150-
branch_output = repo.git.branch(*args)
322+
# Apply contains filter
323+
if contains is not None:
324+
allowed = _get_contains_set(repo, contains)
325+
records = [r for r in records if r["name"] in allowed]
151326

152-
if not branch_output.strip():
153-
return "No branches found"
327+
# Apply merged filter
328+
if merged is not None:
329+
allowed = _get_merged_set(repo, merged=merged)
330+
records = [r for r in records if r["name"] in allowed]
154331

155-
return f"Branches:\n{branch_output}"
332+
return _format_branches(records)
333+
334+
except ValueError as e:
335+
return f"❌ Branch list error: {str(e)}"
156336
except GitCommandError as e:
157337
return f"❌ Branch list failed: {str(e)}"
158338
except Exception as e:

src/mcp_server_git/git/_remote_ops.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ def git_push(
6767
force_with_lease: bool = False,
6868
force_with_lease_expect: str | None = None,
6969
force_if_includes: bool = False,
70+
delete: bool = False,
71+
refspec: str | None = None,
72+
dry_run: bool = False,
7073
) -> str:
7174
"""Push with comprehensive authentication including fallback to system git credentials.
7275
@@ -81,6 +84,16 @@ def git_push(
8184
Composes with ``--force-with-lease`` to also detect rebase-on-stale-base.
8285
8386
``force`` and ``force_with_lease`` are mutually exclusive.
87+
88+
Delete / refspec controls (issue #173):
89+
- ``delete``: maps to ``--delete <branch>`` (delete remote branch).
90+
Requires ``branch``; mutually exclusive with force/refspec.
91+
- ``refspec``: raw push refspec (e.g. ``src:dst`` or ``:branch``).
92+
Mutually exclusive with ``branch``/``delete``.
93+
94+
Dry-run control (issue #176):
95+
- ``dry_run``: maps to ``--dry-run``. Compatible with all push modes.
96+
When True, git reports what would be pushed without modifying remote state.
8497
"""
8598
try:
8699
# Validate force-push parameter combinations (issue #161)
@@ -92,6 +105,36 @@ def git_push(
92105
if force_with_lease_expect is not None and not force_with_lease:
93106
return "❌ force_with_lease_expect requires force_with_lease=True"
94107

108+
# Validate delete/refspec combinations (issue #173)
109+
if delete:
110+
if not branch:
111+
return "❌ delete=True requires branch to be set"
112+
if force or force_with_lease or force_if_includes:
113+
return "❌ delete=True cannot be combined with force/force_with_lease/force_if_includes"
114+
if set_upstream:
115+
return "❌ delete=True cannot be combined with set_upstream"
116+
if refspec is not None:
117+
return "❌ delete=True cannot be combined with refspec"
118+
if refspec is not None:
119+
if branch is not None:
120+
return "❌ refspec cannot be combined with branch"
121+
if set_upstream:
122+
return "❌ refspec cannot be combined with set_upstream"
123+
124+
# Handle raw refspec push
125+
if refspec is not None:
126+
extra = ["--dry-run"] if dry_run else []
127+
repo.git.push(remote, refspec, *extra)
128+
suffix = " (dry-run; no remote state modified)" if dry_run else ""
129+
return f"✅ Successfully pushed refspec '{refspec}' to {remote}{suffix}"
130+
131+
# Handle delete remote branch
132+
if delete:
133+
extra = ["--dry-run"] if dry_run else []
134+
repo.git.push(remote, "--delete", branch, *extra)
135+
suffix = " (dry-run; no remote state modified)" if dry_run else ""
136+
return f"✅ Successfully deleted remote branch '{branch}' from {remote}{suffix}"
137+
95138
# Get current branch if not specified
96139
if not branch:
97140
try:
@@ -121,6 +164,8 @@ def git_push(
121164
if force_if_includes:
122165
# Compatible with --force-with-lease; harmless without it on git 2.30+.
123166
push_args.insert(0, "--force-if-includes")
167+
if dry_run:
168+
push_args.append("--dry-run")
124169

125170
# Get remote URL for GitHub authentication handling (cache for reuse)
126171
remote_url = ""
@@ -176,6 +221,8 @@ def git_push(
176221
success_msg = f"✅ Successfully pushed {branch} to {remote}"
177222
if set_upstream:
178223
success_msg += " (set upstream tracking)"
224+
if dry_run:
225+
success_msg += " (dry-run; no remote state modified)"
179226

180227
# Indicate which authentication method was used
181228
if os.getenv("GITHUB_TOKEN"):
@@ -217,6 +264,8 @@ def git_push(
217264
success_msg = f"✅ Successfully pushed {branch} to {remote}"
218265
if set_upstream:
219266
success_msg += " (set upstream tracking)"
267+
if dry_run:
268+
success_msg += " (dry-run; no remote state modified)"
220269
success_msg += "\n🔐 Used system git authentication"
221270
return success_msg
222271
else:
@@ -258,6 +307,8 @@ def git_push(
258307
success_msg = f"✅ Successfully pushed {branch} to {remote}"
259308
if set_upstream:
260309
success_msg += " (set upstream tracking)"
310+
if dry_run:
311+
success_msg += " (dry-run; no remote state modified)"
261312
return success_msg
262313
except GitCommandError as e:
263314
# If regular push fails and this is GitHub HTTPS, suggest auth options

0 commit comments

Comments
 (0)