pulli 0.2.1__tar.gz

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.
pulli-0.2.1/.gitignore ADDED
@@ -0,0 +1,20 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .venv/
6
+ venv/
7
+ env/
8
+
9
+ # Test / tooling
10
+ .pytest_cache/
11
+ .mypy_cache/
12
+ .ruff_cache/
13
+ .coverage
14
+
15
+ # Build artifacts
16
+ dist/
17
+ build/
18
+
19
+ # OS
20
+ .DS_Store
pulli-0.2.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stefan Waldherr
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.
pulli-0.2.1/PKG-INFO ADDED
@@ -0,0 +1,207 @@
1
+ Metadata-Version: 2.5
2
+ Name: pulli
3
+ Version: 0.2.1
4
+ Summary: Walk a directory tree, show git repos with status, and fast-forward the ones that are behind upstream.
5
+ Author: Stefan Waldherr
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: cli,developer-tools,git,pull,status
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Software Development :: Version Control :: Git
13
+ Requires-Python: >=3.11
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=8; extra == 'dev'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # pulli
19
+
20
+ Discover git repositories under a directory, show their status, and
21
+ fast-forward the ones that are behind upstream.
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ uv tool install git+https://github.com/devskale/pulli
27
+ # or
28
+ pipx install git+https://github.com/devskale/pulli
29
+ ```
30
+
31
+ Both put `pulli` on your PATH (`~/.local/bin/pulli`). To install a local
32
+ checkout instead (development):
33
+
34
+ ```bash
35
+ cd ~/code/pulli
36
+ uv tool install --force .
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ ```bash
42
+ pulli # status tree of the current dir
43
+ pulli ~/code # status tree of ~/code (= `pulli tree ~/code`)
44
+ pulli --no-fetch ~/code # flags may come before or after the path
45
+ pulli pull # pull the repos that are behind
46
+ pulli pull --dry-run ~/code # show what would be pulled, don't pull
47
+ ```
48
+
49
+ ### `tree` (default)
50
+
51
+ By default `pulli` prints a **flat list of the repos only** — one line per
52
+ repo, sorted by path — with no directory scaffolding, no `node_modules`/
53
+ `.claude`/`dist` noise. Pass `--tree` to get the full directory tree
54
+ instead (plain dirs, pruned dirs, symlinks, submodules, and the box-drawing
55
+ connectors).
56
+
57
+ | flag | effect |
58
+ | --- | --- |
59
+ | `--tree` | show the full directory tree instead of the flat repo list |
60
+ | `--no-fetch` | don't fetch remotes before showing status |
61
+ | `--no-symlinks` | don't follow symlinks (default: follow, marked) |
62
+ | `--max-depth N` | limit recursion (default: 50) |
63
+ | `--no-color` | disable ANSI colours (also implied off a TTY) |
64
+ | `--json` | one JSON object per line, for scripts |
65
+
66
+ Fetches run in parallel by default, so ahead/behind is current, and each is
67
+ bounded by a short timeout — if you're offline (on a train) unreachable
68
+ remotes are reported as `offline` and the tree still renders.
69
+
70
+ While remotes are being fetched, a spinner animates on stderr with live
71
+ progress (`⠋ Fetching remotes… 3/8`), so a multi-second fetch never looks
72
+ frozen. The spinner only appears on a real terminal; when output is piped
73
+ or captured (`pulli | less`, CI, scripts) it is a no-op and the output stays
74
+ byte-clean. `--no-color` disables the spinner's colouring too.
75
+
76
+ ### `pull`
77
+
78
+ | flag | effect |
79
+ | --- | --- |
80
+ | `--dry-run` | decide and report, change nothing |
81
+ | `--no-fetch` | use local refs only (faster, may be stale) |
82
+ | `--json` | the same decision as data; implies `--dry-run` |
83
+ | `--no-color`, `--no-symlinks`, `--max-depth` | as above |
84
+
85
+ Fetches every repo first, then fast-forwards the ones that are behind.
86
+
87
+ ## What `pull` does and does not touch
88
+
89
+ Every rule below is a **skip, never a force** — pulli will not destroy or
90
+ corrupt work to make progress.
91
+
92
+ | repo state | action |
93
+ | --- | --- |
94
+ | behind upstream, clean | `git pull --ff-only` |
95
+ | up to date | nothing (silent) |
96
+ | ahead only | nothing — local commits need a *push*, not a pull |
97
+ | uncommitted changes | skipped, reported |
98
+ | diverged (ahead **and** behind) | skipped — a fast-forward is impossible; merge or rebase is your call |
99
+ | merge / rebase / bisect in progress | skipped, reported — never touched |
100
+ | no upstream (detached HEAD, no remote) | nothing to pull |
101
+ | remote unreachable | reported as `offline`; **not** a failure |
102
+ | broken repo | reported; exit code 1 |
103
+
104
+ `--ff-only` is the important part: a plain `git pull` can *create* a merge
105
+ commit (and a merge conflict) in a repo you never touched. `--ff-only`
106
+ refuses instead.
107
+
108
+ Exit code is `0` when nothing needs you, `1` on a broken repo or a failed
109
+ pull. Being offline is not a failure.
110
+
111
+ ```
112
+ $ pulli pull --dry-run ~/code
113
+ ↕ aiuis/pi-gui ↓4 ↑2 diverged — needs merge or rebase, skipping
114
+ ◐ www/chopdok ↓5 dirty 1 (MERGE-REVIEW.md), skipping pull
115
+ ↑ throway ↑1 ↑1 ahead (not pushed)
116
+ ◐ klark0 merge in progress — skipping pull
117
+ ↓ chopdok ↓5 would pull
118
+
119
+ Dry run — no pulls performed.
120
+
121
+ Would pull 1 repo(s).
122
+ ```
123
+
124
+ ## What the output shows
125
+
126
+ ### Flat list (default)
127
+
128
+ ```
129
+ clones/pi earendil-works/pi main ↓0 ↑0 ● clean
130
+ clones/gogcli openclaw/gogcli main ↓0 ↑0 ◐ dirty 5
131
+ kontext.one devskale/kontext.one main ↓0 ↑0 ◐ dirty 5
132
+ ```
133
+
134
+ Each line is `path remote branch ↓behind ↑ahead state`. Repos are
135
+ sorted by path, so a run is reproducible.
136
+
137
+ ### Tree (`--tree`)
138
+
139
+ ```
140
+ ~/code
141
+ ├── clones/
142
+ │ ├── herdr ogulcancelik/herdr master ↓0 ↑0 ● clean
143
+ │ ├── pi earendil-works/pi main ↓33 ↑0 ● clean
144
+ │ └── gogcli openclaw/gogcli main ↓0 ↑0 ◐ dirty 5
145
+ ├── handoffs -> code/skaleshare/handoffs (alias)
146
+ ├── kontext.one devskale/kontext.one main ↓0 ↑0 ◐ dirty 5
147
+ │ ├── klark0 devskale/klark0 dev ↓0 ↑0 ● clean
148
+ │ └── python-utils (submodule) ↓0 ↑0 ● clean
149
+ └── backups/
150
+ └── model-proxy.git/ (bare repo — nothing to pull)
151
+ ```
152
+
153
+ ## What the tree shows
154
+
155
+ ```
156
+ ~/code
157
+ ├── clones/
158
+ │ ├── herdr ogulcancelik/herdr master ↓0 ↑0 ● clean
159
+ │ ├── pi earendil-works/pi main ↓33 ↑0 ● clean
160
+ │ └── gogcli openclaw/gogcli main ↓0 ↑0 ◐ dirty 5
161
+ ├── handoffs -> code/skaleshare/handoffs (alias)
162
+ ├── kontext.one devskale/kontext.one main ↓0 ↑0 ◐ dirty 5
163
+ │ ├── klark0 devskale/klark0 dev ↓0 ↑0 ● clean
164
+ │ └── python-utils (submodule) ↓0 ↑0 ● clean
165
+ └── backups/
166
+ └── model-proxy.git/ (bare repo — nothing to pull)
167
+ ```
168
+
169
+ - **`↓N ↑M`** — commits behind / ahead of upstream; `· ·` means there is
170
+ no upstream to compare against (a fresh `git init`, a detached HEAD, a
171
+ remote-less clone), which is not the same as "in sync"
172
+ - **`●` clean / `◐` dirty N / `✗` error** — `◐ offline` means the fetch
173
+ failed, so the numbers may be stale
174
+ - **symlinks** are followed by default and marked; one whose target is also
175
+ reachable under its real name is shown as an `(alias)` and is never a
176
+ second pull target. `--no-symlinks` skips them
177
+ - **submodules** are their own nodes, marked `(submodule)`
178
+ - **nested repos** (a repo inside another repo) are found and shown
179
+ - **bare repos** (`x.git/`) have no working tree: marked, never pulled
180
+ - **credentials** in remote URLs (tokens, `user:pass@`) are stripped before
181
+ display
182
+
183
+ ## Development
184
+
185
+ ```bash
186
+ uv run --extra dev pytest # 45 tests, all against real git repos in tmpdirs
187
+ ```
188
+
189
+ Tests build actual repositories in every state pulli reasons about
190
+ (behind, ahead, diverged, dirty, detached, mid-merge, offline, bare,
191
+ symlinked, vendored) and assert on the *decision*, not the formatting —
192
+ plus a cross-check that ahead/behind matches what `git rev-list` reports.
193
+
194
+ ## Layout
195
+
196
+ ```
197
+ src/pulli/
198
+ ├── cli.py # argparse entry point (tree + pull subcommands)
199
+ ├── discovery.py # tree walk: repos, nested repos, submodules, symlinks, bare
200
+ ├── status.py # per-repo git status (branch, ahead/behind, dirty, operation)
201
+ ├── pull.py # the decision: what to skip, what to fast-forward
202
+ └── tree.py # ANSI tree renderer with credential scrubbing
203
+ ```
204
+
205
+ `status.py` is the only module that shells out for status; `tree.py` renders
206
+ from the fields it fills in. That keeps git to one call per repo and keeps
207
+ credential scrubbing in exactly one place.
pulli-0.2.1/README.md ADDED
@@ -0,0 +1,190 @@
1
+ # pulli
2
+
3
+ Discover git repositories under a directory, show their status, and
4
+ fast-forward the ones that are behind upstream.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ uv tool install git+https://github.com/devskale/pulli
10
+ # or
11
+ pipx install git+https://github.com/devskale/pulli
12
+ ```
13
+
14
+ Both put `pulli` on your PATH (`~/.local/bin/pulli`). To install a local
15
+ checkout instead (development):
16
+
17
+ ```bash
18
+ cd ~/code/pulli
19
+ uv tool install --force .
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ```bash
25
+ pulli # status tree of the current dir
26
+ pulli ~/code # status tree of ~/code (= `pulli tree ~/code`)
27
+ pulli --no-fetch ~/code # flags may come before or after the path
28
+ pulli pull # pull the repos that are behind
29
+ pulli pull --dry-run ~/code # show what would be pulled, don't pull
30
+ ```
31
+
32
+ ### `tree` (default)
33
+
34
+ By default `pulli` prints a **flat list of the repos only** — one line per
35
+ repo, sorted by path — with no directory scaffolding, no `node_modules`/
36
+ `.claude`/`dist` noise. Pass `--tree` to get the full directory tree
37
+ instead (plain dirs, pruned dirs, symlinks, submodules, and the box-drawing
38
+ connectors).
39
+
40
+ | flag | effect |
41
+ | --- | --- |
42
+ | `--tree` | show the full directory tree instead of the flat repo list |
43
+ | `--no-fetch` | don't fetch remotes before showing status |
44
+ | `--no-symlinks` | don't follow symlinks (default: follow, marked) |
45
+ | `--max-depth N` | limit recursion (default: 50) |
46
+ | `--no-color` | disable ANSI colours (also implied off a TTY) |
47
+ | `--json` | one JSON object per line, for scripts |
48
+
49
+ Fetches run in parallel by default, so ahead/behind is current, and each is
50
+ bounded by a short timeout — if you're offline (on a train) unreachable
51
+ remotes are reported as `offline` and the tree still renders.
52
+
53
+ While remotes are being fetched, a spinner animates on stderr with live
54
+ progress (`⠋ Fetching remotes… 3/8`), so a multi-second fetch never looks
55
+ frozen. The spinner only appears on a real terminal; when output is piped
56
+ or captured (`pulli | less`, CI, scripts) it is a no-op and the output stays
57
+ byte-clean. `--no-color` disables the spinner's colouring too.
58
+
59
+ ### `pull`
60
+
61
+ | flag | effect |
62
+ | --- | --- |
63
+ | `--dry-run` | decide and report, change nothing |
64
+ | `--no-fetch` | use local refs only (faster, may be stale) |
65
+ | `--json` | the same decision as data; implies `--dry-run` |
66
+ | `--no-color`, `--no-symlinks`, `--max-depth` | as above |
67
+
68
+ Fetches every repo first, then fast-forwards the ones that are behind.
69
+
70
+ ## What `pull` does and does not touch
71
+
72
+ Every rule below is a **skip, never a force** — pulli will not destroy or
73
+ corrupt work to make progress.
74
+
75
+ | repo state | action |
76
+ | --- | --- |
77
+ | behind upstream, clean | `git pull --ff-only` |
78
+ | up to date | nothing (silent) |
79
+ | ahead only | nothing — local commits need a *push*, not a pull |
80
+ | uncommitted changes | skipped, reported |
81
+ | diverged (ahead **and** behind) | skipped — a fast-forward is impossible; merge or rebase is your call |
82
+ | merge / rebase / bisect in progress | skipped, reported — never touched |
83
+ | no upstream (detached HEAD, no remote) | nothing to pull |
84
+ | remote unreachable | reported as `offline`; **not** a failure |
85
+ | broken repo | reported; exit code 1 |
86
+
87
+ `--ff-only` is the important part: a plain `git pull` can *create* a merge
88
+ commit (and a merge conflict) in a repo you never touched. `--ff-only`
89
+ refuses instead.
90
+
91
+ Exit code is `0` when nothing needs you, `1` on a broken repo or a failed
92
+ pull. Being offline is not a failure.
93
+
94
+ ```
95
+ $ pulli pull --dry-run ~/code
96
+ ↕ aiuis/pi-gui ↓4 ↑2 diverged — needs merge or rebase, skipping
97
+ ◐ www/chopdok ↓5 dirty 1 (MERGE-REVIEW.md), skipping pull
98
+ ↑ throway ↑1 ↑1 ahead (not pushed)
99
+ ◐ klark0 merge in progress — skipping pull
100
+ ↓ chopdok ↓5 would pull
101
+
102
+ Dry run — no pulls performed.
103
+
104
+ Would pull 1 repo(s).
105
+ ```
106
+
107
+ ## What the output shows
108
+
109
+ ### Flat list (default)
110
+
111
+ ```
112
+ clones/pi earendil-works/pi main ↓0 ↑0 ● clean
113
+ clones/gogcli openclaw/gogcli main ↓0 ↑0 ◐ dirty 5
114
+ kontext.one devskale/kontext.one main ↓0 ↑0 ◐ dirty 5
115
+ ```
116
+
117
+ Each line is `path remote branch ↓behind ↑ahead state`. Repos are
118
+ sorted by path, so a run is reproducible.
119
+
120
+ ### Tree (`--tree`)
121
+
122
+ ```
123
+ ~/code
124
+ ├── clones/
125
+ │ ├── herdr ogulcancelik/herdr master ↓0 ↑0 ● clean
126
+ │ ├── pi earendil-works/pi main ↓33 ↑0 ● clean
127
+ │ └── gogcli openclaw/gogcli main ↓0 ↑0 ◐ dirty 5
128
+ ├── handoffs -> code/skaleshare/handoffs (alias)
129
+ ├── kontext.one devskale/kontext.one main ↓0 ↑0 ◐ dirty 5
130
+ │ ├── klark0 devskale/klark0 dev ↓0 ↑0 ● clean
131
+ │ └── python-utils (submodule) ↓0 ↑0 ● clean
132
+ └── backups/
133
+ └── model-proxy.git/ (bare repo — nothing to pull)
134
+ ```
135
+
136
+ ## What the tree shows
137
+
138
+ ```
139
+ ~/code
140
+ ├── clones/
141
+ │ ├── herdr ogulcancelik/herdr master ↓0 ↑0 ● clean
142
+ │ ├── pi earendil-works/pi main ↓33 ↑0 ● clean
143
+ │ └── gogcli openclaw/gogcli main ↓0 ↑0 ◐ dirty 5
144
+ ├── handoffs -> code/skaleshare/handoffs (alias)
145
+ ├── kontext.one devskale/kontext.one main ↓0 ↑0 ◐ dirty 5
146
+ │ ├── klark0 devskale/klark0 dev ↓0 ↑0 ● clean
147
+ │ └── python-utils (submodule) ↓0 ↑0 ● clean
148
+ └── backups/
149
+ └── model-proxy.git/ (bare repo — nothing to pull)
150
+ ```
151
+
152
+ - **`↓N ↑M`** — commits behind / ahead of upstream; `· ·` means there is
153
+ no upstream to compare against (a fresh `git init`, a detached HEAD, a
154
+ remote-less clone), which is not the same as "in sync"
155
+ - **`●` clean / `◐` dirty N / `✗` error** — `◐ offline` means the fetch
156
+ failed, so the numbers may be stale
157
+ - **symlinks** are followed by default and marked; one whose target is also
158
+ reachable under its real name is shown as an `(alias)` and is never a
159
+ second pull target. `--no-symlinks` skips them
160
+ - **submodules** are their own nodes, marked `(submodule)`
161
+ - **nested repos** (a repo inside another repo) are found and shown
162
+ - **bare repos** (`x.git/`) have no working tree: marked, never pulled
163
+ - **credentials** in remote URLs (tokens, `user:pass@`) are stripped before
164
+ display
165
+
166
+ ## Development
167
+
168
+ ```bash
169
+ uv run --extra dev pytest # 45 tests, all against real git repos in tmpdirs
170
+ ```
171
+
172
+ Tests build actual repositories in every state pulli reasons about
173
+ (behind, ahead, diverged, dirty, detached, mid-merge, offline, bare,
174
+ symlinked, vendored) and assert on the *decision*, not the formatting —
175
+ plus a cross-check that ahead/behind matches what `git rev-list` reports.
176
+
177
+ ## Layout
178
+
179
+ ```
180
+ src/pulli/
181
+ ├── cli.py # argparse entry point (tree + pull subcommands)
182
+ ├── discovery.py # tree walk: repos, nested repos, submodules, symlinks, bare
183
+ ├── status.py # per-repo git status (branch, ahead/behind, dirty, operation)
184
+ ├── pull.py # the decision: what to skip, what to fast-forward
185
+ └── tree.py # ANSI tree renderer with credential scrubbing
186
+ ```
187
+
188
+ `status.py` is the only module that shells out for status; `tree.py` renders
189
+ from the fields it fills in. That keeps git to one call per repo and keeps
190
+ credential scrubbing in exactly one place.
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "pulli"
3
+ dynamic = ["version"]
4
+ description = "Walk a directory tree, show git repos with status, and fast-forward the ones that are behind upstream."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Stefan Waldherr" }]
10
+ keywords = ["git", "cli", "pull", "status", "developer-tools"]
11
+ classifiers = [
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "Programming Language :: Python :: 3",
15
+ "Topic :: Software Development :: Version Control :: Git",
16
+ ]
17
+ dependencies = []
18
+
19
+ [project.scripts]
20
+ pulli = "pulli.cli:main"
21
+
22
+ [project.optional-dependencies]
23
+ dev = ["pytest>=8"]
24
+
25
+ [build-system]
26
+ requires = ["hatchling"]
27
+ build-backend = "hatchling.build"
28
+
29
+ [tool.hatch.version]
30
+ path = "src/pulli/__init__.py"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/pulli"]
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
37
+ addopts = "-q"
@@ -0,0 +1,2 @@
1
+ """pulli — discover git repos under a root and report their status."""
2
+ __version__ = "0.2.1"