repowatch 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
repowatch/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """repowatch — one view of every git repo under a folder, compared across all its remotes."""
2
+
3
+ __version__ = "0.4.0"
repowatch/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
repowatch/cli.py ADDED
@@ -0,0 +1,284 @@
1
+ """Command line entry point.
2
+
3
+ repowatch [ROOT] interactive matrix (default)
4
+ repowatch status [ROOT] [--fetch] [--json] [--attention]
5
+ repowatch sync [ROOT] [--apply] [--repo NAME ...]
6
+ level Gitea and local with GitHub (fast-forward only)
7
+ repowatch prune [ROOT] [--apply] [--include-unmerged DAYS]
8
+ delete merged / squash-merged branches on GitHub
9
+ repowatch schedule [--every MIN] [--remove]
10
+ run `sync --apply` automatically (Windows Task Scheduler)
11
+ repowatch init write an example config file
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import json
18
+ import shutil
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from . import __version__, core
23
+ from .config import config_dir, config_path, load_config, write_example_config
24
+
25
+ COMMANDS = {"tui", "status", "sync", "prune", "schedule", "init"}
26
+
27
+
28
+ def _root(arg: str | None, cfg) -> Path:
29
+ raw = arg or cfg.root or "."
30
+ root = Path(raw).expanduser().resolve()
31
+ if not root.is_dir():
32
+ src = "command line" if arg else ("`root` in " + str(config_path()) if cfg.root else "current folder")
33
+ print(f"repowatch: folder not found: {root} (from {src})", file=sys.stderr)
34
+ sys.exit(2)
35
+ return root
36
+
37
+
38
+ def cmd_status(args, cfg) -> int:
39
+ root = _root(args.root, cfg)
40
+ statuses = core.scan(root, cfg, do_fetch=args.fetch)
41
+ if args.attention:
42
+ statuses = [s for s in statuses if s.flags()]
43
+
44
+ if args.json:
45
+ print(json.dumps({"root": str(root), "repos": [s.to_dict() for s in statuses]}, indent=2))
46
+ return 0
47
+
48
+ from rich.console import Console
49
+ from rich.table import Table
50
+
51
+ from .render import branch_cell, flags_cell, local_cell, remote_cell
52
+
53
+ labels = core.all_labels(statuses, cfg.column_order)
54
+ t = Table(title=f"{root} · {len(statuses)} repos", title_justify="left", header_style="bold")
55
+ for col in ["Repo", "Branch", "Local", *labels, "Status"]:
56
+ t.add_column(col, no_wrap=col != "Status")
57
+ for s in statuses:
58
+ t.add_row(s.name, branch_cell(s), local_cell(s),
59
+ *[remote_cell(s.remote(l)) for l in labels], flags_cell(s))
60
+ Console().print(t)
61
+ if not args.fetch:
62
+ Console(stderr=True).print("[dim]Remote columns reflect the last fetch. Add --fetch to refresh them.[/]")
63
+ return 1 if any(s.flags() for s in statuses) and args.attention else 0
64
+
65
+
66
+ def cmd_sync(args, cfg) -> int:
67
+ import datetime
68
+
69
+ from rich.console import Console
70
+
71
+ from .render import sync_text
72
+ from .sync import sync_all
73
+
74
+ root = _root(args.root, cfg)
75
+ results = sync_all(str(root), cfg, apply=args.apply, names=args.repo)
76
+
77
+ log_path = Path(args.log) if args.log else (config_dir() / "sync.log" if sys.stdout is None else None)
78
+ if log_path:
79
+ log_path.parent.mkdir(parents=True, exist_ok=True)
80
+ if log_path.exists() and log_path.stat().st_size > 1_000_000:
81
+ log_path.replace(log_path.with_suffix(".log.1"))
82
+ fh = log_path.open("a", encoding="utf-8")
83
+ con = Console(file=fh, width=120, color_system=None, highlight=False)
84
+ else:
85
+ con = Console(highlight=False)
86
+ stamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
87
+ mode = "applied" if args.apply else "dry run — nothing changed, add --apply to do it"
88
+ con.print(f"[bold]repowatch sync[/] {root} · {stamp} · {mode}")
89
+ shown = 0
90
+ for r in results:
91
+ if args.quiet and not r.steps and not r.skipped:
92
+ continue
93
+ con.print(sync_text(r, args.apply), end="")
94
+ shown += 1
95
+ n_act = sum(len(r.actions) for r in results)
96
+ n_flag = sum(len(r.flags) for r in results)
97
+ n_fail = sum(len(r.failed) for r in results)
98
+ n_skip = sum(1 for r in results if r.skipped)
99
+ con.print(f"[dim]{len(results)} repos · {n_act} action(s) · {n_fail} failed · "
100
+ f"{n_flag} need you · {n_skip} skipped[/]")
101
+ con.print()
102
+ return 1 if (n_fail or n_flag) else 0
103
+
104
+
105
+ def cmd_prune(args, cfg) -> int:
106
+ from rich.console import Console
107
+ from rich.text import Text
108
+
109
+ from .prune import prune_all
110
+
111
+ root = _root(args.root, cfg)
112
+ con = Console(highlight=False)
113
+ mode = "applied" if args.apply else "dry run — nothing deleted, add --apply to do it"
114
+ con.print(f"[bold]repowatch prune[/] {root} · {mode}")
115
+ with con.status("Fetching and checking branches…"):
116
+ results = prune_all(str(root), cfg, apply=args.apply, names=args.repo,
117
+ include_unmerged_days=args.include_unmerged, min_age_days=args.min_age)
118
+ total_del = total_fail = 0
119
+ for r in results:
120
+ t = Text()
121
+ t.append(r.name, style="bold")
122
+ if r.skipped:
123
+ t.append(f" skipped: {r.skipped}", style="dim")
124
+ con.print(t)
125
+ continue
126
+ dels = r.to_delete
127
+ kept = [b for b in r.branches if not b.delete]
128
+ t.append(f" {r.remote} · default {r.default} · {len(dels)} to delete, {len(kept)} kept", style="dim")
129
+ con.print(t)
130
+ if r.note:
131
+ con.print(f" [yellow]! {r.note}[/]")
132
+ if r.error:
133
+ con.print(f" [red]! {r.error}[/]")
134
+ for b in r.branches:
135
+ if not b.delete and not args.verbose and b.kind != "unmerged":
136
+ continue
137
+ line = Text(" ")
138
+ if b.delete:
139
+ if not args.apply:
140
+ line.append("→ delete ", style="cyan")
141
+ elif b.deleted:
142
+ line.append("✓ deleted", style="green"); total_del += 1
143
+ elif b.deleted is False:
144
+ line.append("✗ refused", style="red"); total_fail += 1
145
+ else:
146
+ line.append("? unknown", style="yellow")
147
+ else:
148
+ line.append("· keep ", style="dim")
149
+ line.append(f" {b.name}")
150
+ info = b.kind + (f" +{b.ahead}" if b.kind == "unmerged" else "") + f" · {b.age_days}d"
151
+ if b.reason:
152
+ info += f" · {b.reason}"
153
+ line.append(f" {info}", style="dim")
154
+ con.print(line)
155
+ n = sum(len(r.to_delete) for r in results)
156
+ n_unm = sum(1 for r in results for b in r.branches if b.kind == "unmerged" and not b.delete)
157
+ summary = f"{n} branch(es) {'deleted' if args.apply else 'to delete'} · {n_unm} unmerged kept"
158
+ if args.apply:
159
+ summary += f" · {total_fail} refused"
160
+ con.print(f"[dim]{summary}[/]")
161
+ return 1 if total_fail else 0
162
+
163
+
164
+ TASK_NAME = "repowatch sync"
165
+
166
+
167
+ def cmd_schedule(args, cfg) -> int:
168
+ import subprocess
169
+
170
+ if args.remove:
171
+ if sys.platform != "win32":
172
+ print("Remove the cron/launchd entry you added.")
173
+ return 0
174
+ cp = subprocess.run(["schtasks", "/Delete", "/F", "/TN", TASK_NAME], capture_output=True, text=True)
175
+ print(cp.stdout or cp.stderr)
176
+ return cp.returncode
177
+
178
+ if not cfg.root:
179
+ print(f"Set `root` in {config_path()} first (run `repowatch init`), so the task knows where to look.")
180
+ return 2
181
+ log = config_dir() / "sync.log"
182
+
183
+ if sys.platform != "win32":
184
+ exe = Path(sys.executable)
185
+ line = f'*/{args.every} * * * * "{exe}" -m repowatch sync --apply --quiet --log "{log}"'
186
+ print("Add this line with `crontab -e`:\n\n " + line + "\n")
187
+ return 0
188
+
189
+ pyw = Path(sys.executable).with_name("pythonw.exe")
190
+ runner = pyw if pyw.exists() else Path(sys.executable)
191
+ tr = f'"{runner}" -m repowatch sync --apply --quiet --log "{log}"'
192
+ if len(tr) > 261:
193
+ print("Task command too long for Task Scheduler (261 chars):\n" + tr)
194
+ return 2
195
+ cmd = ["schtasks", "/Create", "/F", "/TN", TASK_NAME, "/SC", "MINUTE", "/MO", str(args.every), "/TR", tr]
196
+ cp = subprocess.run(cmd, capture_output=True, text=True)
197
+ print(cp.stdout or cp.stderr)
198
+ if cp.returncode == 0:
199
+ print(f"Runs every {args.every} min while you're logged in. Log: {log}")
200
+ print("Remove with: repowatch schedule --remove")
201
+ return cp.returncode
202
+
203
+
204
+ def main(argv: list[str] | None = None) -> int:
205
+ argv = list(sys.argv[1:] if argv is None else argv)
206
+ # Default to the TUI when no subcommand is given; skip past global options first.
207
+ i = 0
208
+ while i < len(argv) and argv[i] in {"--config"}:
209
+ i += 2
210
+ if i >= len(argv) or (argv[i] not in COMMANDS and argv[i] not in {"-h", "--help", "--version"}):
211
+ argv.insert(i, "tui")
212
+
213
+ p = argparse.ArgumentParser(
214
+ prog="repowatch", description="Every git repo under a folder, compared across all its remotes."
215
+ )
216
+ p.add_argument("--version", action="version", version=f"repowatch {__version__}")
217
+ p.add_argument("--config", type=Path, help=f"config file (default {config_path()})")
218
+ sub = p.add_subparsers(dest="cmd")
219
+
220
+ pt = sub.add_parser("tui", help="interactive matrix (default)")
221
+ pt.add_argument("root", nargs="?")
222
+ pt.add_argument("--fetch", action="store_true", help="fetch all remotes on start")
223
+ pt.add_argument("--depth", type=int)
224
+
225
+ ps = sub.add_parser("status", help="print the matrix once")
226
+ ps.add_argument("root", nargs="?")
227
+ ps.add_argument("--fetch", action="store_true", help="fetch all remotes first")
228
+ ps.add_argument("--json", action="store_true", help="machine-readable output")
229
+ ps.add_argument("--attention", action="store_true", help="only repos that need something (exit 1 if any)")
230
+ ps.add_argument("--depth", type=int)
231
+
232
+ py = sub.add_parser("sync", help="level Gitea and local with GitHub (fast-forward only)")
233
+ py.add_argument("root", nargs="?")
234
+ py.add_argument("--apply", action="store_true", help="actually push / fast-forward (default is a dry run)")
235
+ py.add_argument("--repo", action="append", help="only this repo (repeatable)")
236
+ py.add_argument("--quiet", action="store_true", help="hide repos with nothing to do")
237
+ py.add_argument("--log", help="append output to this file instead of the screen")
238
+ py.add_argument("--depth", type=int)
239
+
240
+ pp = sub.add_parser("prune", help="delete merged / squash-merged branches on GitHub")
241
+ pp.add_argument("root", nargs="?")
242
+ pp.add_argument("--apply", action="store_true", help="actually delete (default is a dry run)")
243
+ pp.add_argument("--repo", action="append", help="only this repo (repeatable)")
244
+ pp.add_argument("--include-unmerged", type=int, metavar="DAYS",
245
+ help="also delete UNMERGED branches older than DAYS with no open PR (needs gh)")
246
+ pp.add_argument("--min-age", type=int, metavar="DAYS", help="override prune_min_age_days")
247
+ pp.add_argument("-v", "--verbose", action="store_true", help="also list kept merged branches")
248
+ pp.add_argument("--depth", type=int)
249
+
250
+ pc = sub.add_parser("schedule", help="run `sync --apply` automatically")
251
+ pc.add_argument("--every", type=int, default=60, help="minutes between runs (default 60)")
252
+ pc.add_argument("--remove", action="store_true", help="delete the scheduled task")
253
+
254
+ sub.add_parser("init", help="write an example config file")
255
+
256
+ args = p.parse_args(argv)
257
+ if shutil.which("git") is None:
258
+ print("repowatch: git not found on PATH", file=sys.stderr)
259
+ return 2
260
+
261
+ cfg = load_config(args.config)
262
+ if getattr(args, "depth", None):
263
+ cfg.depth = args.depth
264
+
265
+ if args.cmd == "init":
266
+ path = write_example_config(args.config)
267
+ print(f"Config: {path}")
268
+ return 0
269
+ if args.cmd == "status":
270
+ return cmd_status(args, cfg)
271
+ if args.cmd == "sync":
272
+ return cmd_sync(args, cfg)
273
+ if args.cmd == "prune":
274
+ return cmd_prune(args, cfg)
275
+ if args.cmd == "schedule":
276
+ return cmd_schedule(args, cfg)
277
+
278
+ from .tui import run
279
+ run(_root(args.root, cfg), cfg, fetch=args.fetch)
280
+ return 0
281
+
282
+
283
+ if __name__ == "__main__":
284
+ sys.exit(main())
repowatch/config.py ADDED
@@ -0,0 +1,124 @@
1
+ """Config loading. Optional TOML file; everything has a sensible default."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import tomllib
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+ DEFAULT_HOSTS = {
12
+ "github.com": "github",
13
+ "gitlab.com": "gitlab",
14
+ "bitbucket.org": "bitbucket",
15
+ }
16
+
17
+ EXAMPLE_CONFIG = """\
18
+ # repowatch config
19
+
20
+ # Folder to scan when no path is given on the command line.
21
+ # root = "C:/Users/you/code"
22
+ # root = "~/code"
23
+
24
+ # How many folder levels below root to look for repos.
25
+ depth = 3
26
+
27
+ # Seconds between automatic local refreshes in the TUI (no network). 0 = off.
28
+ refresh_seconds = 30
29
+
30
+ # Seconds allowed per remote when fetching.
31
+ fetch_timeout = 25
32
+
33
+ # Column order in the matrix. Labels not listed here follow alphabetically.
34
+ column_order = ["github", "gitea"]
35
+
36
+ # Where work normally lands (pushes/PRs). `repowatch sync` treats this as the source.
37
+ primary_labels = ["github"]
38
+
39
+ # Secondary copies kept level with the primary by `repowatch sync` (fast-forward only,
40
+ # both directions). Their differences are reported as "mirror lag" rather than "push".
41
+ mirror_labels = ["gitea"]
42
+
43
+ # Which branches `repowatch sync` keeps level between primary and secondary.
44
+ # Glob patterns; a branch is synced if it matches sync_branches and not sync_exclude.
45
+ # The local checkout's current branch is fast-forwarded regardless.
46
+ sync_branches = ["main", "master"]
47
+ sync_exclude = ["dependabot/*"]
48
+
49
+ # `repowatch prune`: branches never deleted (globs; the default branch is always kept),
50
+ # and the minimum age before a merged branch is deleted.
51
+ prune_keep = ["main", "master", "develop", "release/*"]
52
+ prune_min_age_days = 7
53
+
54
+ # Map remote hosts to column labels. Exact host match first, then substring.
55
+ # Anything containing "gitea" is labelled "gitea" automatically.
56
+ [hosts]
57
+ "github.com" = "github"
58
+ # "git.example.internal" = "gitea"
59
+
60
+ # URL templates used to pre-fill the "add remote" dialog.
61
+ # {owner} and {repo} are taken from the repo's first remote URL.
62
+ [remote_templates]
63
+ # gitea = "https://git.example.internal/{owner}/{repo}.git"
64
+ """
65
+
66
+
67
+ def config_dir() -> Path:
68
+ if sys.platform == "win32":
69
+ base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
70
+ return Path(base) / "repowatch"
71
+ xdg = os.environ.get("XDG_CONFIG_HOME")
72
+ return (Path(xdg) if xdg else Path.home() / ".config") / "repowatch"
73
+
74
+
75
+ def config_path() -> Path:
76
+ return config_dir() / "config.toml"
77
+
78
+
79
+ @dataclass
80
+ class Config:
81
+ root: str | None = None
82
+ depth: int = 3
83
+ refresh_seconds: int = 30
84
+ fetch_timeout: int = 25
85
+ column_order: list[str] = field(default_factory=lambda: ["github", "gitea"])
86
+ hosts: dict[str, str] = field(default_factory=lambda: dict(DEFAULT_HOSTS))
87
+ remote_templates: dict[str, str] = field(default_factory=dict)
88
+ mirror_labels: list[str] = field(default_factory=lambda: ["gitea"])
89
+ primary_labels: list[str] = field(default_factory=lambda: ["github"])
90
+ sync_branches: list[str] = field(default_factory=lambda: ["main", "master"])
91
+ sync_exclude: list[str] = field(default_factory=lambda: ["dependabot/*"])
92
+ prune_keep: list[str] = field(default_factory=lambda: ["main", "master", "develop", "release/*"])
93
+ prune_min_age_days: int = 7
94
+
95
+
96
+ def load_config(path: Path | None = None) -> Config:
97
+ path = path or config_path()
98
+ cfg = Config()
99
+ if not path.exists():
100
+ return cfg
101
+ with path.open("rb") as fh:
102
+ data = tomllib.load(fh)
103
+ cfg.root = data.get("root", cfg.root)
104
+ cfg.depth = int(data.get("depth", cfg.depth))
105
+ cfg.refresh_seconds = int(data.get("refresh_seconds", cfg.refresh_seconds))
106
+ cfg.fetch_timeout = int(data.get("fetch_timeout", cfg.fetch_timeout))
107
+ cfg.column_order = list(data.get("column_order", cfg.column_order))
108
+ cfg.hosts.update({k.lower(): v for k, v in data.get("hosts", {}).items()})
109
+ cfg.remote_templates.update(data.get("remote_templates", {}))
110
+ cfg.mirror_labels = list(data.get("mirror_labels", cfg.mirror_labels))
111
+ cfg.primary_labels = list(data.get("primary_labels", cfg.primary_labels))
112
+ cfg.sync_branches = list(data.get("sync_branches", cfg.sync_branches))
113
+ cfg.sync_exclude = list(data.get("sync_exclude", cfg.sync_exclude))
114
+ cfg.prune_keep = list(data.get("prune_keep", cfg.prune_keep))
115
+ cfg.prune_min_age_days = int(data.get("prune_min_age_days", cfg.prune_min_age_days))
116
+ return cfg
117
+
118
+
119
+ def write_example_config(path: Path | None = None) -> Path:
120
+ path = path or config_path()
121
+ path.parent.mkdir(parents=True, exist_ok=True)
122
+ if not path.exists():
123
+ path.write_text(EXAMPLE_CONFIG, encoding="utf-8")
124
+ return path