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/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))