fastws-cli 0.0.7__tar.gz → 0.0.8__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.
- fastws_cli-0.0.8/CHANGELOG.md +47 -0
- fastws_cli-0.0.8/PKG-INFO +154 -0
- fastws_cli-0.0.8/README.md +131 -0
- fastws_cli-0.0.8/fastws/__init__.py +7 -0
- {fastws_cli-0.0.7 → fastws_cli-0.0.8}/fastws/core.py +140 -58
- fastws_cli-0.0.8/fastws/releases.py +108 -0
- fastws_cli-0.0.8/fastws_cli.egg-info/PKG-INFO +154 -0
- {fastws_cli-0.0.7 → fastws_cli-0.0.8}/fastws_cli.egg-info/SOURCES.txt +2 -0
- fastws_cli-0.0.8/fastws_cli.egg-info/entry_points.txt +9 -0
- {fastws_cli-0.0.7 → fastws_cli-0.0.8}/fastws_cli.egg-info/requires.txt +4 -2
- {fastws_cli-0.0.7 → fastws_cli-0.0.8}/pyproject.toml +12 -8
- fastws_cli-0.0.8/tests/test_releases.py +60 -0
- fastws_cli-0.0.8/tests/test_sync.py +500 -0
- fastws_cli-0.0.7/CHANGELOG.md +0 -31
- fastws_cli-0.0.7/PKG-INFO +0 -99
- fastws_cli-0.0.7/README.md +0 -78
- fastws_cli-0.0.7/fastws/__init__.py +0 -5
- fastws_cli-0.0.7/fastws_cli.egg-info/PKG-INFO +0 -99
- fastws_cli-0.0.7/fastws_cli.egg-info/entry_points.txt +0 -7
- fastws_cli-0.0.7/tests/test_sync.py +0 -236
- {fastws_cli-0.0.7 → fastws_cli-0.0.8}/LICENSE +0 -0
- {fastws_cli-0.0.7 → fastws_cli-0.0.8}/MANIFEST.in +0 -0
- {fastws_cli-0.0.7 → fastws_cli-0.0.8}/fastws_cli.egg-info/dependency_links.txt +0 -0
- {fastws_cli-0.0.7 → fastws_cli-0.0.8}/fastws_cli.egg-info/top_level.txt +0 -0
- {fastws_cli-0.0.7 → fastws_cli-0.0.8}/setup.cfg +0 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<!-- do not remove -->
|
|
2
|
+
|
|
3
|
+
## 0.0.8
|
|
4
|
+
|
|
5
|
+
### New Features
|
|
6
|
+
|
|
7
|
+
- Add ws-releases: report unreleased commits across the workspace, with configurable skip patterns and repo exclusions ([#17](https://github.com/AnswerDotAI/fastws/pull/17)), thanks to [@jph00](https://github.com/jph00)
|
|
8
|
+
- Add once-daily dependency floating to ws_sync (uv sync -U + cargo update), with --upgrade flag to force ([#15](https://github.com/AnswerDotAI/fastws/issues/15))
|
|
9
|
+
- ws-sync skips uv sync for member dirs lacking pyproject.toml; ws-add resolves local folder names via origin remote ([#12](https://github.com/AnswerDotAI/fastws/issues/12))
|
|
10
|
+
- Allow bare folder name in ws-remove; separate directory deletion confirmation from metadata removal ([#9](https://github.com/AnswerDotAI/fastws/issues/9))
|
|
11
|
+
- Add `ws-remove` command to delete a repo from the workspace; refactor CLI wrappers with `@delegates` ([#8](https://github.com/AnswerDotAI/fastws/issues/8))
|
|
12
|
+
- Add [dev] extra to workspace dependencies and filter spurious warnings ([#7](https://github.com/AnswerDotAI/fastws/pull/7)), thanks to [@RensDimmendaal](https://github.com/RensDimmendaal)
|
|
13
|
+
- add --workers flag to ws-sync ([#6](https://github.com/AnswerDotAI/fastws/pull/6)), thanks to [@RensDimmendaal](https://github.com/RensDimmendaal)
|
|
14
|
+
|
|
15
|
+
### Bugs Squashed
|
|
16
|
+
|
|
17
|
+
- Preserving existing tool.uv.sources entries during ws-sync ([#14](https://github.com/AnswerDotAI/fastws/pull/14)), thanks to [@kafkasl](https://github.com/kafkasl)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
## 0.0.7
|
|
21
|
+
|
|
22
|
+
### New Features
|
|
23
|
+
|
|
24
|
+
- Refactor Pyright editable path setup to write pyrightconfig.json instead of .pth files ([#4](https://github.com/AnswerDotAI/fastws/issues/4))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
## 0.0.6
|
|
28
|
+
|
|
29
|
+
### New Features
|
|
30
|
+
|
|
31
|
+
- Add root-aware clone/pull helpers, auto-pull in ws-sync, and docstrings to CLI entry points ([#2](https://github.com/AnswerDotAI/fastws/issues/2))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
## 0.0.5
|
|
35
|
+
|
|
36
|
+
### New Features
|
|
37
|
+
|
|
38
|
+
- Add ws-sync and ws-add commands with workspace metadata sync and Pyright editable path support ([#1](https://github.com/AnswerDotAI/fastws/issues/1))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
## 0.0.3
|
|
42
|
+
|
|
43
|
+
- change names
|
|
44
|
+
|
|
45
|
+
## 0.0.2
|
|
46
|
+
|
|
47
|
+
- init release
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastws-cli
|
|
3
|
+
Version: 0.0.8
|
|
4
|
+
Summary: Fast workspace tools for multi-repo management
|
|
5
|
+
Author: Jeremy Howard
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/AnswerDotAI/fastws
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: fastcore>=1.14.3
|
|
14
|
+
Requires-Dist: fastgit>=0.0.7
|
|
15
|
+
Requires-Dist: ghapi>=1.0.7
|
|
16
|
+
Requires-Dist: packaging
|
|
17
|
+
Requires-Dist: tomli; python_version < "3.11"
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: fastship; extra == "dev"
|
|
20
|
+
Requires-Dist: build; extra == "dev"
|
|
21
|
+
Requires-Dist: twine; extra == "dev"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# fastws
|
|
25
|
+
|
|
26
|
+
Fast workspace tools for multi-repo management.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install fastws-cli
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Setup
|
|
35
|
+
|
|
36
|
+
Create a `repos.txt` file listing your repos (one per line):
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
AnswerDotAI/fastcore
|
|
40
|
+
AnswerDotAI/fastgit
|
|
41
|
+
AnswerDotAI/fastship
|
|
42
|
+
AnswerDotAI/fastws
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Commands
|
|
46
|
+
|
|
47
|
+
### `ws-clone`
|
|
48
|
+
|
|
49
|
+
Clone all repos from your repos file:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
ws-clone
|
|
53
|
+
ws-clone --repos-file myrepos.txt
|
|
54
|
+
ws-clone --workers 8
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### `ws-pull`
|
|
58
|
+
|
|
59
|
+
Pull updates for all repos (parallel):
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
ws-pull
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### `ws-status`
|
|
66
|
+
|
|
67
|
+
Show uncommitted changes and unpushed commits:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
ws-status
|
|
71
|
+
ws-status --branches
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### `ws-branches`
|
|
75
|
+
|
|
76
|
+
Check if all repos are on the expected branch:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
ws-branches
|
|
80
|
+
ws-branches --expected develop
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### `ws-sync`
|
|
84
|
+
|
|
85
|
+
Sync the workspace metadata, pull local repos, and install updates.
|
|
86
|
+
By default it uses the active venv parent as the workspace root, so you do not need to `cd` first.
|
|
87
|
+
It respects `tool.uv.workspace.members` and `exclude` when scanning local projects, and if any
|
|
88
|
+
member directory isn't a Python project yet (no `pyproject.toml`, e.g. a fresh empty clone), it
|
|
89
|
+
warns and skips the `uv sync` step instead of letting uv fail on the whole workspace.
|
|
90
|
+
|
|
91
|
+
At most once per day (tracked by a stamp file inside the workspace's `.git`, so git never
|
|
92
|
+
sees it), the sync also floats dependencies: `uv sync -U` instead of plain `uv sync`, plus
|
|
93
|
+
a parallel `cargo update` in every member with a `Cargo.toml`, printing what moved. Pass
|
|
94
|
+
`--upgrade` to force that pass regardless of when it last ran.
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
ws-sync
|
|
98
|
+
ws-sync --workspace ~/aai-ws
|
|
99
|
+
ws-sync --upgrade
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### `ws-add`
|
|
103
|
+
|
|
104
|
+
Add a repo to `repos.txt`, then run `ws-sync`. Given `owner/repo`, it clones; given the
|
|
105
|
+
name of an existing local folder (e.g. one just scaffolded with `nbdev-new` or `ship-new`),
|
|
106
|
+
it resolves `owner/repo` from the folder's `origin` remote instead, telling you exactly
|
|
107
|
+
what's missing if the folder has no git repo, no GitHub origin, or no `pyproject.toml`:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
ws-add AnswerDotAI/fastws
|
|
111
|
+
ws-add answerdotai/fastws
|
|
112
|
+
ws-add fastws # existing local folder, resolved via its origin remote
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### `ws-remove`
|
|
116
|
+
|
|
117
|
+
Remove a repo: delete its clone, and drop it from `repos.txt` and the workspace
|
|
118
|
+
`pyproject.toml`, then run `uv sync`. It refuses if the directory has uncommitted
|
|
119
|
+
changes, unpushed commits, no `origin` remote, or isn't a clean git checkout, and
|
|
120
|
+
always prompts for confirmation before deleting anything:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
ws-remove AnswerDotAI/fastws
|
|
124
|
+
ws-remove fastws # bare folder name also works if the directory exists
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### `ws-releases`
|
|
128
|
+
|
|
129
|
+
Report repos with commits since their newest GitHub release, so nothing reviewed sits unshipped:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
ws-releases # sweep every repo in repos.txt
|
|
133
|
+
ws-releases solveit # only solveit's transitive workspace dependencies
|
|
134
|
+
ws-releases --skip 'wip' # extra start-of-message regex for commits that need no release
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Each pending repo lists its unreleased commit summaries; repos with no releases yet get one quiet `no releases:` line, and fully-released repos appear in `up to date:`. The newest release is picked by version number (publish timestamps can be out of order), and repos whose default branch isn't `main` are handled automatically.
|
|
138
|
+
|
|
139
|
+
Commits whose message matches a start-anchored regex from the skip set need no release and aren't reported. The built-in set covers version bumps and housekeeping (`bump`, `nbdev regen`, `.gitignore`, `docs`, `CI`, ...: `DEFAULT_SKIP` in `fastws.releases`); `[tool.fastws]` in the workspace root `pyproject.toml` adds to it, and names repos that should never be swept (apps that deploy rather than release):
|
|
140
|
+
|
|
141
|
+
```toml
|
|
142
|
+
[tool.fastws]
|
|
143
|
+
release_skip = ["docs only"]
|
|
144
|
+
release_exclude = ["solveit", "md_site"]
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
From Python (the primary interface - the CLI is a thin wrapper over `fastws.releases`):
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from fastws import check_releases, check_release
|
|
151
|
+
await check_releases() # ReleaseReport: the repr is the report
|
|
152
|
+
await check_releases('solveit') # dependency-closure mode
|
|
153
|
+
await check_release('mdhtml') # one repo: list of unreleased commit summaries (None = no releases)
|
|
154
|
+
```
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# fastws
|
|
2
|
+
|
|
3
|
+
Fast workspace tools for multi-repo management.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install fastws-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
Create a `repos.txt` file listing your repos (one per line):
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
AnswerDotAI/fastcore
|
|
17
|
+
AnswerDotAI/fastgit
|
|
18
|
+
AnswerDotAI/fastship
|
|
19
|
+
AnswerDotAI/fastws
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Commands
|
|
23
|
+
|
|
24
|
+
### `ws-clone`
|
|
25
|
+
|
|
26
|
+
Clone all repos from your repos file:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
ws-clone
|
|
30
|
+
ws-clone --repos-file myrepos.txt
|
|
31
|
+
ws-clone --workers 8
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### `ws-pull`
|
|
35
|
+
|
|
36
|
+
Pull updates for all repos (parallel):
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
ws-pull
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### `ws-status`
|
|
43
|
+
|
|
44
|
+
Show uncommitted changes and unpushed commits:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
ws-status
|
|
48
|
+
ws-status --branches
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### `ws-branches`
|
|
52
|
+
|
|
53
|
+
Check if all repos are on the expected branch:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
ws-branches
|
|
57
|
+
ws-branches --expected develop
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### `ws-sync`
|
|
61
|
+
|
|
62
|
+
Sync the workspace metadata, pull local repos, and install updates.
|
|
63
|
+
By default it uses the active venv parent as the workspace root, so you do not need to `cd` first.
|
|
64
|
+
It respects `tool.uv.workspace.members` and `exclude` when scanning local projects, and if any
|
|
65
|
+
member directory isn't a Python project yet (no `pyproject.toml`, e.g. a fresh empty clone), it
|
|
66
|
+
warns and skips the `uv sync` step instead of letting uv fail on the whole workspace.
|
|
67
|
+
|
|
68
|
+
At most once per day (tracked by a stamp file inside the workspace's `.git`, so git never
|
|
69
|
+
sees it), the sync also floats dependencies: `uv sync -U` instead of plain `uv sync`, plus
|
|
70
|
+
a parallel `cargo update` in every member with a `Cargo.toml`, printing what moved. Pass
|
|
71
|
+
`--upgrade` to force that pass regardless of when it last ran.
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
ws-sync
|
|
75
|
+
ws-sync --workspace ~/aai-ws
|
|
76
|
+
ws-sync --upgrade
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### `ws-add`
|
|
80
|
+
|
|
81
|
+
Add a repo to `repos.txt`, then run `ws-sync`. Given `owner/repo`, it clones; given the
|
|
82
|
+
name of an existing local folder (e.g. one just scaffolded with `nbdev-new` or `ship-new`),
|
|
83
|
+
it resolves `owner/repo` from the folder's `origin` remote instead, telling you exactly
|
|
84
|
+
what's missing if the folder has no git repo, no GitHub origin, or no `pyproject.toml`:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
ws-add AnswerDotAI/fastws
|
|
88
|
+
ws-add answerdotai/fastws
|
|
89
|
+
ws-add fastws # existing local folder, resolved via its origin remote
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### `ws-remove`
|
|
93
|
+
|
|
94
|
+
Remove a repo: delete its clone, and drop it from `repos.txt` and the workspace
|
|
95
|
+
`pyproject.toml`, then run `uv sync`. It refuses if the directory has uncommitted
|
|
96
|
+
changes, unpushed commits, no `origin` remote, or isn't a clean git checkout, and
|
|
97
|
+
always prompts for confirmation before deleting anything:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
ws-remove AnswerDotAI/fastws
|
|
101
|
+
ws-remove fastws # bare folder name also works if the directory exists
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### `ws-releases`
|
|
105
|
+
|
|
106
|
+
Report repos with commits since their newest GitHub release, so nothing reviewed sits unshipped:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
ws-releases # sweep every repo in repos.txt
|
|
110
|
+
ws-releases solveit # only solveit's transitive workspace dependencies
|
|
111
|
+
ws-releases --skip 'wip' # extra start-of-message regex for commits that need no release
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Each pending repo lists its unreleased commit summaries; repos with no releases yet get one quiet `no releases:` line, and fully-released repos appear in `up to date:`. The newest release is picked by version number (publish timestamps can be out of order), and repos whose default branch isn't `main` are handled automatically.
|
|
115
|
+
|
|
116
|
+
Commits whose message matches a start-anchored regex from the skip set need no release and aren't reported. The built-in set covers version bumps and housekeeping (`bump`, `nbdev regen`, `.gitignore`, `docs`, `CI`, ...: `DEFAULT_SKIP` in `fastws.releases`); `[tool.fastws]` in the workspace root `pyproject.toml` adds to it, and names repos that should never be swept (apps that deploy rather than release):
|
|
117
|
+
|
|
118
|
+
```toml
|
|
119
|
+
[tool.fastws]
|
|
120
|
+
release_skip = ["docs only"]
|
|
121
|
+
release_exclude = ["solveit", "md_site"]
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
From Python (the primary interface - the CLI is a thin wrapper over `fastws.releases`):
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from fastws import check_releases, check_release
|
|
128
|
+
await check_releases() # ReleaseReport: the repr is the report
|
|
129
|
+
await check_releases('solveit') # dependency-closure mode
|
|
130
|
+
await check_release('mdhtml') # one repo: list of unreleased commit summaries (None = no releases)
|
|
131
|
+
```
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
"Fast workspace tools for multi-repo management."
|
|
2
2
|
|
|
3
|
-
__all__ = ["ws_clone", "
|
|
4
|
-
"ws_sync", "ws_sync_cli", "ws_add", "ws_add_cli"]
|
|
3
|
+
__all__ = ["ws_clone", "ws_pull", "ws_status", "ws_branches", "ws_sync", "ws_add", "ws_remove"]
|
|
5
4
|
|
|
6
|
-
import ast, fnmatch, json, os, re, shutil, subprocess
|
|
5
|
+
import ast, fnmatch, json, os, re, shutil, subprocess, time
|
|
7
6
|
from pathlib import Path
|
|
8
7
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
9
8
|
|
|
@@ -96,6 +95,31 @@ def _update_repos_file(repos_path: Path, repos: list[str]) -> list[str]:
|
|
|
96
95
|
repos_path.write_text(content + "\n".join(missing) + "\n")
|
|
97
96
|
return missing
|
|
98
97
|
|
|
98
|
+
def _remove_from_repos_file(repos_path: Path, repo: str) -> bool:
|
|
99
|
+
if not repos_path.exists(): return False
|
|
100
|
+
key = _repo_key(repo)
|
|
101
|
+
lines = repos_path.read_text().splitlines()
|
|
102
|
+
kept = [l for l in lines if l.startswith("#") or not l.strip() or _repo_key(l) != key]
|
|
103
|
+
if len(kept) == len(lines): return False
|
|
104
|
+
repos_path.write_text("\n".join(kept) + ("\n" if kept else ""))
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
def _remove_from_pyproject(pyproject_path: Path, names: list[str]) -> list[str]:
|
|
108
|
+
if not pyproject_path.exists(): return []
|
|
109
|
+
content = pyproject_path.read_text()
|
|
110
|
+
data = tomllib.loads(content)
|
|
111
|
+
sources = dict(data.get("tool", {}).get("uv", {}).get("sources", {}))
|
|
112
|
+
deps = list(data.get("project", {}).get("dependencies", []))
|
|
113
|
+
targets = {_pkg_key(n) for n in names}
|
|
114
|
+
removed = sorted({_pkg_key(s) for s in sources if _pkg_key(s) in targets} | {_dep_key(d) for d in deps if _dep_key(d) in targets})
|
|
115
|
+
if not removed: return []
|
|
116
|
+
sources = {k:v for k,v in sources.items() if _pkg_key(k) not in targets}
|
|
117
|
+
deps = [d for d in deps if _dep_key(d) not in targets]
|
|
118
|
+
content = _replace_table(content, "tool.uv.sources", "\n".join(f"{k} = {{ workspace = true }}" for k in sources))
|
|
119
|
+
content = _replace_project_dependencies(content, deps)
|
|
120
|
+
pyproject_path.write_text(content)
|
|
121
|
+
return removed
|
|
122
|
+
|
|
99
123
|
def _read_pyproject_name(path: Path) -> str|None:
|
|
100
124
|
try: data = tomllib.loads(path.read_text())
|
|
101
125
|
except tomllib.TOMLDecodeError:
|
|
@@ -207,7 +231,7 @@ def _write_pyright_config(root: Path):
|
|
|
207
231
|
|
|
208
232
|
def _clone_one(repo: str, root: str = ".") -> str:
|
|
209
233
|
d = Path(root)/_repo_dir(repo)
|
|
210
|
-
if d.exists(): return
|
|
234
|
+
if d.exists(): return
|
|
211
235
|
try:
|
|
212
236
|
subprocess.run(["git", "clone", f"git@github.com:{repo}.git", str(d)], check=True, capture_output=True)
|
|
213
237
|
return f"✓ {d.name}: cloned"
|
|
@@ -225,6 +249,7 @@ def _pull(repos: list[str], workers: int = 16, root: str = "."):
|
|
|
225
249
|
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
226
250
|
for result in as_completed([ex.submit(_pull_one, r, root) for r in repos]): print(result.result())
|
|
227
251
|
|
|
252
|
+
@call_parse
|
|
228
253
|
def ws_clone(
|
|
229
254
|
repos_file: str = "repos.txt", # File containing repo list (one per line: owner/repo)
|
|
230
255
|
workers: int = 16, # Number of parallel workers
|
|
@@ -232,16 +257,10 @@ def ws_clone(
|
|
|
232
257
|
"Clone all repos from a repos file."
|
|
233
258
|
repos = _load_repos(repos_file)
|
|
234
259
|
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
235
|
-
for result in as_completed([ex.submit(_clone_one, r) for r in repos]):
|
|
260
|
+
for result in as_completed([ex.submit(_clone_one, r) for r in repos]):
|
|
261
|
+
if result.result(): print(result.result())
|
|
236
262
|
|
|
237
263
|
@call_parse
|
|
238
|
-
def ws_clone_cli(
|
|
239
|
-
repos_file: str = "repos.txt", # File containing repo list (one per line: owner/repo)
|
|
240
|
-
workers: int = 16, # Number of parallel workers
|
|
241
|
-
):
|
|
242
|
-
"Clone all repos from a repos file."
|
|
243
|
-
ws_clone(repos_file, workers)
|
|
244
|
-
|
|
245
264
|
def ws_pull(
|
|
246
265
|
repos_file: str = "repos.txt", # File containing repo list
|
|
247
266
|
workers: int = 16, # Number of parallel workers
|
|
@@ -250,13 +269,6 @@ def ws_pull(
|
|
|
250
269
|
_pull(_load_repos(repos_file), workers)
|
|
251
270
|
|
|
252
271
|
@call_parse
|
|
253
|
-
def ws_pull_cli(
|
|
254
|
-
repos_file: str = "repos.txt", # File containing repo list
|
|
255
|
-
workers: int = 16, # Number of parallel workers
|
|
256
|
-
):
|
|
257
|
-
"Pull updates for all repos."
|
|
258
|
-
ws_pull(repos_file, workers)
|
|
259
|
-
|
|
260
272
|
def ws_status(
|
|
261
273
|
repos_file: str = "repos.txt", # File containing repo list
|
|
262
274
|
branches: bool = False, # Show unpushed commit details
|
|
@@ -269,27 +281,17 @@ def ws_status(
|
|
|
269
281
|
g = Git(d)
|
|
270
282
|
if not g.exists: continue
|
|
271
283
|
changes = g.status('-s') or ""
|
|
272
|
-
if isinstance(changes, list): changes = "\n".join(changes)
|
|
273
284
|
unpushed = ""
|
|
274
285
|
try:
|
|
275
286
|
if branches: unpushed = g.log('--branches', '--not', '--remotes', format='%h %s') or ""
|
|
276
|
-
elif (branch := g.branch(show_current=True).strip()) in ('main', 'master'):
|
|
277
|
-
unpushed = g.log('@{upstream}..HEAD', '-1', format='%h %s') or ""
|
|
287
|
+
elif (branch := g.branch(show_current=True).strip()) in ('main', 'master'): unpushed = g.log('@{upstream}..HEAD', '-1', format='%h %s') or ""
|
|
278
288
|
except Exception: pass
|
|
279
|
-
if isinstance(unpushed, list): unpushed = "\n".join(unpushed)
|
|
280
289
|
if changes or unpushed:
|
|
281
290
|
print(f"\n=== {d} ===")
|
|
282
291
|
if changes: print(changes)
|
|
283
292
|
if unpushed: print(unpushed if branches else "unpushed commits")
|
|
284
293
|
|
|
285
294
|
@call_parse
|
|
286
|
-
def ws_status_cli(
|
|
287
|
-
repos_file: str = "repos.txt", # File containing repo list
|
|
288
|
-
branches: bool = False, # Show unpushed commit details
|
|
289
|
-
):
|
|
290
|
-
"Show uncommitted changes and optionally unpushed commit details across repos."
|
|
291
|
-
ws_status(repos_file, branches)
|
|
292
|
-
|
|
293
295
|
def ws_branches(
|
|
294
296
|
repos_file: str = "repos.txt", # File containing repo list
|
|
295
297
|
expected: str = "main", # Expected branch name
|
|
@@ -308,21 +310,41 @@ def ws_branches(
|
|
|
308
310
|
branch = g.branch(show_current=True).strip()
|
|
309
311
|
print(f"✓ {d}: OK (on {expected})" if branch == expected else f"⚠️ {d}: WARNING (on {branch})")
|
|
310
312
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
313
|
+
def _upgrade_stamp(root: Path) -> Path:
|
|
314
|
+
"Stamp whose mtime records the last dependency upgrade; kept inside .git (never tracked) when the root is a git repo."
|
|
315
|
+
gitdir = root/".git"
|
|
316
|
+
return gitdir/"fastws-upgraded" if gitdir.is_dir() else root/".fastws-upgraded"
|
|
317
|
+
|
|
318
|
+
def _should_upgrade(root: Path, max_age: float = 86400) -> bool:
|
|
319
|
+
"True when this workspace hasn't upgraded dependencies within `max_age` seconds."
|
|
320
|
+
stamp = _upgrade_stamp(root)
|
|
321
|
+
return not stamp.exists() or time.time() - stamp.stat().st_mtime > max_age
|
|
322
|
+
|
|
323
|
+
def _cargo_update(root: Path, workers: int = 16):
|
|
324
|
+
"Float each member crate's Cargo.lock to latest matching versions, in parallel; prints what moved."
|
|
325
|
+
crates = [d for d in _ws_dirs(root) if (d/"Cargo.toml").exists()]
|
|
326
|
+
if not crates: return
|
|
318
327
|
|
|
328
|
+
def upd(d):
|
|
329
|
+
res = subprocess.run(["cargo", "update"], cwd=d, capture_output=True, text=True)
|
|
330
|
+
return d.name, res.returncode, (res.stdout or "") + (res.stderr or "")
|
|
331
|
+
|
|
332
|
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
333
|
+
for name, code, out in ex.map(upd, crates):
|
|
334
|
+
if code: print(f"⚠️ {name}: cargo update failed\n{out}")
|
|
335
|
+
elif lines := [l for l in out.splitlines() if l.lstrip().startswith(("Updating ", "Adding ", "Removing "))]:
|
|
336
|
+
print(f"{name}:\n" + "\n".join(lines))
|
|
337
|
+
|
|
338
|
+
@call_parse
|
|
319
339
|
def ws_sync(
|
|
320
340
|
workspace: str = "", # Workspace root; defaults to active venv parent when available
|
|
321
341
|
repos_file: str = "repos.txt", # Repo list to update from local git remotes
|
|
322
342
|
pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
|
|
323
343
|
template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
|
|
344
|
+
workers: int = 16, # Number of parallel workers
|
|
345
|
+
upgrade: bool = False, # Force the once-daily dependency upgrade pass
|
|
324
346
|
):
|
|
325
|
-
"Sync workspace metadata
|
|
347
|
+
"Sync workspace metadata and run uv sync; at most once per day (or with `upgrade`), float dependencies with uv sync -U plus cargo update in member crates."
|
|
326
348
|
root = _ws_root(workspace, repos_file, pyproject_file, template_file)
|
|
327
349
|
repos_path = _resolve_path(root, repos_file)
|
|
328
350
|
pyproject_path = _resolve_path(root, pyproject_file)
|
|
@@ -330,47 +352,107 @@ def ws_sync(
|
|
|
330
352
|
repos = _discover_ws_repos(root)
|
|
331
353
|
|
|
332
354
|
if missing_repos := _update_repos_file(repos_path, repos): print(f"Added repos: {', '.join(missing_repos)}")
|
|
333
|
-
_pull(repos, root=str(root))
|
|
355
|
+
_pull(repos, workers=workers, root=str(root))
|
|
334
356
|
|
|
335
357
|
if missing_projects := _sync_ws_pyproject(pyproject_path, template_path, _ws_projects(root)): print(f"Added workspace projects: {', '.join(missing_projects)}")
|
|
336
358
|
|
|
337
|
-
|
|
338
|
-
|
|
359
|
+
if bad := [d.name for d in _ws_dirs(root) if not (d/"pyproject.toml").exists()]:
|
|
360
|
+
print(f"⚠️ Skipping uv sync, not Python projects yet (scaffold with e.g. nbdev-new or ship-new, or remove): {', '.join(bad)}")
|
|
361
|
+
return
|
|
362
|
+
up = upgrade or _should_upgrade(root)
|
|
363
|
+
subprocess.run(["uv", "sync", "-U"] if up else ["uv", "sync"], check=True, cwd=root)
|
|
364
|
+
if up:
|
|
365
|
+
_cargo_update(root, workers=workers)
|
|
366
|
+
_upgrade_stamp(root).touch()
|
|
339
367
|
|
|
340
368
|
@call_parse
|
|
341
|
-
def ws_sync_cli(
|
|
342
|
-
workspace: str = "", # Workspace root; defaults to active venv parent when available
|
|
343
|
-
repos_file: str = "repos.txt", # Repo list to update from local git remotes
|
|
344
|
-
pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
|
|
345
|
-
template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
|
|
346
|
-
):
|
|
347
|
-
"Sync workspace metadata, run uv sync -U, and update Pyright config."
|
|
348
|
-
ws_sync(workspace, repos_file, pyproject_file, template_file)
|
|
349
|
-
|
|
350
369
|
def ws_add(
|
|
351
|
-
repo: str, # Repo to add,
|
|
370
|
+
repo: str, # Repo to add (owner/repo to clone), or an existing local folder name
|
|
352
371
|
workspace: str = "", # Workspace root; defaults to active venv parent when available
|
|
353
372
|
repos_file: str = "repos.txt", # Repo list to update
|
|
354
373
|
pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
|
|
355
374
|
template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
|
|
356
375
|
):
|
|
357
|
-
"Add a repo to repos.txt and then run ws-sync."
|
|
376
|
+
"Add a repo to repos.txt (a local folder resolves via its origin remote) and then run ws-sync."
|
|
358
377
|
root = _ws_root(workspace, repos_file, pyproject_file, template_file)
|
|
359
378
|
repos_path = _resolve_path(root, repos_file)
|
|
360
|
-
repo =
|
|
379
|
+
repo, local = _resolve_add_target(root, repo)
|
|
361
380
|
added = _update_repos_file(repos_path, [repo])
|
|
362
381
|
if added: print(f"Added repo: {repo}")
|
|
363
382
|
else: print(f"Repo already present: {repo}")
|
|
364
|
-
print(_clone_one(repo, str(root)))
|
|
383
|
+
if not local: print(_clone_one(repo, str(root)))
|
|
365
384
|
ws_sync(str(root), repos_file, pyproject_file, template_file)
|
|
366
385
|
|
|
386
|
+
def _is_repo_spec(repo: str) -> bool:
|
|
387
|
+
repo = repo.strip().rstrip("/").removesuffix(".git")
|
|
388
|
+
return bool(_parse_github_repo(repo) or re.fullmatch(r"[^/\s]+/[^/\s]+", repo))
|
|
389
|
+
|
|
390
|
+
def _resolve_add_target(root: Path, repo: str) -> tuple[str, bool]:
|
|
391
|
+
"Canonical owner/repo for `repo` and whether it named a local folder, which must be a git repo with a GitHub origin and a pyproject.toml"
|
|
392
|
+
if _is_repo_spec(repo): return _normalize_repo(repo), False
|
|
393
|
+
d = root/repo
|
|
394
|
+
if not d.is_dir(): return _normalize_repo(repo), False # invalid spec and no such folder: raise the standard error
|
|
395
|
+
if not (d/".git").exists(): raise SystemExit(f"{d.name} is not a git repository: `git init` it and create a remote, e.g. `gh repo create <owner>/{d.name} --source={d} --push`")
|
|
396
|
+
res = subprocess.run(["git", "-C", str(d), "remote", "get-url", "origin"], capture_output=True, text=True)
|
|
397
|
+
if res.returncode != 0 or not (parsed := _parse_github_repo(res.stdout)):
|
|
398
|
+
raise SystemExit(f"{d.name} has no GitHub 'origin' remote: create one, e.g. `gh repo create <owner>/{d.name} --source={d} --push`")
|
|
399
|
+
if not (d/"pyproject.toml").exists():
|
|
400
|
+
raise SystemExit(f"{d.name} has no pyproject.toml: scaffold it first (e.g. `nbdev-new` or `ship-new`), then re-run ws-add")
|
|
401
|
+
return parsed, True
|
|
402
|
+
|
|
403
|
+
def _resolve_removal_target(root: Path, repo: str, repos_path: Path) -> str:
|
|
404
|
+
"Canonical owner/repo for `repo`, matching an existing folder name when `repo` isn't a valid spec."
|
|
405
|
+
if _is_repo_spec(repo): return _normalize_repo(repo)
|
|
406
|
+
if not (root/repo).is_dir(): return _normalize_repo(repo) # invalid spec and no such folder: raise
|
|
407
|
+
for r in (_load_repos(repos_path) if repos_path.exists() else []):
|
|
408
|
+
if _repo_dir(r).casefold() == repo.casefold(): return r
|
|
409
|
+
if (root/repo/".git").exists():
|
|
410
|
+
res = subprocess.run(["git", "-C", str(root/repo), "remote", "get-url", "origin"], capture_output=True, text=True)
|
|
411
|
+
if res.returncode == 0 and (parsed := _parse_github_repo(res.stdout)): return parsed
|
|
412
|
+
return repo
|
|
413
|
+
|
|
414
|
+
def _resolve_repo_dir(root: Path, repo: str) -> Path:
|
|
415
|
+
root = root.resolve()
|
|
416
|
+
d = root/_repo_dir(repo)
|
|
417
|
+
if d.is_symlink(): raise SystemExit(f"Refusing to remove {d}: it is a symlink")
|
|
418
|
+
if (rd := d.resolve()) == root or rd.parent != root: raise SystemExit(f"Refusing to remove {d}: not directly inside {root}")
|
|
419
|
+
return d
|
|
420
|
+
|
|
421
|
+
def _git_out(d: Path, *args: str) -> str:
|
|
422
|
+
return subprocess.run(["git", "-C", str(d), *args], check=True, capture_output=True, text=True).stdout
|
|
423
|
+
|
|
424
|
+
def _repo_safety_issues(d: Path) -> list[str]:
|
|
425
|
+
if not (d/".git").exists(): return [f"{d.name} is not a git repository"]
|
|
426
|
+
issues = []
|
|
427
|
+
if subprocess.run(["git", "-C", str(d), "remote", "get-url", "origin"], capture_output=True, text=True).returncode != 0:
|
|
428
|
+
issues.append(f"{d.name} has no 'origin' remote")
|
|
429
|
+
if _git_out(d, "status", "--porcelain").strip(): issues.append(f"{d.name} has uncommitted changes")
|
|
430
|
+
if _git_out(d, "log", "--branches", "--not", "--remotes", "--format=%h").strip(): issues.append(f"{d.name} has unpushed commits")
|
|
431
|
+
return issues
|
|
432
|
+
|
|
367
433
|
@call_parse
|
|
368
|
-
def
|
|
369
|
-
repo: str, # Repo to
|
|
434
|
+
def ws_remove(
|
|
435
|
+
repo: str, # Repo to remove, e.g. AnswerDotAI/fastws
|
|
370
436
|
workspace: str = "", # Workspace root; defaults to active venv parent when available
|
|
371
437
|
repos_file: str = "repos.txt", # Repo list to update
|
|
372
438
|
pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
|
|
373
439
|
template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
|
|
374
440
|
):
|
|
375
|
-
"
|
|
376
|
-
|
|
441
|
+
"Remove a repo: delete its clone and drop it from repos.txt and the workspace pyproject."
|
|
442
|
+
root = _ws_root(workspace, repos_file, pyproject_file, template_file)
|
|
443
|
+
repos_path = _resolve_path(root, repos_file)
|
|
444
|
+
pyproject_path = _resolve_path(root, pyproject_file)
|
|
445
|
+
repo = _resolve_removal_target(root, repo, repos_path)
|
|
446
|
+
d = _resolve_repo_dir(root, repo)
|
|
447
|
+
name = _read_pyproject_name(d/"pyproject.toml") if (d/"pyproject.toml").exists() else None
|
|
448
|
+
names = [name] if name else [d.name]
|
|
449
|
+
in_repos = _repo_key(repo) in {_repo_key(r) for r in (_load_repos(repos_path) if repos_path.exists() else [])}
|
|
450
|
+
if not d.exists() and not in_repos: raise SystemExit(f"Nothing to remove for {repo}")
|
|
451
|
+
if d.exists() and (issues := _repo_safety_issues(d)): raise SystemExit("Refusing to remove:\n" + "\n".join(f" - {i}" for i in issues))
|
|
452
|
+
_remove_from_repos_file(repos_path, repo)
|
|
453
|
+
_remove_from_pyproject(pyproject_path, names)
|
|
454
|
+
if d.exists():
|
|
455
|
+
try: ans = input(f"Remove directory {d}? [y/N] ")
|
|
456
|
+
except EOFError: ans = ""
|
|
457
|
+
if ans.strip().lower() in ("y", "yes"): shutil.rmtree(d)
|
|
458
|
+
subprocess.run(["uv", "sync"], check=True, cwd=root)
|