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,6 @@
1
+ """Entry point for `python -m tmuxpull` and the `tmuxpull` console script."""
2
+
3
+ from tmuxpull import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tmuxpull = tmuxpull.__main__:main
@@ -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.