Skip to content

Commit f80d7c7

Browse files
committed
Bugfix local commits on remote deleted branch
1 parent 88dcc37 commit f80d7c7

File tree

1 file changed

+93
-71
lines changed

1 file changed

+93
-71
lines changed

scripts/hooks/update/50.workspace.py

Lines changed: 93 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,40 @@
11
#!/usr/bin/env python3
22
"""
3-
This script updates all git repositories under the *src* directory of the workspace.
4-
- it fetches all remotes
5-
- it pulls the current branch (if it has an upstream)
6-
- it detects branches that are deleted on the remote and have no new commits
7-
- it offers to delete those branches
8-
- it runs in parallel for all repositories
3+
Update every git repository under the *src* directory of a ROS 2 workspace.
4+
5+
* fetches & prunes all remotes
6+
* pulls the current branch (if it has an upstream)
7+
* detects local branches whose upstream was deleted **and** have no commits
8+
unknown to any remote – offers to delete them
9+
* performs the heavy work in parallel, prints results sequentially
910
"""
1011
from __future__ import annotations
1112

1213
import os
1314
import signal
1415
import subprocess
15-
from concurrent.futures import ThreadPoolExecutor, as_completed
16-
from pathlib import Path
17-
from typing import List
1816
import time
1917
import shutil
20-
from tuda_workspace_scripts.print import *
18+
from concurrent.futures import ThreadPoolExecutor, as_completed
19+
from pathlib import Path
20+
from typing import List, Tuple
21+
22+
from tuda_workspace_scripts.print import * # TU DA helper
2123
from tuda_workspace_scripts.workspace import get_workspace_root
2224

2325
try:
2426
import git
2527
except ImportError:
2628
print(
27-
"GitPython is required! Install using 'pip3 install --user gitpython' or 'apt install python3-git'"
29+
"GitPython is required! Install with 'pip3 install --user gitpython' or "
30+
"'apt install python3-git'"
2831
)
2932
raise
3033

3134

35+
# ───────────────────────── helpers ──────────────────────────────────────────────
3236
def launch_subprocess(cmd: list[str] | tuple[str, ...], cwd: str | Path):
33-
"""Run *cmd* in *cwd* forwarding *SIGINT* to the child process group."""
37+
"""Run *cmd* in *cwd*, forwarding Ctrl-C to the child process group."""
3438
try:
3539
return subprocess.run(
3640
cmd,
@@ -42,45 +46,51 @@ def launch_subprocess(cmd: list[str] | tuple[str, ...], cwd: str | Path):
4246
preexec_fn=os.setpgrp,
4347
)
4448
except KeyboardInterrupt:
45-
# propagate Ctrl‑C to the subprocess group so *git* exits cleanly
4649
os.killpg(0, signal.SIGINT)
4750
raise
4851

4952

