fastws-cli 0.0.6__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.
@@ -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
+ ```
@@ -0,0 +1,7 @@
1
+ __version__ = "0.0.8"
2
+
3
+
4
+
5
+ from .core import *
6
+ from .releases import *
7
+
@@ -1,9 +1,8 @@
1
1
  "Fast workspace tools for multi-repo management."
2
2
 
3
- __all__ = ["ws_clone", "ws_clone_cli", "ws_pull", "ws_pull_cli", "ws_status", "ws_status_cli", "ws_branches", "ws_branches_cli",
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, 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:
@@ -193,22 +217,21 @@ def _site_packages(root: Path) -> Path|None:
193
217
  if candidates: return candidates[0]
194
218
  return None
195
219
 
196
- def _write_pyright_pth_files(root: Path) -> list[Path]:
220
+ def _write_pyright_config(root: Path):
197
221
  site = _site_packages(root)
198
222
  if not site:
199
223
  print("No site-packages directory found for editable Pyright paths")
200
- return []
201
- created = []
202
- for finder in sorted(site.glob("__editable__*_finder.py")):
203
- for pkg,path in _editable_mapping(finder).items():
204
- pth = site/f"_pyright_editable_{pkg}.pth"
205
- pth.write_text(str(Path(path).parent) + "\n")
206
- created.append(pth)
207
- return created
224
+ return
225
+ paths = sorted({str(Path(p).parent) for f in site.glob("__editable__*_finder.py") for p in _editable_mapping(f).values()})
226
+ cfg_path = root / 'pyrightconfig.json'
227
+ cfg = json.loads(cfg_path.read_text()) if cfg_path.exists() else {}
228
+ cfg['extraPaths'] = paths
229
+ cfg_path.write_text(json.dumps(cfg, indent=2) + '\n')
230
+ for pth in site.glob('_pyright_editable_*.pth'): pth.unlink()
208
231
 
209
232
  def _clone_one(repo: str, root: str = ".") -> str:
210
233
  d = Path(root)/_repo_dir(repo)
211
- if d.exists(): return f"✓ {d.name}: already exists"
234
+ if d.exists(): return
212
235
  try:
213
236
  subprocess.run(["git", "clone", f"git@github.com:{repo}.git", str(d)], check=True, capture_output=True)
214
237
  return f"✓ {d.name}: cloned"
@@ -226,6 +249,7 @@ def _pull(repos: list[str], workers: int = 16, root: str = "."):
226
249
  with ThreadPoolExecutor(max_workers=workers) as ex:
227
250
  for result in as_completed([ex.submit(_pull_one, r, root) for r in repos]): print(result.result())
228
251
 
252
+ @call_parse
229
253
  def ws_clone(
230
254
  repos_file: str = "repos.txt", # File containing repo list (one per line: owner/repo)
231
255
  workers: int = 16, # Number of parallel workers
@@ -233,16 +257,10 @@ def ws_clone(
233
257
  "Clone all repos from a repos file."
234
258
  repos = _load_repos(repos_file)
235
259
  with ThreadPoolExecutor(max_workers=workers) as ex:
236
- for result in as_completed([ex.submit(_clone_one, r) for r in repos]): print(result.result())
260
+ for result in as_completed([ex.submit(_clone_one, r) for r in repos]):
261
+ if result.result(): print(result.result())
237
262
 
238
263
  @call_parse
239
- def ws_clone_cli(
240
- repos_file: str = "repos.txt", # File containing repo list (one per line: owner/repo)
241
- workers: int = 16, # Number of parallel workers
242
- ):
243
- "Clone all repos from a repos file."
244
- ws_clone(repos_file, workers)
245
-
246
264
  def ws_pull(
247
265
  repos_file: str = "repos.txt", # File containing repo list
248
266
  workers: int = 16, # Number of parallel workers
@@ -251,13 +269,6 @@ def ws_pull(
251
269
  _pull(_load_repos(repos_file), workers)
252
270
 
253
271
  @call_parse
254
- def ws_pull_cli(
255
- repos_file: str = "repos.txt", # File containing repo list
256
- workers: int = 16, # Number of parallel workers
257
- ):
258
- "Pull updates for all repos."
259
- ws_pull(repos_file, workers)
260
-
261
272
  def ws_status(
262
273
  repos_file: str = "repos.txt", # File containing repo list
263
274
  branches: bool = False, # Show unpushed commit details
@@ -270,27 +281,17 @@ def ws_status(
270
281
  g = Git(d)
271
282
  if not g.exists: continue
272
283
  changes = g.status('-s') or ""
273
- if isinstance(changes, list): changes = "\n".join(changes)
274
284
  unpushed = ""
275
285
  try:
276
286
  if branches: unpushed = g.log('--branches', '--not', '--remotes', format='%h %s') or ""
277
- elif (branch := g.branch(show_current=True).strip()) in ('main', 'master'):
278
- 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 ""
279
288
  except Exception: pass
280
- if isinstance(unpushed, list): unpushed = "\n".join(unpushed)
281
289
  if changes or unpushed:
282
290
  print(f"\n=== {d} ===")
283
291
  if changes: print(changes)
284
292
  if unpushed: print(unpushed if branches else "unpushed commits")
285
293
 
286
294
  @call_parse
287
- def ws_status_cli(
288
- repos_file: str = "repos.txt", # File containing repo list
289
- branches: bool = False, # Show unpushed commit details
290
- ):
291
- "Show uncommitted changes and optionally unpushed commit details across repos."
292
- ws_status(repos_file, branches)
293
-
294
295
  def ws_branches(
295
296
  repos_file: str = "repos.txt", # File containing repo list
296
297
  expected: str = "main", # Expected branch name
@@ -309,21 +310,41 @@ def ws_branches(
309
310
  branch = g.branch(show_current=True).strip()
310
311
  print(f"✓ {d}: OK (on {expected})" if branch == expected else f"⚠️ {d}: WARNING (on {branch})")
311
312
 
312
- @call_parse
313
- def ws_branches_cli(
314
- repos_file: str = "repos.txt", # File containing repo list
315
- expected: str = "main", # Expected branch name
316
- ):
317
- "Check if all repos are on the expected branch."
318
- ws_branches(repos_file, expected)
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
319
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
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
320
339
  def ws_sync(
321
340
  workspace: str = "", # Workspace root; defaults to active venv parent when available
322
341
  repos_file: str = "repos.txt", # Repo list to update from local git remotes
323
342
  pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
324
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
325
346
  ):
326
- "Sync workspace metadata, run uv sync -U, and refresh Pyright editable paths."
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."
327
348
  root = _ws_root(workspace, repos_file, pyproject_file, template_file)
328
349
  repos_path = _resolve_path(root, repos_file)
329
350
  pyproject_path = _resolve_path(root, pyproject_file)
@@ -331,47 +352,107 @@ def ws_sync(
331
352
  repos = _discover_ws_repos(root)
332
353
 
333
354
  if missing_repos := _update_repos_file(repos_path, repos): print(f"Added repos: {', '.join(missing_repos)}")
334
- _pull(repos, root=str(root))
355
+ _pull(repos, workers=workers, root=str(root))
335
356
 
336
357
  if missing_projects := _sync_ws_pyproject(pyproject_path, template_path, _ws_projects(root)): print(f"Added workspace projects: {', '.join(missing_projects)}")
337
358
 
338
- subprocess.run(["uv", "sync", "-U"], check=True, cwd=root)
339
- _write_pyright_pth_files(root)
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()
340
367
 
341
368
  @call_parse
342
- def ws_sync_cli(
343
- workspace: str = "", # Workspace root; defaults to active venv parent when available
344
- repos_file: str = "repos.txt", # Repo list to update from local git remotes
345
- pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
346
- template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
347
- ):
348
- "Sync workspace metadata, run uv sync -U, and refresh Pyright editable paths."
349
- ws_sync(workspace, repos_file, pyproject_file, template_file)
350
-
351
369
  def ws_add(
352
- repo: str, # Repo to add, e.g. AnswerDotAI/fastws
370
+ repo: str, # Repo to add (owner/repo to clone), or an existing local folder name
353
371
  workspace: str = "", # Workspace root; defaults to active venv parent when available
354
372
  repos_file: str = "repos.txt", # Repo list to update
355
373
  pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
356
374
  template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
357
375
  ):
358
- "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."
359
377
  root = _ws_root(workspace, repos_file, pyproject_file, template_file)
360
378
  repos_path = _resolve_path(root, repos_file)
361
- repo = _normalize_repo(repo)
379
+ repo, local = _resolve_add_target(root, repo)
362
380
  added = _update_repos_file(repos_path, [repo])
363
381
  if added: print(f"Added repo: {repo}")
364
382
  else: print(f"Repo already present: {repo}")
365
- print(_clone_one(repo, str(root)))
383
+ if not local: print(_clone_one(repo, str(root)))
366
384
  ws_sync(str(root), repos_file, pyproject_file, template_file)
367
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
+
368
433
  @call_parse
369
- def ws_add_cli(
370
- repo: str, # Repo to add, e.g. AnswerDotAI/fastws
434
+ def ws_remove(
435
+ repo: str, # Repo to remove, e.g. AnswerDotAI/fastws
371
436
  workspace: str = "", # Workspace root; defaults to active venv parent when available
372
437
  repos_file: str = "repos.txt", # Repo list to update
373
438
  pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
374
439
  template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
375
440
  ):
376
- "Add a repo to repos.txt and then run ws-sync."
377
- ws_add(repo, workspace, repos_file, pyproject_file, template_file)
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)