This commit is contained in:
2026-02-12 09:42:59 +02:00
commit 906adb539d
87 changed files with 5288 additions and 0 deletions

199
commands/sync.py Normal file
View File

@@ -0,0 +1,199 @@
"""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)