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 +20 -0
- pulli-0.2.1/LICENSE +21 -0
- pulli-0.2.1/PKG-INFO +207 -0
- pulli-0.2.1/README.md +190 -0
- pulli-0.2.1/pyproject.toml +37 -0
- pulli-0.2.1/src/pulli/__init__.py +2 -0
- pulli-0.2.1/src/pulli/cli.py +316 -0
- pulli-0.2.1/src/pulli/discovery.py +541 -0
- pulli-0.2.1/src/pulli/pull.py +276 -0
- pulli-0.2.1/src/pulli/spinner.py +101 -0
- pulli-0.2.1/src/pulli/status.py +441 -0
- pulli-0.2.1/src/pulli/tree.py +447 -0
- pulli-0.2.1/tests/test_pulli.py +692 -0
- pulli-0.2.1/uv.lock +78 -0
pulli-0.2.1/.gitignore
ADDED
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"
|