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/render.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Shared cell rendering for the CLI table and the TUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
|
|
7
|
+
from .core import RemoteStatus, RepoStatus
|
|
8
|
+
|
|
9
|
+
STATE_STYLE = {
|
|
10
|
+
"synced": "green",
|
|
11
|
+
"ahead": "magenta",
|
|
12
|
+
"behind": "yellow",
|
|
13
|
+
"diverged": "bold red",
|
|
14
|
+
"missing": "dim",
|
|
15
|
+
"unknown": "dim",
|
|
16
|
+
"unreachable": "red dim",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
FLAG_STYLE = {
|
|
20
|
+
"conflicts": "bold red",
|
|
21
|
+
"dirty": "yellow",
|
|
22
|
+
"untracked": "dim yellow",
|
|
23
|
+
"stash": "cyan",
|
|
24
|
+
"diverged": "bold red",
|
|
25
|
+
"push": "magenta",
|
|
26
|
+
"pull": "yellow",
|
|
27
|
+
"remotes differ": "bold cyan",
|
|
28
|
+
"mirror lag": "cyan",
|
|
29
|
+
"unreachable": "red dim",
|
|
30
|
+
"no remotes": "dim",
|
|
31
|
+
"error": "bold red",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def local_cell(s: RepoStatus) -> Text:
|
|
36
|
+
if s.error:
|
|
37
|
+
return Text("error", style="bold red")
|
|
38
|
+
t = Text((s.head or "(none)")[:7], style="bold")
|
|
39
|
+
marks = []
|
|
40
|
+
if s.staged:
|
|
41
|
+
marks.append((f"+{s.staged}", "green"))
|
|
42
|
+
if s.unstaged:
|
|
43
|
+
marks.append((f"~{s.unstaged}", "yellow"))
|
|
44
|
+
if s.untracked:
|
|
45
|
+
marks.append((f"?{s.untracked}", "dim yellow"))
|
|
46
|
+
if s.conflicts:
|
|
47
|
+
marks.append((f"!{s.conflicts}", "bold red"))
|
|
48
|
+
if s.stashes:
|
|
49
|
+
marks.append((f"${s.stashes}", "cyan"))
|
|
50
|
+
if marks:
|
|
51
|
+
for m, style in marks:
|
|
52
|
+
t.append(" ")
|
|
53
|
+
t.append(m, style=style)
|
|
54
|
+
else:
|
|
55
|
+
t.append(" ✓", style="green")
|
|
56
|
+
return t
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def remote_cell(r: RemoteStatus | None) -> Text:
|
|
60
|
+
if r is None:
|
|
61
|
+
return Text("")
|
|
62
|
+
style = STATE_STYLE[r.state]
|
|
63
|
+
if r.mirror and r.state in ("ahead", "behind", "diverged", "missing"):
|
|
64
|
+
style = "cyan" # a lagging mirror is expected, not something to push to
|
|
65
|
+
if r.state == "unreachable":
|
|
66
|
+
sha = f"{r.sha[:7]} " if r.sha else ""
|
|
67
|
+
return Text(f"{sha}✗", style=style)
|
|
68
|
+
if r.state == "missing":
|
|
69
|
+
return Text("—", style=style)
|
|
70
|
+
t = Text(r.sha[:7] if r.sha else "", style=style)
|
|
71
|
+
if r.state == "synced":
|
|
72
|
+
t.append(" =", style=style)
|
|
73
|
+
else:
|
|
74
|
+
if r.ahead:
|
|
75
|
+
t.append(f" ↑{r.ahead}", style=style)
|
|
76
|
+
if r.behind:
|
|
77
|
+
t.append(f" ↓{r.behind}", style=style)
|
|
78
|
+
return t
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def branch_cell(s: RepoStatus) -> Text:
|
|
82
|
+
if s.detached:
|
|
83
|
+
return Text("(detached)", style="italic yellow")
|
|
84
|
+
return Text(s.branch or "?")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def flags_cell(s: RepoStatus) -> Text:
|
|
88
|
+
t = Text()
|
|
89
|
+
for i, f in enumerate(s.flags()):
|
|
90
|
+
if i:
|
|
91
|
+
t.append(" ")
|
|
92
|
+
t.append(f, style=FLAG_STYLE.get(f, ""))
|
|
93
|
+
return t if t.plain else Text("ok", style="green")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def sync_text(res, applied: bool) -> Text:
|
|
97
|
+
"""One block per repo for `repowatch sync` and the TUI confirm dialog."""
|
|
98
|
+
t = Text()
|
|
99
|
+
t.append(res.name, style="bold")
|
|
100
|
+
if res.skipped:
|
|
101
|
+
t.append(f" skipped: {res.skipped}\n", style="dim")
|
|
102
|
+
return t
|
|
103
|
+
if not res.steps:
|
|
104
|
+
t.append(" in step\n", style="green")
|
|
105
|
+
return t
|
|
106
|
+
t.append("\n")
|
|
107
|
+
for st in res.steps:
|
|
108
|
+
if st.kind == "flag":
|
|
109
|
+
t.append(" ! ", style="yellow")
|
|
110
|
+
t.append(st.text + "\n", style="yellow")
|
|
111
|
+
else:
|
|
112
|
+
if not applied:
|
|
113
|
+
mark, style = " → ", "cyan"
|
|
114
|
+
elif st.ok:
|
|
115
|
+
mark, style = " ✓ ", "green"
|
|
116
|
+
else:
|
|
117
|
+
mark, style = " ✗ ", "red"
|
|
118
|
+
t.append(mark, style=style)
|
|
119
|
+
t.append(st.text, style=style)
|
|
120
|
+
if st.error:
|
|
121
|
+
t.append(f" ({st.error})", style="red dim")
|
|
122
|
+
t.append("\n")
|
|
123
|
+
return t
|
repowatch/sync.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Keep the secondary remote (Gitea) and the local checkout level with the primary (GitHub).
|
|
2
|
+
|
|
3
|
+
Rules — everything is fast-forward only; nothing is ever force-pushed or merged:
|
|
4
|
+
|
|
5
|
+
per branch that exists on primary or secondary:
|
|
6
|
+
same commit on both -> nothing to do
|
|
7
|
+
only on primary -> create it on secondary
|
|
8
|
+
secondary is behind primary -> fast-forward secondary
|
|
9
|
+
secondary is ahead of primary -> fast-forward primary (work done on Gitea flows up)
|
|
10
|
+
diverged -> flag, touch nothing
|
|
11
|
+
only on secondary -> flag (new on Gitea, or deleted on GitHub? can't tell)
|
|
12
|
+
|
|
13
|
+
local checkout (current branch only):
|
|
14
|
+
clean and behind the new tip -> fast-forward
|
|
15
|
+
dirty -> flag, leave alone
|
|
16
|
+
has commits the remotes don't -> flag; pushing your own work is left to you
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from fnmatch import fnmatchcase
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from . import core
|
|
26
|
+
from .config import Config
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Step:
|
|
31
|
+
kind: str # "push", "ff-local", "flag"
|
|
32
|
+
text: str
|
|
33
|
+
cmd: list[str] | None = None
|
|
34
|
+
ok: bool | None = None # None = planned / not run
|
|
35
|
+
error: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class SyncResult:
|
|
40
|
+
path: str
|
|
41
|
+
name: str
|
|
42
|
+
steps: list[Step] = field(default_factory=list)
|
|
43
|
+
skipped: str | None = None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def actions(self) -> list[Step]:
|
|
47
|
+
return [s for s in self.steps if s.kind != "flag"]
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def flags(self) -> list[Step]:
|
|
51
|
+
return [s for s in self.steps if s.kind == "flag"]
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def failed(self) -> list[Step]:
|
|
55
|
+
return [s for s in self.steps if s.ok is False]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_ancestor(path: str, a: str, b: str) -> bool:
|
|
59
|
+
return core.git(path, "merge-base", "--is-ancestor", a, b).returncode == 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _branches(refs: dict[str, str], remote: str) -> dict[str, str]:
|
|
63
|
+
prefix = remote + "/"
|
|
64
|
+
return {k[len(prefix):]: v for k, v in refs.items()
|
|
65
|
+
if k.startswith(prefix) and k != f"{remote}/HEAD"}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _err(cp) -> str:
|
|
69
|
+
lines = [l.strip() for l in cp.stderr.splitlines() if l.strip()]
|
|
70
|
+
key = [l for l in lines if l.startswith(("error:", "fatal:", "!", "remote: error"))]
|
|
71
|
+
return (key or lines or ["failed"])[0]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def branch_selected(branch: str, cfg: Config) -> bool:
|
|
75
|
+
return (any(fnmatchcase(branch, pat) for pat in cfg.sync_branches)
|
|
76
|
+
and not any(fnmatchcase(branch, pat) for pat in cfg.sync_exclude))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def plan_and_run(path: str, root: str, cfg: Config, apply: bool = False, fetch: bool = True) -> SyncResult:
|
|
80
|
+
path = str(path)
|
|
81
|
+
status = core.repo_status(path, root, cfg)
|
|
82
|
+
res = SyncResult(path=path, name=status.name)
|
|
83
|
+
if status.error:
|
|
84
|
+
res.skipped = status.error
|
|
85
|
+
return res
|
|
86
|
+
|
|
87
|
+
primary = next((r for r in status.remotes if r.label.split(":")[0] in cfg.primary_labels), None)
|
|
88
|
+
secondaries = [r for r in status.remotes if r.mirror]
|
|
89
|
+
if primary is None:
|
|
90
|
+
res.skipped = "no primary remote (" + "/".join(cfg.primary_labels) + ")"
|
|
91
|
+
return res
|
|
92
|
+
|
|
93
|
+
fetch_errors = core.fetch_repo(path, cfg.fetch_timeout) if fetch else {}
|
|
94
|
+
if primary.name in fetch_errors:
|
|
95
|
+
res.skipped = f"{primary.label} unreachable: {fetch_errors[primary.name]}"
|
|
96
|
+
return res
|
|
97
|
+
|
|
98
|
+
cp = core.git(path, "for-each-ref", "--format=%(refname:strip=2)%09%(objectname)", "refs/remotes")
|
|
99
|
+
refs = dict(l.split("\t", 1) for l in cp.stdout.splitlines() if "\t" in l)
|
|
100
|
+
p_br = _branches(refs, primary.name)
|
|
101
|
+
new_tip = dict(p_br) # what primary will hold after this run (if pushes succeed)
|
|
102
|
+
|
|
103
|
+
ssh = core._ssh_override(path)
|
|
104
|
+
|
|
105
|
+
def push(remote: str, sha: str, branch: str, text: str) -> Step:
|
|
106
|
+
st = Step("push", text, ["push", remote, f"{sha}:refs/heads/{branch}"])
|
|
107
|
+
if apply:
|
|
108
|
+
cp = core.git(path, *ssh, *st.cmd, timeout=cfg.fetch_timeout * 2)
|
|
109
|
+
st.ok = cp.returncode == 0
|
|
110
|
+
if not st.ok:
|
|
111
|
+
st.error = _err(cp)
|
|
112
|
+
res.steps.append(st)
|
|
113
|
+
return st
|
|
114
|
+
|
|
115
|
+
for sec in secondaries:
|
|
116
|
+
if sec.name in fetch_errors:
|
|
117
|
+
res.steps.append(Step("flag", f"{sec.label} unreachable — skipped ({fetch_errors[sec.name]})"))
|
|
118
|
+
continue
|
|
119
|
+
s_br = _branches(refs, sec.name)
|
|
120
|
+
for b in sorted(set(p_br) | set(s_br)):
|
|
121
|
+
if not branch_selected(b, cfg):
|
|
122
|
+
continue
|
|
123
|
+
p, s = p_br.get(b), s_br.get(b)
|
|
124
|
+
if p == s:
|
|
125
|
+
continue
|
|
126
|
+
if p and not s:
|
|
127
|
+
push(sec.name, p, b, f"{b}: create on {sec.label} from {primary.label} @ {p[:7]}")
|
|
128
|
+
elif p and s and _is_ancestor(path, s, p):
|
|
129
|
+
n = core.git(path, "rev-list", "--count", f"{s}..{p}").stdout.strip()
|
|
130
|
+
push(sec.name, p, b, f"{b}: {sec.label} +{n} from {primary.label} ({s[:7]}→{p[:7]})")
|
|
131
|
+
elif p and s and _is_ancestor(path, p, s):
|
|
132
|
+
n = core.git(path, "rev-list", "--count", f"{p}..{s}").stdout.strip()
|
|
133
|
+
st = push(primary.name, s, b, f"{b}: {primary.label} +{n} from {sec.label} ({p[:7]}→{s[:7]})")
|
|
134
|
+
if st.ok is not False:
|
|
135
|
+
new_tip[b] = s
|
|
136
|
+
elif p and s:
|
|
137
|
+
res.steps.append(Step("flag", f"{b}: {primary.label} and {sec.label} have diverged — resolve by hand"))
|
|
138
|
+
else:
|
|
139
|
+
res.steps.append(Step("flag", f"{b}: only on {sec.label} — new there, or deleted on {primary.label}?"))
|
|
140
|
+
|
|
141
|
+
# Local checkout: fast-forward the current branch to the (new) primary tip.
|
|
142
|
+
if status.detached or not status.branch or not status.head:
|
|
143
|
+
if status.detached:
|
|
144
|
+
res.steps.append(Step("flag", "local: detached HEAD — left alone"))
|
|
145
|
+
return res
|
|
146
|
+
b = status.branch
|
|
147
|
+
target = new_tip.get(b)
|
|
148
|
+
if not target or target == status.head:
|
|
149
|
+
pass
|
|
150
|
+
elif _is_ancestor(path, status.head, target):
|
|
151
|
+
if status.dirty or status.conflicts:
|
|
152
|
+
res.steps.append(Step("flag", f"local {b}: behind {primary.label} but has uncommitted changes — not updated"))
|
|
153
|
+
else:
|
|
154
|
+
n = core.git(path, "rev-list", "--count", f"HEAD..{target}").stdout.strip()
|
|
155
|
+
st = Step("ff-local", f"local {b}: fast-forward +{n} ({status.head[:7]}→{target[:7]})",
|
|
156
|
+
["merge", "--ff-only", "--quiet", target])
|
|
157
|
+
if apply:
|
|
158
|
+
cp = core.git(path, *st.cmd, timeout=60)
|
|
159
|
+
st.ok = cp.returncode == 0
|
|
160
|
+
if not st.ok:
|
|
161
|
+
st.error = _err(cp)
|
|
162
|
+
res.steps.append(st)
|
|
163
|
+
elif _is_ancestor(path, target, status.head):
|
|
164
|
+
n = core.git(path, "rev-list", "--count", f"{target}..HEAD").stdout.strip()
|
|
165
|
+
res.steps.append(Step("flag", f"local {b}: {n} commit(s) not on {primary.label} — push when ready"))
|
|
166
|
+
else:
|
|
167
|
+
res.steps.append(Step("flag", f"local {b}: diverged from {primary.label} — pull/rebase by hand"))
|
|
168
|
+
return res
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def sync_all(root: str, cfg: Config, apply: bool = False, names: list[str] | None = None,
|
|
172
|
+
workers: int = 6) -> list[SyncResult]:
|
|
173
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
174
|
+
|
|
175
|
+
root = str(Path(root).expanduser().resolve())
|
|
176
|
+
repos = [str(p) for p in core.find_repos(root, cfg.depth)]
|
|
177
|
+
if names:
|
|
178
|
+
wanted = {n.lower() for n in names}
|
|
179
|
+
repos = [p for p in repos if Path(p).name.lower() in wanted
|
|
180
|
+
or Path(p).relative_to(root).as_posix().lower() in wanted]
|
|
181
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
182
|
+
return list(pool.map(lambda p: plan_and_run(p, root, cfg, apply=apply), repos))
|