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"""
1011from __future__ import annotations
1112
1213import os
1314import signal
1415import subprocess
15- from concurrent .futures import ThreadPoolExecutor , as_completed
16- from pathlib import Path
17- from typing import List
1816import time
1917import 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
2123from tuda_workspace_scripts .workspace import get_workspace_root
2224
2325try :
2426 import git
2527except 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 ──────────────────────────────────────────────
3236def 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 ─────────────────────────────────────
8494class 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) ────────────────────────────────────
120134def process_repo (repo_path : Path ) -> RepoResult :
121- """Fetch + optional pull + stale‑ branch 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 ────────────────────────────────────────────
177195def collect_repos (ws_src : Path ) -> List [Path ]:
178- """Return absolute paths of *top‑ level* 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 ────────────────────────────────────────────────
188207def 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 + "\n Delete them now?"
281303 )
0 commit comments