tmuxpull 0.1.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.
tmuxpull/__init__.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tmuxpull -- run `git pull --rebase --autostash` across every Git repo under
|
|
3
|
+
the given roots, concurrently. Print a per-repo summary of what changed, and
|
|
4
|
+
create a dedicated tmux session per repo with a rebase window showing `git status`.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
tmuxpull [-d DEPTH] [-j JOBS] [--tmux {on,off}] [-v] [--dry-run] DIR [DIR ...]
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import asyncio
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import sys
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Iterable
|
|
20
|
+
|
|
21
|
+
import libtmux
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Directories that are never a repo we want to descend into.
|
|
25
|
+
_SKIP_DIRS: frozenset[str] = frozenset(
|
|
26
|
+
{
|
|
27
|
+
".git",
|
|
28
|
+
"node_modules",
|
|
29
|
+
".venv",
|
|
30
|
+
"venv",
|
|
31
|
+
"env",
|
|
32
|
+
"target",
|
|
33
|
+
"build",
|
|
34
|
+
"dist",
|
|
35
|
+
"__pycache__",
|
|
36
|
+
".mypy_cache",
|
|
37
|
+
".pytest_cache",
|
|
38
|
+
".ruff_cache",
|
|
39
|
+
".tox",
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# tmux session names cannot contain ':' or '.' but allow '/'.
|
|
44
|
+
# Use Unix-style paths since tmux runs in Unix-like environments.
|
|
45
|
+
_UNSAFE_TMUX = re.compile(r"[:.\s]")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --------------------------------------------------------------------------- #
|
|
49
|
+
# data model #
|
|
50
|
+
# --------------------------------------------------------------------------- #
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(slots=True)
|
|
54
|
+
class Repo:
|
|
55
|
+
path: Path # absolute filesystem path
|
|
56
|
+
name: str # display + tmux window name (posix-style relative path)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(slots=True)
|
|
60
|
+
class Result:
|
|
61
|
+
repo: Repo
|
|
62
|
+
returncode: int
|
|
63
|
+
stdout: str
|
|
64
|
+
stderr: str
|
|
65
|
+
old_sha: str = ""
|
|
66
|
+
new_sha: str = ""
|
|
67
|
+
log_lines: list[str] = field(default_factory=list)
|
|
68
|
+
shortstat: str = ""
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def ok(self) -> bool:
|
|
72
|
+
return self.returncode == 0
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def changed(self) -> bool:
|
|
76
|
+
return self.ok and bool(self.old_sha) and self.old_sha != self.new_sha
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def needs_attention(self) -> bool:
|
|
80
|
+
# A failed rebase leaves the repo in a state you have to intervene in
|
|
81
|
+
# (conflict markers, in-progress rebase, or an unpopped autostash).
|
|
82
|
+
return not self.ok
|
|
83
|
+
|
|
84
|
+
def summary_line(self) -> str:
|
|
85
|
+
if not self.ok:
|
|
86
|
+
tail = (self.stderr.strip().splitlines() or [f"exit {self.returncode}"])[-1]
|
|
87
|
+
return f"✗ FAIL: {tail}"
|
|
88
|
+
if not self.changed:
|
|
89
|
+
return "· up to date"
|
|
90
|
+
n = len(self.log_lines)
|
|
91
|
+
return f"✓ {n} commit{'s' if n != 1 else ''} {self.shortstat}".rstrip()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# --------------------------------------------------------------------------- #
|
|
95
|
+
# repo discovery #
|
|
96
|
+
# --------------------------------------------------------------------------- #
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def find_repos(roots: Iterable[str], max_depth: int) -> list[Repo]:
|
|
100
|
+
"""Walk each root looking for directories containing a .git entry.
|
|
101
|
+
|
|
102
|
+
Prunes noise directories (see _SKIP_DIRS), enforces a max depth relative
|
|
103
|
+
to each root, and never descends into a repo (so nested submodules are
|
|
104
|
+
ignored -- typically what you want for a "pull everything" script).
|
|
105
|
+
"""
|
|
106
|
+
out: list[Repo] = []
|
|
107
|
+
for root in roots:
|
|
108
|
+
top = Path(root).expanduser().resolve()
|
|
109
|
+
if not top.is_dir():
|
|
110
|
+
print(f"skip: {top} is not a directory", file=sys.stderr)
|
|
111
|
+
continue
|
|
112
|
+
base = len(top.parts)
|
|
113
|
+
for dirpath, dirs, _ in os.walk(top):
|
|
114
|
+
here = Path(dirpath)
|
|
115
|
+
depth = len(here.parts) - base
|
|
116
|
+
dirs[:] = [d for d in dirs if d not in _SKIP_DIRS]
|
|
117
|
+
if depth > max_depth:
|
|
118
|
+
dirs[:] = []
|
|
119
|
+
continue
|
|
120
|
+
if (here / ".git").exists():
|
|
121
|
+
dirs[:] = []
|
|
122
|
+
name = "." if here == top else here.relative_to(top).as_posix()
|
|
123
|
+
out.append(Repo(path=here, name=name))
|
|
124
|
+
|
|
125
|
+
# dedupe (overlapping roots)
|
|
126
|
+
seen: set[Path] = set()
|
|
127
|
+
uniq: list[Repo] = []
|
|
128
|
+
for r in out:
|
|
129
|
+
if r.path in seen:
|
|
130
|
+
continue
|
|
131
|
+
seen.add(r.path)
|
|
132
|
+
uniq.append(r)
|
|
133
|
+
return uniq
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --------------------------------------------------------------------------- #
|
|
137
|
+
# git #
|
|
138
|
+
# --------------------------------------------------------------------------- #
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def _git(repo: Path, *args: str) -> tuple[int, str, str]:
|
|
142
|
+
proc = await asyncio.create_subprocess_exec(
|
|
143
|
+
"git",
|
|
144
|
+
"-C",
|
|
145
|
+
str(repo),
|
|
146
|
+
*args,
|
|
147
|
+
stdout=asyncio.subprocess.PIPE,
|
|
148
|
+
stderr=asyncio.subprocess.PIPE,
|
|
149
|
+
)
|
|
150
|
+
stdout, stderr = await proc.communicate()
|
|
151
|
+
return proc.returncode, stdout.decode(errors="replace"), stderr.decode(errors="replace")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def rebase(repo: Repo, sem: asyncio.Semaphore) -> Result:
|
|
155
|
+
async with sem:
|
|
156
|
+
rc, out, err = 0, "", ""
|
|
157
|
+
_, old_sha, _ = await _git(repo.path, "rev-parse", "HEAD")
|
|
158
|
+
old_sha = old_sha.strip()
|
|
159
|
+
rc, out, err = await _git(repo.path, "pull", "--rebase", "--autostash")
|
|
160
|
+
_, new_sha, _ = await _git(repo.path, "rev-parse", "HEAD")
|
|
161
|
+
new_sha = new_sha.strip()
|
|
162
|
+
|
|
163
|
+
log_lines: list[str] = []
|
|
164
|
+
shortstat = ""
|
|
165
|
+
if rc == 0 and old_sha and new_sha and old_sha != new_sha:
|
|
166
|
+
_, log_out, _ = await _git(
|
|
167
|
+
repo.path,
|
|
168
|
+
"log",
|
|
169
|
+
"--oneline",
|
|
170
|
+
"--no-decorate",
|
|
171
|
+
f"{old_sha}..{new_sha}",
|
|
172
|
+
)
|
|
173
|
+
log_lines = [ln for ln in log_out.splitlines() if ln]
|
|
174
|
+
_, ss, _ = await _git(repo.path, "diff", "--shortstat", f"{old_sha}..{new_sha}")
|
|
175
|
+
shortstat = ss.strip()
|
|
176
|
+
|
|
177
|
+
return Result(
|
|
178
|
+
repo=repo,
|
|
179
|
+
returncode=rc,
|
|
180
|
+
stdout=out,
|
|
181
|
+
stderr=err,
|
|
182
|
+
old_sha=old_sha,
|
|
183
|
+
new_sha=new_sha,
|
|
184
|
+
log_lines=log_lines,
|
|
185
|
+
shortstat=shortstat,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# --------------------------------------------------------------------------- #
|
|
190
|
+
# tmux #
|
|
191
|
+
# --------------------------------------------------------------------------- #
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _sanitize(name: str) -> str:
|
|
195
|
+
return _UNSAFE_TMUX.sub("_", name) or "rebase"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _make_session_name(repo: Repo) -> str:
|
|
199
|
+
"""Create a tmux session name that looks like a Unix path to the repo."""
|
|
200
|
+
parent = repo.path.parent.name
|
|
201
|
+
repo_name = repo.path.name
|
|
202
|
+
|
|
203
|
+
# Handle edge cases
|
|
204
|
+
if not parent or parent == "/":
|
|
205
|
+
session_name = repo_name
|
|
206
|
+
else:
|
|
207
|
+
# Use Unix-style forward slash (tmux runs in Unix-like environments)
|
|
208
|
+
session_name = f"{parent}/{repo_name}"
|
|
209
|
+
|
|
210
|
+
return _sanitize(session_name)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def open_repo_session(server: libtmux.Server, r: Result) -> str:
|
|
214
|
+
"""Create or update a tmux session for a specific repo.
|
|
215
|
+
|
|
216
|
+
Returns the session name for user reference.
|
|
217
|
+
"""
|
|
218
|
+
session_name = _make_session_name(r.repo)
|
|
219
|
+
window_name = "rebase"
|
|
220
|
+
|
|
221
|
+
# Try to get existing session
|
|
222
|
+
sess = server.sessions.get(session_name=session_name, default=None)
|
|
223
|
+
|
|
224
|
+
if sess is None:
|
|
225
|
+
# Create new session with rebase as the first window
|
|
226
|
+
sess = server.new_session(
|
|
227
|
+
session_name=session_name,
|
|
228
|
+
window_name=window_name,
|
|
229
|
+
start_directory=str(r.repo.path),
|
|
230
|
+
attach=False,
|
|
231
|
+
)
|
|
232
|
+
pane = sess.active_pane
|
|
233
|
+
else:
|
|
234
|
+
# Session exists, add a new rebase window
|
|
235
|
+
# Check if rebase window already exists
|
|
236
|
+
existing_rebase = None
|
|
237
|
+
for window in sess.windows:
|
|
238
|
+
if window.name == window_name:
|
|
239
|
+
existing_rebase = window
|
|
240
|
+
break
|
|
241
|
+
|
|
242
|
+
if existing_rebase:
|
|
243
|
+
# Rebase window exists, make it unique with timestamp
|
|
244
|
+
import time
|
|
245
|
+
window_name = f"rebase-{int(time.time()) % 10000}"
|
|
246
|
+
|
|
247
|
+
pane = sess.new_window(
|
|
248
|
+
window_name=window_name,
|
|
249
|
+
start_directory=str(r.repo.path),
|
|
250
|
+
).active_pane
|
|
251
|
+
|
|
252
|
+
# Always land on git status to show current state
|
|
253
|
+
pane.send_keys("git status")
|
|
254
|
+
return session_name
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# --------------------------------------------------------------------------- #
|
|
258
|
+
# main #
|
|
259
|
+
# --------------------------------------------------------------------------- #
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
async def _run(repos: list[Repo], jobs: int) -> list[Result]:
|
|
263
|
+
sem = asyncio.Semaphore(jobs)
|
|
264
|
+
return await asyncio.gather(*(rebase(r, sem) for r in repos))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _print_report(results: list[Result], verbose: int) -> int:
|
|
268
|
+
fails = 0
|
|
269
|
+
width = max((len(r.repo.name) for r in results), default=0)
|
|
270
|
+
for r in results:
|
|
271
|
+
stream = sys.stdout if r.ok else sys.stderr
|
|
272
|
+
print(f"{r.repo.name:<{width}} {r.summary_line()}", file=stream)
|
|
273
|
+
if verbose > 0 and r.changed:
|
|
274
|
+
preview = r.log_lines if verbose > 1 else r.log_lines[:3]
|
|
275
|
+
for ln in preview:
|
|
276
|
+
print(f" {ln}", file=stream)
|
|
277
|
+
if verbose <= 1 and len(r.log_lines) > 3:
|
|
278
|
+
print(f" ... +{len(r.log_lines) - 3} more", file=stream)
|
|
279
|
+
if not r.ok:
|
|
280
|
+
fails += 1
|
|
281
|
+
return fails
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def main() -> None:
|
|
285
|
+
ap = argparse.ArgumentParser(
|
|
286
|
+
prog="tmuxpull",
|
|
287
|
+
description=(
|
|
288
|
+
"Concurrently `git pull --rebase --autostash` every Git repo under the "
|
|
289
|
+
"given roots. Print a per-repo summary of what changed, and create a "
|
|
290
|
+
"dedicated tmux session per repo with a rebase window."
|
|
291
|
+
),
|
|
292
|
+
)
|
|
293
|
+
ap.add_argument(
|
|
294
|
+
"-d",
|
|
295
|
+
"--max-depth",
|
|
296
|
+
type=int,
|
|
297
|
+
default=2,
|
|
298
|
+
metavar="N",
|
|
299
|
+
help="Directory search depth (default: 2).",
|
|
300
|
+
)
|
|
301
|
+
ap.add_argument(
|
|
302
|
+
"-j",
|
|
303
|
+
"--jobs",
|
|
304
|
+
type=int,
|
|
305
|
+
default=min(8, (os.cpu_count() or 2) * 2),
|
|
306
|
+
metavar="N",
|
|
307
|
+
help="Max concurrent rebases (default: min(8, 2*CPU)).",
|
|
308
|
+
)
|
|
309
|
+
ap.add_argument(
|
|
310
|
+
"--tmux",
|
|
311
|
+
choices=("on", "off"),
|
|
312
|
+
default="on",
|
|
313
|
+
help="Create tmux sessions: on (default) or off.",
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
ap.add_argument(
|
|
317
|
+
"-v",
|
|
318
|
+
"--verbose",
|
|
319
|
+
action="count",
|
|
320
|
+
default=0,
|
|
321
|
+
help="Show commit subjects. -v = top 3, -vv = all.",
|
|
322
|
+
)
|
|
323
|
+
ap.add_argument(
|
|
324
|
+
"--dry-run",
|
|
325
|
+
action="store_true",
|
|
326
|
+
help="List repos that would be pulled, then exit.",
|
|
327
|
+
)
|
|
328
|
+
ap.add_argument(
|
|
329
|
+
"dirs",
|
|
330
|
+
nargs="+",
|
|
331
|
+
metavar="dir",
|
|
332
|
+
help="Root directories to scan for Git repos.",
|
|
333
|
+
)
|
|
334
|
+
args = ap.parse_args()
|
|
335
|
+
|
|
336
|
+
repos = find_repos(args.dirs, args.max_depth)
|
|
337
|
+
if not repos:
|
|
338
|
+
print("no git repos found", file=sys.stderr)
|
|
339
|
+
sys.exit(1)
|
|
340
|
+
|
|
341
|
+
if args.dry_run:
|
|
342
|
+
for r in repos:
|
|
343
|
+
print(r.path)
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
print(
|
|
347
|
+
f"rebasing {len(repos)} repo{'s' if len(repos) != 1 else ''} (jobs={args.jobs})...",
|
|
348
|
+
file=sys.stderr,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
results = asyncio.run(_run(repos, args.jobs))
|
|
352
|
+
fails = _print_report(results, args.verbose)
|
|
353
|
+
|
|
354
|
+
tmux_wanted = args.tmux == "on"
|
|
355
|
+
if tmux_wanted and shutil.which("tmux") is None:
|
|
356
|
+
print("tmux not on PATH; skipping sessions", file=sys.stderr)
|
|
357
|
+
elif tmux_wanted:
|
|
358
|
+
server = libtmux.Server()
|
|
359
|
+
session_names = []
|
|
360
|
+
|
|
361
|
+
for r in results:
|
|
362
|
+
session_name = open_repo_session(server, r)
|
|
363
|
+
session_names.append(session_name)
|
|
364
|
+
|
|
365
|
+
if session_names:
|
|
366
|
+
print(f"\n{len(session_names)} tmux session(s) created:", file=sys.stderr)
|
|
367
|
+
for name in sorted(set(session_names)):
|
|
368
|
+
print(f" tmux attach -t {name}", file=sys.stderr)
|
|
369
|
+
|
|
370
|
+
sys.exit(1 if fails else 0)
|
tmuxpull/__main__.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tmuxpull
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Concurrent git pull --rebase --autostash across multiple repos with tmux attention windows
|
|
5
|
+
Project-URL: Homepage, https://github.com/nguyengg/tmuxpull
|
|
6
|
+
Project-URL: Repository, https://github.com/nguyengg/tmuxpull.git
|
|
7
|
+
Project-URL: Issues, https://github.com/nguyengg/tmuxpull/issues
|
|
8
|
+
Author-email: Henry Nguyen <5065089+nguyengg@users.noreply.github.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: automation,concurrent,git,rebase,tmux
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Operating System :: POSIX
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
22
|
+
Classifier: Topic :: System :: Systems Administration
|
|
23
|
+
Classifier: Topic :: Terminals
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Requires-Dist: libtmux>=0.35
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# tmuxpull
|
|
29
|
+
|
|
30
|
+
Concurrent `git pull --rebase --autostash` across multiple Git repositories with tmux integration.
|
|
31
|
+
|
|
32
|
+
## Quick Start
|
|
33
|
+
|
|
34
|
+
### Run instantly with curl (no install)
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Python version — requires uv (https://docs.astral.sh/uv/)
|
|
38
|
+
curl -fsSL https://raw.githubusercontent.com/nguyengg/tmuxpull/main/bin/rebase-all.py | uv run - ~/Workspaces
|
|
39
|
+
|
|
40
|
+
# Zsh version — zero dependencies (just git + tmux)
|
|
41
|
+
curl -fsSL https://raw.githubusercontent.com/nguyengg/tmuxpull/main/bin/rebase-all | zsh -s -- ~/Workspaces
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Install with curl (one-liner)
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# Download the self-contained script to ~/.local/bin
|
|
48
|
+
curl -fsSL https://raw.githubusercontent.com/nguyengg/tmuxpull/main/bin/rebase-all.py -o ~/.local/bin/tmuxpull && chmod +x ~/.local/bin/tmuxpull
|
|
49
|
+
tmuxpull ~/Workspaces
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The script carries its own dependency metadata (PEP 723), so with `uv` on your PATH it bootstraps its own environment on first run — no venv, no pip install.
|
|
53
|
+
|
|
54
|
+
### Install from PyPI
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# Install via pip/uv (recommended)
|
|
58
|
+
pip install tmuxpull
|
|
59
|
+
tmuxpull ~/Workspaces
|
|
60
|
+
|
|
61
|
+
# Or install as a uv tool
|
|
62
|
+
uv tool install tmuxpull
|
|
63
|
+
tmuxpull ~/Workspaces
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Both scan for Git repos under the given directories, pull with rebase concurrently, print a summary per repo, and open tmux windows for repos that need your attention (conflicts, failures, etc.).
|
|
67
|
+
|
|
68
|
+
## Features
|
|
69
|
+
|
|
70
|
+
- **Concurrent execution** with configurable job limits
|
|
71
|
+
- **Smart repo discovery** with depth limits and noise filtering (skips `node_modules`, `.venv`, etc.)
|
|
72
|
+
- **Per-repo summaries** showing commits pulled and file change stats (Python version)
|
|
73
|
+
- **tmux integration** — opens windows for repos needing attention, landing on `git status`
|
|
74
|
+
- **Multiple modes**: attention-only (default), all repos, or no tmux
|
|
75
|
+
- **PEP 723 packaging** (Python) — zero-setup single file with dependencies declared inline
|
|
76
|
+
|
|
77
|
+
## Usage
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
tmuxpull [-d DEPTH] [-j JOBS] [--tmux {all,attn,off}]
|
|
81
|
+
[-s SESSION] [-v] [--dry-run] DIR [DIR ...]
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Options
|
|
85
|
+
|
|
86
|
+
- `-d, --max-depth N` — Directory search depth (default: 2)
|
|
87
|
+
- `-j, --jobs N` — Max concurrent rebases (default: min(8, 2×CPU))
|
|
88
|
+
- `--tmux {all,attn,off}` — tmux windows for: all repos, attention-only (default), or none
|
|
89
|
+
- `-s, --session NAME` — tmux session name (default: "rebase")
|
|
90
|
+
- `-v, --verbose` — Show commit subjects (-v = top 3, -vv = all)
|
|
91
|
+
- `--dry-run` — List repos that would be processed, then exit
|
|
92
|
+
|
|
93
|
+
### Examples
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# Morning sync across your workspace
|
|
97
|
+
tmuxpull ~/Workspaces ~/Projects
|
|
98
|
+
|
|
99
|
+
# High concurrency, all repos get tmux windows
|
|
100
|
+
tmuxpull -j 16 --tmux all ~/Code
|
|
101
|
+
|
|
102
|
+
# Just print what would happen
|
|
103
|
+
tmuxpull --dry-run ~/Projects
|
|
104
|
+
|
|
105
|
+
# Verbose output showing commit messages
|
|
106
|
+
tmuxpull -v ~/Workspaces
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Output
|
|
110
|
+
|
|
111
|
+
Per-repo summary lines:
|
|
112
|
+
```
|
|
113
|
+
my-project ✓ 3 commits 8 files changed, 213 insertions(+), 41 deletions(-)
|
|
114
|
+
other-repo · up to date
|
|
115
|
+
broken-thing ✗ FAIL: could not apply autostash
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Failed repos open tmux windows in the "rebase" session (or `-s NAME`), landing on `git status` so you see what's broken. Attach with `tmux attach -t rebase`.
|
|
119
|
+
|
|
120
|
+
## Two Versions
|
|
121
|
+
|
|
122
|
+
### `bin/rebase-all.py` (Recommended)
|
|
123
|
+
|
|
124
|
+
- **Standard Python package** with proper console script entry point
|
|
125
|
+
- Rich per-repo summaries with git log output and diffstat
|
|
126
|
+
- Better error handling and progress reporting
|
|
127
|
+
- Structured data model for repo state
|
|
128
|
+
|
|
129
|
+
**Requirements**: Python 3.11+, tmux, git
|
|
130
|
+
|
|
131
|
+
### `bin/rebase-all` (Fallback)
|
|
132
|
+
|
|
133
|
+
- **Pure Zsh** — no Python dependencies
|
|
134
|
+
- Basic summaries (commit count only, no diffstat)
|
|
135
|
+
- Simpler concurrency model with job control
|
|
136
|
+
|
|
137
|
+
**Requirements**: Zsh, tmux, git
|
|
138
|
+
|
|
139
|
+
## Installation
|
|
140
|
+
|
|
141
|
+
### From PyPI (Recommended)
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
# Install globally
|
|
145
|
+
pip install tmuxpull
|
|
146
|
+
|
|
147
|
+
# Or as a uv tool (isolated)
|
|
148
|
+
uv tool install tmuxpull
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### From Source
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
# Clone and install
|
|
155
|
+
git clone https://github.com/nguyengg/tmuxpull.git
|
|
156
|
+
cd tmuxpull
|
|
157
|
+
pip install .
|
|
158
|
+
|
|
159
|
+
# Or for development
|
|
160
|
+
uv sync --dev
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Zsh Fallback
|
|
164
|
+
|
|
165
|
+
For machines without Python, use the dependency-free Zsh script:
|
|
166
|
+
```bash
|
|
167
|
+
chmod +x bin/rebase-all
|
|
168
|
+
ln -s $PWD/bin/rebase-all ~/.local/bin/
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Design
|
|
172
|
+
|
|
173
|
+
Finds Git repos by walking the filesystem looking for `.git` directories, up to a configurable depth. Prunes common noise directories (`node_modules`, build artifacts, Python venvs) to avoid slow traversals.
|
|
174
|
+
|
|
175
|
+
Rebases run concurrently via `asyncio` (Python) or Zsh job control, capped at a reasonable limit to avoid overwhelming git servers. Each repo is isolated — failures don't stop other repos.
|
|
176
|
+
|
|
177
|
+
The tmux integration is the key workflow piece: clean repos just print their summary and disappear, while repos needing intervention (conflict resolution, stash conflicts, etc.) open interactive windows where you can fix things. `tmux attach -t rebase` becomes your "work queue" for the morning.
|
|
178
|
+
|
|
179
|
+
## License
|
|
180
|
+
|
|
181
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
tmuxpull/__init__.py,sha256=rk9yOW2NjSURfZbNHwdl3M2xxKPcHs_T-2wKBMufJM8,11636
|
|
2
|
+
tmuxpull/__main__.py,sha256=xDj_Ex52cF8R4KnmOZ50R4SbO8ngntkyB18rTc3Y09E,143
|
|
3
|
+
tmuxpull-0.1.0.dist-info/METADATA,sha256=qGpWF37Wr18M6NACU-hOPDNtMvnwHUMec_pxzDoymuU,5975
|
|
4
|
+
tmuxpull-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
5
|
+
tmuxpull-0.1.0.dist-info/entry_points.txt,sha256=-x4bLcj70nbF3N9cw1cPOymSn0mw6vZQPDd67CQiNbs,52
|
|
6
|
+
tmuxpull-0.1.0.dist-info/licenses/LICENSE,sha256=B7s-ICTPZWDrB7_mo4xH90Zu-sI4VgspGkIXlVqZwxQ,1068
|
|
7
|
+
tmuxpull-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Henry Nguyen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|