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 +3 -0
- repowatch/__main__.py +5 -0
- repowatch/cli.py +284 -0
- repowatch/config.py +124 -0
- repowatch/core.py +399 -0
- repowatch/prune.py +209 -0
- repowatch/render.py +123 -0
- repowatch/sync.py +182 -0
- repowatch/tui.py +402 -0
- repowatch-0.4.0.dist-info/METADATA +218 -0
- repowatch-0.4.0.dist-info/RECORD +15 -0
- repowatch-0.4.0.dist-info/WHEEL +5 -0
- repowatch-0.4.0.dist-info/entry_points.txt +2 -0
- repowatch-0.4.0.dist-info/licenses/LICENSE +21 -0
- repowatch-0.4.0.dist-info/top_level.txt +1 -0
repowatch/__init__.py
ADDED
repowatch/__main__.py
ADDED
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
|