"""flow sync — check git sync status of all projects.""" import os import subprocess import sys from flow.core.config import FlowContext def register(subparsers): p = subparsers.add_parser("sync", help="Git sync tools for projects") sub = p.add_subparsers(dest="sync_command") check = sub.add_parser("check", help="Check all projects status") check.add_argument( "--fetch", dest="fetch", action="store_true", help="Run git fetch before checking (default)", ) check.add_argument( "--no-fetch", dest="fetch", action="store_false", help="Skip git fetch", ) check.set_defaults(fetch=True) check.set_defaults(handler=run_check) fetch = sub.add_parser("fetch", help="Fetch all project remotes") fetch.set_defaults(handler=run_fetch) summary = sub.add_parser("summary", help="Quick overview of project status") summary.set_defaults(handler=run_summary) p.set_defaults(handler=lambda ctx, args: p.print_help()) def _git(repo: str, *cmd, capture: bool = True) -> subprocess.CompletedProcess: return subprocess.run( ["git", "-C", repo] + list(cmd), capture_output=capture, text=True, ) def _check_repo(repo_path: str, do_fetch: bool = True): """Check a single repo, return (name, issues list).""" name = os.path.basename(repo_path) git_dir = os.path.join(repo_path, ".git") if not os.path.isdir(git_dir): return name, None # Not a git repo issues = [] if do_fetch: fetch_result = _git(repo_path, "fetch", "--all", "--quiet") if fetch_result.returncode != 0: issues.append("git fetch failed") # Current branch result = _git(repo_path, "rev-parse", "--abbrev-ref", "HEAD") branch = result.stdout.strip() if result.returncode == 0 else "HEAD" # Uncommitted changes diff_result = _git(repo_path, "diff", "--quiet") cached_result = _git(repo_path, "diff", "--cached", "--quiet") if diff_result.returncode != 0 or cached_result.returncode != 0: issues.append("uncommitted changes") else: untracked = _git(repo_path, "ls-files", "--others", "--exclude-standard") if untracked.stdout.strip(): issues.append("untracked files") # Unpushed commits upstream_check = _git(repo_path, "rev-parse", "--abbrev-ref", f"{branch}@{{u}}") if upstream_check.returncode == 0: unpushed = _git(repo_path, "rev-list", "--oneline", f"{branch}@{{u}}..{branch}") if unpushed.stdout.strip(): count = len(unpushed.stdout.strip().split("\n")) issues.append(f"{count} unpushed commit(s) on {branch}") else: issues.append(f"no upstream for {branch}") # Unpushed branches branches_result = _git(repo_path, "for-each-ref", "--format=%(refname:short)", "refs/heads") for b in branches_result.stdout.strip().split("\n"): if not b or b == branch: continue up = _git(repo_path, "rev-parse", "--abbrev-ref", f"{b}@{{u}}") if up.returncode == 0: ahead = _git(repo_path, "rev-list", "--count", f"{b}@{{u}}..{b}") if ahead.stdout.strip() != "0": issues.append(f"branch {b}: {ahead.stdout.strip()} ahead") else: issues.append(f"branch {b}: no upstream") return name, issues def run_check(ctx: FlowContext, args): projects_dir = os.path.expanduser(ctx.config.projects_dir) if not os.path.isdir(projects_dir): ctx.console.error(f"Projects directory not found: {projects_dir}") sys.exit(1) rows = [] needs_action = [] not_git = [] checked = 0 for entry in sorted(os.listdir(projects_dir)): repo_path = os.path.join(projects_dir, entry) if not os.path.isdir(repo_path): continue name, issues = _check_repo(repo_path, do_fetch=args.fetch) if issues is None: not_git.append(name) continue checked += 1 if issues: needs_action.append(name) rows.append([name, "; ".join(issues)]) else: rows.append([name, "clean and synced"]) if checked == 0: ctx.console.info("No git repositories found in projects directory.") if not_git: ctx.console.info(f"Skipped non-git directories: {', '.join(sorted(not_git))}") return ctx.console.table(["PROJECT", "STATUS"], rows) if needs_action: ctx.console.warn(f"Projects needing action: {', '.join(sorted(needs_action))}") else: ctx.console.success("All repositories clean and synced.") if not_git: ctx.console.info(f"Skipped non-git directories: {', '.join(sorted(not_git))}") def run_fetch(ctx: FlowContext, args): projects_dir = os.path.expanduser(ctx.config.projects_dir) if not os.path.isdir(projects_dir): ctx.console.error(f"Projects directory not found: {projects_dir}") sys.exit(1) had_error = False fetched = 0 for entry in sorted(os.listdir(projects_dir)): repo_path = os.path.join(projects_dir, entry) if not os.path.isdir(os.path.join(repo_path, ".git")): continue ctx.console.info(f"Fetching {entry}...") result = _git(repo_path, "fetch", "--all", "--quiet") fetched += 1 if result.returncode != 0: ctx.console.error(f"Failed to fetch {entry}") had_error = True if fetched == 0: ctx.console.info("No git repositories found in projects directory.") return if had_error: sys.exit(1) ctx.console.success("All remotes fetched.") def run_summary(ctx: FlowContext, args): projects_dir = os.path.expanduser(ctx.config.projects_dir) if not os.path.isdir(projects_dir): ctx.console.error(f"Projects directory not found: {projects_dir}") sys.exit(1) headers = ["PROJECT", "STATUS"] rows = [] for entry in sorted(os.listdir(projects_dir)): repo_path = os.path.join(projects_dir, entry) if not os.path.isdir(repo_path): continue name, issues = _check_repo(repo_path, do_fetch=False) if issues is None: rows.append([name, "not a git repo"]) elif issues: rows.append([name, "; ".join(issues)]) else: rows.append([name, "clean"]) if not rows: ctx.console.info("No projects found.") return ctx.console.table(headers, rows)