50-
def _is_deleted_branch(repo: "git.Repo", branch: "git.Head") -> bool:
51-
"""Return *True* if *branch* was deleted upstream and has no unique commits."""
53+
def _has_unpushed_commits(repo: git.Repo, branch_name: str) -> bool:
54+
"""True iff *branch_name* contains commits unknown to **any** remote."""
5255
try:
53-
tracking_branch = branch.tracking_branch()
54-
if tracking_branch is None:
55-
return False # not tracking → ignore
56-
57-
for remote in repo.remotes:
58-
if remote.name == tracking_branch.remote_name:
59-
if (
60-
tracking_branch in remote.refs
61-
and tracking_branch not in remote.stale_refs
62-
):
63-
# upstream still exists & not stale
64-
return False
65-
break
66-
except (git.exc.GitCommandError, Exception) as exc:
67-
print_error(
68-
f"{os.path.basename(repo.working_tree_dir)} has error on branch {branch.name}: {exc}"
56+
cnt = int(
57+
repo.git.rev_list("--count", branch_name, "--not", "--remotes").strip()
58+
or "0"
6959
)
60+
return cnt > 0
61+
except git.exc.GitCommandError:
7062
return False
7163

72-
# Upstream appears gone – do we have local commits not pushed anywhere?
64+
65+
def _is_deleted_branch(repo: git.Repo, branch: git.Head) -> Tuple[bool, str | None]:
66+
"""
67+
Returns (deletable, warning)
68+
69+
* deletable → upstream vanished **and** branch is fully merged everywhere.
70+
* warning → explanatory message when *not* deletable (None if none).
71+
"""
72+
tracking = branch.tracking_branch()
73+
if tracking is None:
74+
return False, None
75+
7376
try:
74-
if any(True for _ in repo.iter_commits("{0}@{{u}}..{0}".format(branch.name))):
75-
print_warn(
76-
f"Branch {branch.name} seems to be deleted on remote but still has commits that were not pushed."
77-
)
78-
return False
79-
except git.exc.GitCommandError:
80-
pass # Ignore error if branch is not tracking anything
81-
return True
77+
remote = repo.remotes[tracking.remote_name]
78+
if tracking in remote.refs: # upstream still exists
79+
return False, None
80+
except (IndexError, ValueError): # remote itself lost
81+
return False, None
82+
83+
if _has_unpushed_commits(repo, branch.name):
84+
warn = (
85+
f"Branch {branch.name} was deleted on the remote but still has "
86+
"commits that are not present on any remote."
87+
)
88+
return False, warn
8289

90+
return True, None
8391

92+
93+
# ───────────────────────── result container ─────────────────────────────────────
8494
class RepoResult:
8595
__slots__ = (
8696
"path",
@@ -89,6 +99,7 @@ class RepoResult:
8999
"pull_attempted",
90100
"pull_ok",
91101
"deletable",
102+
"warnings",
92103
"stdout",
93104
"stderr",
94105
"error",
@@ -102,44 +113,48 @@ def __init__(
102113
pull_attempted: bool,
103114
pull_ok: bool,
104115
deletable: List[str],
116+
warnings: List[str],
105117
stdout: str,
106118
stderr: str,
107119
error: str | None = None,
108-
) -> None:
120+
):
109121
self.path = path
110122
self.branch = branch
111123
self.fetch_ok = fetch_ok
112124
self.pull_attempted = pull_attempted
113125
self.pull_ok = pull_ok
114126
self.deletable = deletable
127+
self.warnings = warnings
115128
self.stdout = stdout
116129
self.stderr = stderr
117130
self.error = error
118131

119132

133+
# ───────────────────────── worker (parallel) ────────────────────────────────────
120134
def process_repo(repo_path: Path) -> RepoResult:
121-
"""Fetch + optional pull + stalebranch detection (runs in a worker)."""
135+
"""Fetch, optional pull, stale-branch detection runs in a thread."""
122136
try:
123137
repo = git.Repo(repo_path)
124138

125139
if not repo.head.is_valid():
126-
return RepoResult(repo_path, "no-HEAD", True, False, True, [], "", "", None)
140+
return RepoResult(
141+
repo_path, "no-HEAD", True, False, True, [], [], "", "", None
142+
)
127143

128144
branch_name = (
129145
f"detached@{repo.head.commit.hexsha[:7]}"
130146
if repo.head.is_detached
131147
else repo.head.ref.name
132148
)
133149

134-
# -- always fetch/prune first so other branches are updated --------- #
150+
# fetch & prune
135151
fetch = launch_subprocess(["git", "fetch", "--all", "--prune"], cwd=repo_path)
136152
fetch_ok = fetch.returncode == 0
137153

138-
# ------------------------------------------------------------------ #
154+
# pull current branch (fast-forward only)
139155
pull_attempted = False
140-
pull_ok = True # will stay True when pull is skipped
141-
pull_out, pull_err = "", ""
142-
156+
pull_ok = True
157+
pull_out = pull_err = ""
143158
if not repo.head.is_detached:
144159
upstream = repo.head.ref.tracking_branch()
145160
if upstream is not None and upstream in repo.refs:
@@ -149,15 +164,16 @@ def process_repo(repo_path: Path) -> RepoResult:
149164
pull_out = pull.stdout or ""
150165
pull_err = pull.stderr or ""
151166

152-
# deletable branches – only when fetch succeeded so refs are current
167+
# stale-branch detection
153168
deletable: List[str] = []
169+
warnings: List[str] = []
154170
if fetch_ok:
155-
deletable = [
156-
br.name for br in repo.branches if _is_deleted_branch(repo, br)
157-
]
158-
159-
stdout_combined = (fetch.stdout or "") + pull_out
160-
stderr_combined = (fetch.stderr or "") + pull_err
171+
for br in repo.branches:
172+
can_del, warn = _is_deleted_branch(repo, br)
173+
if can_del:
174+
deletable.append(br.name)
175+
if warn:
176+
warnings.append(warn)
161177

162178
return RepoResult(
163179
path=repo_path,
@@ -166,25 +182,28 @@ def process_repo(repo_path: Path) -> RepoResult:
166182
pull_attempted=pull_attempted,
167183
pull_ok=pull_ok,
168184
deletable=deletable,
169-
stdout=stdout_combined,
170-
stderr=stderr_combined,
185+
warnings=warnings,
186+
stdout=(fetch.stdout or "") + pull_out,
187+
stderr=(fetch.stderr or "") + pull_err,
171188
)
172189

173-
except Exception as exc: # broad catch for isolation between threads
174-
return RepoResult(repo_path, "?", False, False, False, [], "", "", str(exc))
190+
except Exception as exc: # keep other repos going
191+
return RepoResult(repo_path, "?", False, False, False, [], [], "", "", str(exc))
175192

176193

194+
# ───────────────────────── discovery ────────────────────────────────────────────
177195
def collect_repos(ws_src: Path) -> List[Path]:
178-
"""Return absolute paths of *toplevel* git work‑trees under *ws_src*."""
196+
"""Return absolute paths of *top-level* git repos under ws_src."""
179197
repos: List[Path] = []
180198
for root, dirs, _ in os.walk(ws_src):
181199
root_p = Path(root)
182200
if (root_p / ".git").is_dir():
183201
repos.append(root_p)
184-
dirs[:] = [] # prune recursion into this repo
202+
dirs[:] = [] # don’t recurse into repo
185203
return repos
186204

187205

206+
# ───────────────────────── main ────────────────────────────────────────────────
188207
def update(**_) -> bool:
189208
ws_root = get_workspace_root()
190209
if ws_root is None:
@@ -199,15 +218,13 @@ def update(**_) -> bool:
199218
print_info("No git repositories found.")
200219
return True
201220

202-
# ----------------------- parallel phase ------------------------------- #
221+
# ───── parallel phase ────────────────────────────────────────────────────
203222
total = len(repos)
204-
205223
_BAR_START = time.monotonic()
206224

207225
def _progress(idx: int):
208-
"""Draw a simple progress bar that lives on one terminal line."""
209226
cols = shutil.get_terminal_size((80, 20)).columns
210-
bar_len = max(10, min(50, cols - 30)) # leave space for counters & percent
227+
bar_len = max(10, min(50, cols - 30))
211228
filled = int(bar_len * idx / total)
212229
bar = (
213230
("=" * filled + ">" + " " * (bar_len - filled - 1))
@@ -240,7 +257,7 @@ def _progress(idx: int):
240257
finally:
241258
print() # newline after progress bar
242259

243-
# ----------------------- sequential phase ----------------------------- #
260+
# ───── sequential phase ─────────────────────────────────────────────────
244261
overall_ok = True
245262
for res in sorted(results, key=lambda r: r.path):
246263
rel = res.path.relative_to(ws_src)
@@ -251,15 +268,15 @@ def _progress(idx: int):
251268
overall_ok = False
252269
continue
253270

254-
# -- fetch status --------------------------------------------------- #
271+
# fetch status
255272
if not res.fetch_ok:
256273
print_error("git fetch failed – repository might be out of date:")
257274
if res.stderr.strip():
258275
print(res.stderr.rstrip())
259276
overall_ok = False
260-
continue # pull & deletion checks rely on fresh refs
277+
continue
261278

262-
# -- pull status ---------------------------------------------------- #
279+
# pull status
263280
if res.pull_attempted:
264281
if not res.pull_ok:
265282
print_error("git pull failed:")
@@ -272,10 +289,15 @@ def _progress(idx: int):
272289
else:
273290
print_info("skipped pull – current branch has no upstream")
274291

275-
# -- stale branches ------------------------------------------------- #
292+
# branch-specific warnings
293+
for msg in res.warnings:
294+
print_warn(msg)
295+
296+
# candidate branches for deletion
276297
if res.deletable:
277298
msg = (
278-
"The following local branches are deleted on the remote and have no extra commits:\n"
299+
"The following local branches are deleted on the remote and "
300+
"have no extra commits:\n"
279301
+ "\n".join(f" {b}" for b in res.deletable)
280302
+ "\nDelete them now?"
281303
)

0 commit comments

Comments
 (0)