flow
This commit is contained in:
199
commands/sync.py
Normal file
199
commands/sync.py
Normal 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)
|
||||
Reference in New Issue
Block a user