fastws-cli 0.0.14__tar.gz → 0.0.16__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastws-cli
3
- Version: 0.0.14
3
+ Version: 0.0.16
4
4
  Summary: Fast workspace tools for multi-repo management
5
5
  Author: Jeremy Howard
6
6
  License: Apache-2.0
@@ -82,17 +82,31 @@ AnswerDotAI/fastcore
82
82
  jph00/private ~/private
83
83
  ```
84
84
 
85
+ Select Python extras by attaching brackets to the repo name, before any checkout location:
86
+
87
+ ```text
88
+ AnswerDotAI/fastws[dev]
89
+ owner/repo[dev,test] ~/somewhere
90
+ ```
91
+
92
+ These select `[project.optional-dependencies]`, not `[dependency-groups]`. Sync uses the package name from `pyproject.toml`, so `AnswerDotAI/fastws[dev]` installs `fastws-cli[dev]` editably. For a checkout containing several discovered Python packages, the selection applies to each. uv handles the extras and their dependencies.
93
+
94
+ Extras from duplicate entries in `repos.txt` and `repos-local.txt` are combined. For example, a personal `AnswerDotAI/fastws[dev]` entry adds development extras to a shared `AnswerDotAI/fastws` entry. The lists are authoritative for those packages' extras: each sync updates existing workspace dependencies, including removing extras no longer selected. Put personal extra selections in `repos-local.txt`, not the generated `pyproject.toml`. Existing version constraints, markers, sources, and unrelated dependencies are preserved.
95
+
85
96
  ### Shared baseline and personal additions
86
97
 
87
98
  Keep `repos.txt` tracked in the workspace Git repo. Put personal additions in a gitignored `repos-local.txt`, using the same format. Every command reads both lists. `ws-add` and automatic discovery append only to the local list. Edit the shared list deliberately when changing the team's baseline. Duplicate repo names are matched case-insensitively. Conflicting explicit locations, or two different repos targeting the same directory, cause errors.
88
99
 
89
100
  `--repos-file myrepos.txt` uses `myrepos-local.txt` alongside it. Either list may be absent. Removing a repo from the baseline does not delete its checkout. The next sync discovers an existing root checkout as a personal addition. External checkouts also remain on disk; add them to the local list to keep managing them.
90
101
 
91
- The workspace `pyproject.toml` is local generated state. `ws-sync` creates it from the tracked `pyproject.tmpl` when available, or generates a minimal one, then maintains it without replacing personal settings. The template supplies starting defaults only: later template edits do not overwrite existing configuration. Ignore these files in the workspace repo (not in its member repos):
102
+ The workspace `pyproject.toml` is local generated state. `ws-sync` creates it from the tracked `pyproject.tmpl` when available, or generates a minimal one, then maintains it while preserving personal settings except for the repo extras managed above. The template supplies starting defaults only: later template edits do not overwrite existing configuration. Ignore these files in the workspace repo (not in its member repos):
92
103
 
93
104
  ```gitignore
94
105
  /repos-local.txt
95
106
  /pyproject.toml
107
+ /package.json
108
+ /package-lock.json
109
+ /node_modules/
96
110
  /pyrightconfig.json
97
111
  /uv.lock
98
112
  /.cargo/
@@ -194,7 +208,7 @@ By default, `ws-sync` uses the active venv's parent as the workspace root. It cr
194
208
 
195
209
  Project scanning respects `tool.uv.workspace.members` and `exclude`. If a member directory lacks `pyproject.toml`, such as a fresh empty clone, sync warns and skips the `uv sync` step.
196
210
 
197
- The workspace `exclude` list is maintained automatically. An unlisted top-level directory that is not a valid Python project is excluded until it has a `pyproject.toml`. A `repos.txt` checkout is automatically excluded only when it is a Cargo-only Rust crate. A listed checkout with neither `pyproject.toml` nor `Cargo.toml` is treated as a pending member and triggers the warning above.
211
+ Fastws maintains `tool.uv.workspace.exclude`. It excludes unlisted top-level directories without a valid Python project. It also excludes listed Rust or JavaScript projects without `pyproject.toml`. For a listed checkout with none of `pyproject.toml`, `Cargo.toml`, or `package.json`, sync warns and skips installation.
198
212
 
199
213
  Existing globs, entries for missing directories, and entries for checkouts that are still not Python projects are retained. Use `exclude = [...]` under `[tool.fastws]` to specify exclusions the scan cannot infer, such as keeping a valid project out of the workspace. Adding members preserves hand-written `[tool.uv.sources]` entries, including path and Git sources.
200
214
 
@@ -204,6 +218,20 @@ Do not commit a `Cargo.lock` generated under these patches. Its source-less loca
204
218
 
205
219
  When `sccache` is installed, sync also configures it as Cargo's `rustc-wrapper`, allowing unchanged compilation units to be reused across the workspace's otherwise-independent Cargo target directories. An existing wrapper is always preserved.
206
220
 
221
+ Fastws discovers JavaScript packages directly under the workspace root. It reads each package's `workspaces` list for declared nested packages. These lists accept paths and globs. Fastws ignores undeclared nested packages. Lockfiles do not affect discovery. `[tool.fastws].exclude` matches paths relative to the workspace root. Fastws skips `node_modules` and directories whose names start with `.` or `_`.
222
+
223
+ Each sync updates `workspaces` in the root `package.json`. It creates the file when needed. It preserves external paths, globs, and unrelated settings. It reports added and removed packages.
224
+
225
+ An optional tracked `package.json.shared` is merged into the root `package.json` on every sync. Object-valued fields merge one level deep, shared values win conflicts, and arrays replace rather than concatenate. Unrelated local settings remain; removing a shared key does not delete its local value. Workspace discovery still manages `workspaces`. Use this file for shared policy such as version-pinned npm `allowScripts` approvals.
226
+
227
+ After `uv sync`, fastws runs `<tool> install` at the workspace root. It then runs `<tool> run build` in each JavaScript package containing both `Cargo.toml` and a `build` script. Cargo handles incremental compilation. Other build steps still run.
228
+
229
+ For npm, sync hides routine install summaries and build-command banners. Install warnings, audit findings, unreviewed install scripts, and failure diagnostics remain visible. Build scripts retain their own output; configure their tools to suppress routine progress. Other package managers retain their normal output.
230
+
231
+ When Rust-backed JavaScript builds are present, fastws checks before dependency installation that `rustc` and `cargo` on `PATH` are rustup's proxies. Install rustup from https://rustup.rs and select a stable toolchain with `rustup default stable` if not already configured. If another Rust installation shadows rustup, put its bin directory first on `PATH` after Homebrew's shell setup: `export PATH="$HOME/.cargo/bin:$PATH"` (use `$CARGO_HOME/bin` for a custom Cargo home), then open a new shell. Homebrew Rust can remain installed for Homebrew's own use. Fastws reports missing or shadowed rustup without changing your shell or installing a different toolchain. Packages using wasm-pack can then install their WASM target and matching binding tools automatically as part of their build.
232
+
233
+ The package manager defaults to `npm`. Set `js` under `[tool.fastws]` in the workspace `pyproject.toml` to choose another executable, such as `js = "bun"`. Fastws requires a list for `workspaces` in `package.json`, following the npm format. It stops if it cannot find the selected executable.
234
+
207
235
  At most once per day, sync upgrades dependencies with `uv sync -U` and a parallel `cargo update` in every member with a `Cargo.toml`. It prints the changes and records the run in a stamp file inside the workspace's `.git` directory. Pass `--upgrade` to force an upgrade regardless of the last run time.
208
236
 
209
237
  Before every uv sync, `ws-sync` writes `.git/fastws-cargo-key` for each member crate. The key hashes `Cargo.lock` contents and the workspace Cargo patch configuration. For Git dependencies redirected to local paths by `[patch."<url>"]`, it also hashes each patched crate's `Cargo.toml`, `build.rs`, and `src` tree, recursively. The file is rewritten only when that content changes, so projects can use `{ file = ".git/fastws-cargo-key" }` in `tool.uv.cache-keys` without rebuilding after a timestamp-only `Cargo.lock` write.
@@ -57,17 +57,31 @@ AnswerDotAI/fastcore
57
57
  jph00/private ~/private
58
58
  ```
59
59
 
60
+ Select Python extras by attaching brackets to the repo name, before any checkout location:
61
+
62
+ ```text
63
+ AnswerDotAI/fastws[dev]
64
+ owner/repo[dev,test] ~/somewhere
65
+ ```
66
+
67
+ These select `[project.optional-dependencies]`, not `[dependency-groups]`. Sync uses the package name from `pyproject.toml`, so `AnswerDotAI/fastws[dev]` installs `fastws-cli[dev]` editably. For a checkout containing several discovered Python packages, the selection applies to each. uv handles the extras and their dependencies.
68
+
69
+ Extras from duplicate entries in `repos.txt` and `repos-local.txt` are combined. For example, a personal `AnswerDotAI/fastws[dev]` entry adds development extras to a shared `AnswerDotAI/fastws` entry. The lists are authoritative for those packages' extras: each sync updates existing workspace dependencies, including removing extras no longer selected. Put personal extra selections in `repos-local.txt`, not the generated `pyproject.toml`. Existing version constraints, markers, sources, and unrelated dependencies are preserved.
70
+
60
71
  ### Shared baseline and personal additions
61
72
 
62
73
  Keep `repos.txt` tracked in the workspace Git repo. Put personal additions in a gitignored `repos-local.txt`, using the same format. Every command reads both lists. `ws-add` and automatic discovery append only to the local list. Edit the shared list deliberately when changing the team's baseline. Duplicate repo names are matched case-insensitively. Conflicting explicit locations, or two different repos targeting the same directory, cause errors.
63
74
 
64
75
  `--repos-file myrepos.txt` uses `myrepos-local.txt` alongside it. Either list may be absent. Removing a repo from the baseline does not delete its checkout. The next sync discovers an existing root checkout as a personal addition. External checkouts also remain on disk; add them to the local list to keep managing them.
65
76
 
66
- The workspace `pyproject.toml` is local generated state. `ws-sync` creates it from the tracked `pyproject.tmpl` when available, or generates a minimal one, then maintains it without replacing personal settings. The template supplies starting defaults only: later template edits do not overwrite existing configuration. Ignore these files in the workspace repo (not in its member repos):
77
+ The workspace `pyproject.toml` is local generated state. `ws-sync` creates it from the tracked `pyproject.tmpl` when available, or generates a minimal one, then maintains it while preserving personal settings except for the repo extras managed above. The template supplies starting defaults only: later template edits do not overwrite existing configuration. Ignore these files in the workspace repo (not in its member repos):
67
78
 
68
79
  ```gitignore
69
80
  /repos-local.txt
70
81
  /pyproject.toml
82
+ /package.json
83
+ /package-lock.json
84
+ /node_modules/
71
85
  /pyrightconfig.json
72
86
  /uv.lock
73
87
  /.cargo/
@@ -169,7 +183,7 @@ By default, `ws-sync` uses the active venv's parent as the workspace root. It cr
169
183
 
170
184
  Project scanning respects `tool.uv.workspace.members` and `exclude`. If a member directory lacks `pyproject.toml`, such as a fresh empty clone, sync warns and skips the `uv sync` step.
171
185
 
172
- The workspace `exclude` list is maintained automatically. An unlisted top-level directory that is not a valid Python project is excluded until it has a `pyproject.toml`. A `repos.txt` checkout is automatically excluded only when it is a Cargo-only Rust crate. A listed checkout with neither `pyproject.toml` nor `Cargo.toml` is treated as a pending member and triggers the warning above.
186
+ Fastws maintains `tool.uv.workspace.exclude`. It excludes unlisted top-level directories without a valid Python project. It also excludes listed Rust or JavaScript projects without `pyproject.toml`. For a listed checkout with none of `pyproject.toml`, `Cargo.toml`, or `package.json`, sync warns and skips installation.
173
187
 
174
188
  Existing globs, entries for missing directories, and entries for checkouts that are still not Python projects are retained. Use `exclude = [...]` under `[tool.fastws]` to specify exclusions the scan cannot infer, such as keeping a valid project out of the workspace. Adding members preserves hand-written `[tool.uv.sources]` entries, including path and Git sources.
175
189
 
@@ -179,6 +193,20 @@ Do not commit a `Cargo.lock` generated under these patches. Its source-less loca
179
193
 
180
194
  When `sccache` is installed, sync also configures it as Cargo's `rustc-wrapper`, allowing unchanged compilation units to be reused across the workspace's otherwise-independent Cargo target directories. An existing wrapper is always preserved.
181
195
 
196
+ Fastws discovers JavaScript packages directly under the workspace root. It reads each package's `workspaces` list for declared nested packages. These lists accept paths and globs. Fastws ignores undeclared nested packages. Lockfiles do not affect discovery. `[tool.fastws].exclude` matches paths relative to the workspace root. Fastws skips `node_modules` and directories whose names start with `.` or `_`.
197
+
198
+ Each sync updates `workspaces` in the root `package.json`. It creates the file when needed. It preserves external paths, globs, and unrelated settings. It reports added and removed packages.
199
+
200
+ An optional tracked `package.json.shared` is merged into the root `package.json` on every sync. Object-valued fields merge one level deep, shared values win conflicts, and arrays replace rather than concatenate. Unrelated local settings remain; removing a shared key does not delete its local value. Workspace discovery still manages `workspaces`. Use this file for shared policy such as version-pinned npm `allowScripts` approvals.
201
+
202
+ After `uv sync`, fastws runs `<tool> install` at the workspace root. It then runs `<tool> run build` in each JavaScript package containing both `Cargo.toml` and a `build` script. Cargo handles incremental compilation. Other build steps still run.
203
+
204
+ For npm, sync hides routine install summaries and build-command banners. Install warnings, audit findings, unreviewed install scripts, and failure diagnostics remain visible. Build scripts retain their own output; configure their tools to suppress routine progress. Other package managers retain their normal output.
205
+
206
+ When Rust-backed JavaScript builds are present, fastws checks before dependency installation that `rustc` and `cargo` on `PATH` are rustup's proxies. Install rustup from https://rustup.rs and select a stable toolchain with `rustup default stable` if not already configured. If another Rust installation shadows rustup, put its bin directory first on `PATH` after Homebrew's shell setup: `export PATH="$HOME/.cargo/bin:$PATH"` (use `$CARGO_HOME/bin` for a custom Cargo home), then open a new shell. Homebrew Rust can remain installed for Homebrew's own use. Fastws reports missing or shadowed rustup without changing your shell or installing a different toolchain. Packages using wasm-pack can then install their WASM target and matching binding tools automatically as part of their build.
207
+
208
+ The package manager defaults to `npm`. Set `js` under `[tool.fastws]` in the workspace `pyproject.toml` to choose another executable, such as `js = "bun"`. Fastws requires a list for `workspaces` in `package.json`, following the npm format. It stops if it cannot find the selected executable.
209
+
182
210
  At most once per day, sync upgrades dependencies with `uv sync -U` and a parallel `cargo update` in every member with a `Cargo.toml`. It prints the changes and records the run in a stamp file inside the workspace's `.git` directory. Pass `--upgrade` to force an upgrade regardless of the last run time.
183
211
 
184
212
  Before every uv sync, `ws-sync` writes `.git/fastws-cargo-key` for each member crate. The key hashes `Cargo.lock` contents and the workspace Cargo patch configuration. For Git dependencies redirected to local paths by `[patch."<url>"]`, it also hashes each patched crate's `Cargo.toml`, `build.rs`, and `src` tree, recursively. The file is rewritten only when that content changes, so projects can use `{ file = ".git/fastws-cargo-key" }` in `tool.uv.cache-keys` without rebuilding after a timestamp-only `Cargo.lock` write.
@@ -1,4 +1,6 @@
1
- __version__ = "0.0.14"
1
+ __version__ = "0.0.16"
2
+
3
+
2
4
 
3
5
 
4
6
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  __all__ = ["ws_setup", "ws_clone", "ws_pull", "ws_status", "ws_branches", "ws_build", "ws_sync", "ws_add", "ws_remove"]
4
4
 
5
- import ast, fnmatch, hashlib, json, os, re, shlex, shutil, subprocess, sys, time
5
+ import ast, fnmatch, glob, hashlib, json, os, re, shlex, shutil, subprocess, sys, time
6
6
  from pathlib import Path
7
7
  from concurrent.futures import ThreadPoolExecutor
8
8
 
@@ -10,6 +10,8 @@ from fastcore.parallel import parallel_async_gen
10
10
  from fastcore.script import call_parse
11
11
  from fastgit import Git
12
12
  from ghapi.core import dep_key, dep_closure
13
+ from packaging.requirements import Requirement
14
+ from packaging.utils import canonicalize_name
13
15
 
14
16
  try: import tomllib
15
17
  except ModuleNotFoundError: import tomli as tomllib
@@ -19,10 +21,12 @@ def _repo_lines(repos_file) -> list[str]:
19
21
  if not p.exists(): raise SystemExit(f"File not found: {repos_file}")
20
22
  return [line.strip() for line in p.read_text().splitlines() if line.strip() and not line.startswith("#")]
21
23
 
22
- def _parse_repo_line(line: str) -> tuple[str, str|None]:
23
- "Split a repos.txt line into its repo spec and optional checkout location"
24
- parts = line.split(None, 1)
25
- return parts[0], (parts[1].strip() or None) if len(parts) > 1 else None
24
+ def _parse_repo_line(line: str) -> tuple[str, str|None, set[str]]:
25
+ "Split owner/repo[extra,...] and an optional checkout location."
26
+ m = re.fullmatch(r'([^\s\[\]]+)(?:\[([^\[\]]+)\])?(?:\s+(.+))?', line.strip())
27
+ if not m: raise SystemExit(f'Invalid repo entry: {line}')
28
+ repo, extras, loc = m.groups()
29
+ return repo, loc, {canonicalize_name(e.strip()) for e in extras.split(',')} if extras else set()
26
30
 
27
31
  def _load_repos(repos_file: str = "repos.txt") -> list[str]:
28
32
  return [_parse_repo_line(l)[0] for l in _repo_lines(repos_file)] if Path(repos_file).exists() else []
@@ -31,21 +35,23 @@ def _local_repos_path(repos_file) -> Path:
31
35
  p = Path(repos_file)
32
36
  return p.with_name(f'{p.stem}-local{p.suffix}')
33
37
 
34
- def _load_repo_entries(repos_file, root: Path) -> list[tuple[str, Path]]:
35
- "Shared and local entries, deduplicated by repo; conflicting checkout locations are errors."
38
+ def _load_repo_entries(repos_file, root: Path) -> list[tuple[str, Path, set[str]]]:
39
+ "Shared and local entries, with extras unioned by repo; conflicting checkout locations are errors."
36
40
  res, locations = {}, {}
37
41
  for p in (Path(repos_file), _local_repos_path(repos_file)):
38
42
  if not p.exists(): continue
39
43
  for line in _repo_lines(p):
40
- repo, loc = _parse_repo_line(line)
44
+ repo, loc, extras = _parse_repo_line(line)
41
45
  key = _repo_key(repo)
42
- if key in res and loc is None: continue
46
+ if key in res:
47
+ res[key][2].update(extras)
48
+ if loc is None: continue
43
49
  d = _resolve_path(root, Path(loc).expanduser()) if loc else root/_repo_dir(repo)
44
50
  resolved = d.resolve()
45
51
  if key in res and res[key][1].resolve() != resolved: raise SystemExit(f'Conflicting locations for {repo} in {p}')
46
52
  if resolved in locations and locations[resolved] != key:
47
53
  raise SystemExit(f'Checkout location {d} is shared by {repo} and {locations[resolved]}')
48
- res.setdefault(key, (repo, d))
54
+ res.setdefault(key, (repo, d, extras))
49
55
  locations[resolved] = key
50
56
  return list(res.values())
51
57
 
@@ -57,7 +63,7 @@ def _resolve_path(root: Path, path: str) -> Path:
57
63
 
58
64
  def _repo_key(repo: str) -> str: return repo.strip().rstrip("/").removesuffix(".git").casefold()
59
65
 
60
- def _pkg_key(name: str) -> str: return name.casefold()
66
+ def _pkg_key(name: str) -> str: return canonicalize_name(name)
61
67
 
62
68
  def _fmt_toml_val(v) -> str:
63
69
  if isinstance(v, bool): return "true" if v else "false"
@@ -98,16 +104,25 @@ def _ws_cfg(root: Path):
98
104
  exclude = ws.get("exclude") or []
99
105
  return members, exclude
100
106
 
107
+ def _fastws_cfg(root: Path) -> dict:
108
+ "The `[tool.fastws]` table from the workspace root pyproject.toml (empty when absent)."
109
+ pyproj = root/"pyproject.toml"
110
+ if not pyproj.exists(): return {}
111
+ return tomllib.loads(pyproj.read_text(encoding="utf-8")).get("tool", {}).get("fastws", {})
112
+
101
113
  def _matches_ws(name: str, pattern: str) -> bool:
102
114
  pattern = pattern.strip()
103
115
  return any(fnmatch.fnmatch(candidate, normalized) for candidate in (name, f"./{name}") for normalized in (pattern, pattern.removeprefix("./")))
104
116
 
105
- def _is_ws_dir(d: Path, members, exclude) -> bool:
106
- return d.is_dir() and not d.name.startswith(".") and any(_matches_ws(d.name, o) for o in members) and not any(_matches_ws(d.name, o) for o in exclude)
117
+ def _project_dirs(root: Path, manifest="", members=("*",), exclude=()) -> list[Path]:
118
+ "Expand member patterns into unique directories, optionally requiring a manifest; exclusions are relative to root."
119
+ dirs = dict.fromkeys(d for pat in members for d in sorted(root.glob(pat)))
120
+ return [d for d in dirs if d.is_dir() and (not manifest or (d/manifest).is_file())
121
+ and not any(_matches_ws(os.path.relpath(d, root), e) for e in exclude)]
107
122
 
108
123
  def _ws_dirs(root: Path) -> list[Path]:
109
124
  members, exclude = _ws_cfg(root)
110
- return [d for d in sorted(root.iterdir()) if _is_ws_dir(d, members, exclude)]
125
+ return [d for d in _project_dirs(root, exclude=exclude) if not d.name.startswith(".") and any(_matches_ws(d.name, o) for o in members)]
111
126
 
112
127
  def _root_git_dirs(root: Path) -> list[Path]:
113
128
  "Every git checkout directly under `root`, whatever the uv workspace config; `_`-prefixed dirs are private"
@@ -152,10 +167,10 @@ def _remove_from_pyproject(pyproject_path: Path, names: list[str]) -> list[str]:
152
167
  sources = dict(data.get("tool", {}).get("uv", {}).get("sources", {}))
153
168
  deps = list(data.get("project", {}).get("dependencies", []))
154
169
  targets = {_pkg_key(n) for n in names}
155
- 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})
170
+ removed = sorted({_pkg_key(s) for s in sources if _pkg_key(s) in targets} | {_pkg_key(dep_key(d)) for d in deps if _pkg_key(dep_key(d)) in targets})
156
171
  if not removed: return []
157
172
  sources = {k:v for k,v in sources.items() if _pkg_key(k) not in targets}
158
- deps = [d for d in deps if dep_key(d) not in targets]
173
+ deps = [d for d in deps if _pkg_key(dep_key(d)) not in targets]
159
174
  content = _replace_table(content, "tool.uv.sources", "\n".join(f"{k} = {_fmt_source(v)}" for k,v in sources.items()))
160
175
  content = _replace_project_dependencies(content, deps)
161
176
  pyproject_path.write_text(content)
@@ -178,16 +193,24 @@ def _external_projects(root: Path, dirs: list[Path]) -> list[tuple[str,str]]:
178
193
  res = []
179
194
  for d in dirs:
180
195
  if not d.is_dir(): continue
181
- cands = [d] if (d/"pyproject.toml").exists() else sorted(p for p in d.iterdir() if p.is_dir() and not p.name.startswith((".","_")) and (p/"pyproject.toml").exists())
196
+ cands = [d] if (d/"pyproject.toml").exists() else [p for p in _project_dirs(d, "pyproject.toml") if not p.name.startswith((".", "_"))]
182
197
  for c in cands:
183
198
  if name := _read_pyproject_name(c/"pyproject.toml"): res.append((name, os.path.relpath(c, root)))
184
199
  return res
185
200
 
201
+ def _repo_extras(root: Path, entries) -> dict[str, set[str]]:
202
+ "Package extras selected by the repo lists, including empty selections for removal on sync."
203
+ return {_pkg_key(name): extras for _,d,extras in entries for name,_ in _external_projects(root, [d])}
204
+
186
205
  def _valid_project_dir(d: Path) -> bool: return (d/"pyproject.toml").exists() and bool(_read_pyproject_name(d/"pyproject.toml"))
187
206
 
188
- def _cargo_only(d: Path) -> bool:
189
- "A Rust crate with no Python layer: not a pending scaffold, just not a uv workspace member"
190
- return (d/"Cargo.toml").exists() and not (d/"pyproject.toml").exists()
207
+ def _non_python_project(d: Path) -> bool:
208
+ "A Rust or JS project without a Python layer is not a pending Python scaffold."
209
+ return not (d/"pyproject.toml").exists() and any((d/f).exists() for f in ("Cargo.toml", "package.json"))
210
+
211
+ def _pending_dirs(root: Path) -> list[str]:
212
+ "uv workspace dirs that are not Python projects yet; sync stops rather than let uv fail on them"
213
+ return [d.name for d in _ws_dirs(root) if not (d/"pyproject.toml").exists()]
191
214
 
192
215
  def _sync_ws_excludes(pyproject_path: Path, root: Path, tracked: set[str]) -> tuple[list[str], list[str]]:
193
216
  """Regenerate `tool.uv.workspace.exclude` and return (added, removed).
@@ -195,7 +218,7 @@ def _sync_ws_excludes(pyproject_path: Path, root: Path, tracked: set[str]) -> tu
195
218
  Kept as-is: `[tool.fastws].exclude` entries (intent), globs, missing dirs, and tracked dirs
196
219
  (repos.txt checkouts) that are still not valid Python projects. Auto-managed: entries for other
197
220
  existing dirs are regenerated each sync, excluding dirs without a valid pyproject (tracked dirs
198
- only when they are Cargo-only crates, since a tracked dir with neither file is a pending member
221
+ only when they are Cargo-only crates or npm-only packages, since a tracked dir with none of those files is a pending member
199
222
  awaiting scaffolding) and un-excluding dirs that gained one; deliberately excluding a real
200
223
  project takes a `[tool.fastws]` entry."""
201
224
  if not pyproject_path.exists(): return [], []
@@ -207,10 +230,8 @@ def _sync_ws_excludes(pyproject_path: Path, root: Path, tracked: set[str]) -> tu
207
230
  intent = [e for e in data.get("tool", {}).get("fastws", {}).get("exclude", []) if isinstance(e, str)]
208
231
  kept = [e for e in cur if e in intent or any(c in e for c in "*?[") or not (root/e).is_dir() or (e in tracked and not _valid_project_dir(root/e))]
209
232
  kept += [e for e in intent if e not in kept]
210
- auto = [d.name for d in sorted(root.iterdir()) # chkstyle: ignore-node
211
- if d.is_dir() and not d.name.startswith(".") and any(_matches_ws(d.name, m) for m in members)
212
- and not any(_matches_ws(d.name, e) for e in kept)
213
- and (d.name not in tracked or _cargo_only(d)) and not _valid_project_dir(d)]
233
+ auto = [d.name for d in _project_dirs(root, exclude=kept) if not d.name.startswith(".") and any(_matches_ws(d.name, m) for m in members)
234
+ and (d.name not in tracked or _non_python_project(d)) and not _valid_project_dir(d)]
214
235
  survivors = set(kept) | set(auto)
215
236
  new = [e for e in cur if e in survivors] + [e for e in kept + auto if e not in cur]
216
237
  if new == cur: return [], []
@@ -264,7 +285,7 @@ def _replace_project_dependencies(content: str, deps: list[str]) -> str:
264
285
  if not (span := _table_span(content, "project")): raise SystemExit("Missing [project] table in pyproject.toml")
265
286
  start,end = span
266
287
  section = content[start:end]
267
- dep_block = "dependencies = [\n" + "".join(f' "{dep}",\n' for dep in deps) + "]"
288
+ dep_block = "dependencies = [\n" + "".join(f' {json.dumps(dep)},\n' for dep in deps) + "]"
268
289
  if m := re.search(r"(?m)^dependencies\s*=\s*\[", section):
269
290
  arr_start = m.end()-1
270
291
  arr_end = _find_array_end(section, arr_start)
@@ -287,29 +308,36 @@ members = ["./*"]
287
308
  ''')
288
309
  return True
289
310
 
290
- def _sync_ws_pyproject(pyproject_path: Path, template_path: Path, projects: list[str], externals: list[tuple] = None) -> list[str]:
311
+ def _sync_ws_pyproject(pyproject_path: Path, template_path: Path, projects: list[str], externals: list[tuple] = None,
312
+ extras: dict[str, set[str]] = None) -> list[str]:
291
313
  if not pyproject_path.exists():
292
314
  if template_path.exists(): shutil.copyfile(template_path, pyproject_path)
293
315
  else: _init_ws_pyproject(pyproject_path)
294
- content = pyproject_path.read_text()
316
+ original = content = pyproject_path.read_text()
295
317
  data = tomllib.loads(content)
296
318
  sources = dict(data.get("tool", {}).get("uv", {}).get("sources", {}))
297
319
  source_keys = {_pkg_key(proj) for proj in sources}
298
320
  missing = [proj for proj in projects if _pkg_key(proj) not in source_keys]
299
321
  ext_missing = [(n,p) for n,p in (externals or []) if _pkg_key(n) not in source_keys]
300
- if not missing and not ext_missing: return []
301
322
  for proj in missing: sources[proj] = {"workspace": True}
302
323
  for n,p in ext_missing: sources[n] = {"path": p, "editable": True}
303
324
  deps = list(data.get("project", {}).get("dependencies", []))
304
- dep_keys = {dep_key(dep) for dep in deps}
305
- for proj in missing + [n for n,_ in ext_missing]:
325
+ dep_keys = {_pkg_key(dep_key(dep)) for dep in deps}
326
+ for proj in projects + [n for n,_ in (externals or [])]:
306
327
  if _pkg_key(proj) in dep_keys: continue
307
328
  deps.append(proj)
308
329
  dep_keys.add(_pkg_key(proj))
309
- source_lines = "\n".join(f"{proj} = {_fmt_source(src)}" for proj,src in sources.items())
310
- content = _replace_table(content, "tool.uv.sources", source_lines)
311
- content = _replace_project_dependencies(content, deps)
312
- pyproject_path.write_text(content)
330
+ for i,dep in enumerate(deps):
331
+ if (key := _pkg_key(dep_key(dep))) not in (extras or {}): continue
332
+ req = Requirement(dep)
333
+ if {canonicalize_name(e) for e in req.extras} == extras[key]: continue
334
+ req.extras = extras[key]
335
+ deps[i] = str(req)
336
+ if missing or ext_missing:
337
+ source_lines = "\n".join(f"{proj} = {_fmt_source(src)}" for proj,src in sources.items())
338
+ content = _replace_table(content, "tool.uv.sources", source_lines)
339
+ if deps != data.get("project", {}).get("dependencies", []): content = _replace_project_dependencies(content, deps)
340
+ if content != original: pyproject_path.write_text(content)
313
341
  return missing + [n for n,_ in ext_missing]
314
342
 
315
343
  def _editable_mapping(path: Path) -> dict[str,str]:
@@ -353,10 +381,10 @@ async def _clone_one(repo: str, d: Path) -> str|None:
353
381
  except subprocess.CalledProcessError as e: return f"✗ {d.name}: {e.stderr.strip()}"
354
382
 
355
383
  async def _clone_repos(entries, workers):
356
- async def clone(e): return await _clone_one(*e)
384
+ async def clone(e): return await _clone_one(e[0], e[1])
357
385
  async for _, res in parallel_async_gen(clone, entries, n_workers=workers):
358
386
  if res: print(res)
359
- if missing := [str(d) for _,d in entries if not (d/'.git').exists()]:
387
+ if missing := [str(d) for _,d,_ in entries if not (d/'.git').exists()]:
360
388
  raise SystemExit(f'Could not clone repos (missing Git checkouts): {", ".join(missing)}')
361
389
 
362
390
  async def _pull_workspace(root: Path):
@@ -436,7 +464,7 @@ async def ws_pull(
436
464
  workers: int = 64, # Number of parallel workers
437
465
  ):
438
466
  "Pull updates for repos whose GitHub origin has moved (all repos when that can't be checked)."
439
- dirs = [d for _,d in _load_repo_entries(repos_file, Path("."))]
467
+ dirs = [d for _,d,_ in _load_repo_entries(repos_file, Path("."))]
440
468
  try: dirs = await _changed_dirs(dirs)
441
469
  except Exception: pass
442
470
  await _pull(dirs, workers)
@@ -447,7 +475,7 @@ def ws_status(
447
475
  branches: bool = False, # Show unpushed commit details
448
476
  ):
449
477
  "Show uncommitted changes and optionally unpushed commit details across repos."
450
- for repo,d in _load_repo_entries(repos_file, Path(".")):
478
+ for repo,d,_ in _load_repo_entries(repos_file, Path(".")):
451
479
  if not d.exists(): continue
452
480
  g = Git(d)
453
481
  if not g.exists: continue
@@ -468,7 +496,7 @@ def ws_branches(
468
496
  expected: str = "main", # Expected branch name
469
497
  ):
470
498
  "Check if all repos are on the expected branch."
471
- for repo,d in _load_repo_entries(repos_file, Path(".")):
499
+ for repo,d,_ in _load_repo_entries(repos_file, Path(".")):
472
500
  if not Path(d).exists():
473
501
  print(f"⚠️ {d}: directory not found")
474
502
  continue
@@ -503,7 +531,7 @@ def _build_projects(root: Path, repos_file: str, project: str = None) -> list[tu
503
531
  "(name, dir) for every project ws-sync installs: workspace members plus external checkouts"
504
532
  repos_path = _resolve_path(root, repos_file)
505
533
  entries = _load_repo_entries(repos_path, root)
506
- ext_dirs = [d for _,d in entries if d.resolve().parent != root.resolve()]
534
+ ext_dirs = [d for _,d,_ in entries if d.resolve().parent != root.resolve()]
507
535
  res = [(name, d) for d in _ws_dirs(root) if (d/"pyproject.toml").exists() and (name := _read_pyproject_name(d/"pyproject.toml"))]
508
536
  res += [(n, _resolve_path(root, p)) for n,p in _external_projects(root, ext_dirs)]
509
537
  if project is None: return res
@@ -569,7 +597,7 @@ _CARGO_KEY = Path(".git/fastws-cargo-key")
569
597
 
570
598
  def _crate_dirs(root: Path) -> list[Path]:
571
599
  "Root dirs containing a Cargo.toml: the crate view of the workspace, independent of uv membership"
572
- return [d for d in sorted(root.iterdir()) if d.is_dir() and not d.name.startswith((".", "_")) and (d/"Cargo.toml").exists()]
600
+ return [d for d in _project_dirs(root, "Cargo.toml") if not d.name.startswith((".", "_"))]
573
601
 
574
602
  def _cargo_patches(root: Path):
575
603
  "Local Cargo patches keyed by normalized Git URL and package name, plus their config file."
@@ -589,12 +617,10 @@ def _crate_pkgs(d: Path):
589
617
  try: data = tomllib.loads((d/"Cargo.toml").read_text())
590
618
  except tomllib.TOMLDecodeError: return
591
619
  if name := data.get("package", {}).get("name"): yield name, d
592
- for pat in data.get("workspace", {}).get("members", []):
593
- for m in sorted(d.glob(pat)):
594
- if not (m/"Cargo.toml").exists(): continue
595
- try: sub = tomllib.loads((m/"Cargo.toml").read_text())
596
- except tomllib.TOMLDecodeError: continue
597
- if name := sub.get("package", {}).get("name"): yield name, m
620
+ for m in _project_dirs(d, "Cargo.toml", data.get("workspace", {}).get("members", [])):
621
+ try: sub = tomllib.loads((m/"Cargo.toml").read_text())
622
+ except tomllib.TOMLDecodeError: continue
623
+ if name := sub.get("package", {}).get("name"): yield name, m
598
624
 
599
625
  def _local_crates(root: Path) -> dict[str, Path]:
600
626
  "Package name -> dir for every crate under `root`, nested cargo workspace members included"
@@ -623,6 +649,10 @@ def _strip_patch_tables(content: str) -> str:
623
649
  content = content[:m.start()] + content[end:]
624
650
  return content
625
651
 
652
+ def _entry_changes(current, new):
653
+ "Added and removed keys, in the order supplied."
654
+ return [k for k in new if k not in current], [k for k in current if k not in new]
655
+
626
656
  def _sync_cargo_patches(root: Path) -> tuple[list[str], list[str]]:
627
657
  """Regenerate `[patch]` entries in the workspace `.cargo/config.toml` and return (added, removed).
628
658
 
@@ -659,7 +689,8 @@ def _sync_cargo_patches(root: Path) -> tuple[list[str], list[str]]:
659
689
  config.write_text(new)
660
690
  before = {n for entries in old.values() for n in entries}
661
691
  after = {n for entries in tables.values() for n in entries}
662
- return sorted(after - before), sorted(before - after)
692
+ added, removed = _entry_changes(before, after)
693
+ return sorted(added), sorted(removed)
663
694
 
664
695
  def _sync_cargo_wrapper(root: Path) -> bool:
665
696
  "Add sccache to the generated Cargo config when installed, without overriding another wrapper"
@@ -744,6 +775,78 @@ def _cargo_update(root: Path, workers: int = 16):
744
775
  elif lines := [l for l in out.splitlines() if l.lstrip().startswith(("Updating ", "Adding ", "Removing ")) and "crates.io index" not in l]:
745
776
  print(f"{name}:\n" + "\n".join(lines))
746
777
 
778
+ def _npm_dirs(root: Path) -> list[Path]:
779
+ "Find root JavaScript packages and their declared workspace members."
780
+ exclude = _fastws_cfg(root).get("exclude", [])
781
+ def ok(d): return not d.name.startswith((".", "_")) and d.name != "node_modules"
782
+ res = []
783
+ for d in _project_dirs(root, "package.json", exclude=exclude):
784
+ if not ok(d): continue
785
+ data = json.loads((d/"package.json").read_text())
786
+ members = [glob.escape(d.name)] + [f"{glob.escape(d.name)}/{p}" for p in data.get("workspaces", [])]
787
+ res += _project_dirs(root, "package.json", members, exclude)
788
+ return list(dict.fromkeys(d for d in res if ok(d)))
789
+
790
+ def _sync_ws_package_json(root: Path, members: list[Path]) -> tuple[list[str], list[str]]:
791
+ "Merge shared settings and update workspaces, preserving external paths and globs; return (added, removed)."
792
+ path = root/"package.json"
793
+ data = json.loads(path.read_text()) if path.exists() else {"private": True}
794
+ before = json.dumps(data)
795
+ shared = root/"package.json.shared"
796
+ if shared.exists():
797
+ for k,v in json.loads(shared.read_text()).items():
798
+ data[k] = data[k] | v if isinstance(v, dict) and isinstance(data.get(k), dict) else v
799
+ cur = data.get("workspaces", [])
800
+ if not isinstance(cur, list): raise SystemExit(f"{path}: fastws requires `workspaces` to be a list of package paths.")
801
+ kept = [e for e in cur if any(c in e for c in "*?[") or not (root/e).resolve().is_relative_to(root.resolve())]
802
+ new = kept + [e for e in (os.path.relpath(d, root) for d in members) if e not in kept]
803
+ if new != cur: data["workspaces"] = new
804
+ if json.dumps(data) == before: return [], []
805
+ path.write_text(json.dumps(data, indent=2) + "\n")
806
+ return _entry_changes(cur, new)
807
+
808
+ def _native_js(members: list[Path]) -> list[Path]:
809
+ "JavaScript packages with a Rust manifest and a build script."
810
+ return [d for d in members if (d/"Cargo.toml").exists()
811
+ and json.loads((d/"package.json").read_text()).get("scripts", {}).get("build")]
812
+
813
+ def _check_rustup():
814
+ "Require rustup's compiler proxies for automatic WASM target installation."
815
+ if not (rustup := shutil.which("rustup")):
816
+ raise SystemExit("Rust-backed JavaScript builds require rustup. Install it from https://rustup.rs, "
817
+ "select a stable toolchain with `rustup default stable`, then reopen your shell and rerun ws-sync.")
818
+ for tool in "rustc","cargo":
819
+ path = shutil.which(tool)
820
+ if path and os.path.samefile(path, rustup): continue
821
+ raise SystemExit(f"Rust-backed JavaScript builds require rustup-managed Rust, but {tool} resolves to {path or 'nothing'}. "
822
+ "Put rustup's bin directory first on PATH after Homebrew or other toolchain setup in your shell profile "
823
+ '(normally: export PATH="$HOME/.cargo/bin:$PATH"; use $CARGO_HOME/bin when customized). '
824
+ "Homebrew Rust can remain installed. Open a new shell and rerun ws-sync.")
825
+
826
+ def _npm_install(root: Path):
827
+ "Hide npm's install summary, but retain warnings, audit findings, and failure diagnostics."
828
+ try: res = subprocess.run(["npm", "install", "--json"], check=True, cwd=root, stdout=subprocess.PIPE, text=True)
829
+ except subprocess.CalledProcessError as e:
830
+ if e.stdout: print(e.stdout, file=sys.stderr, end="")
831
+ raise
832
+ try: data = json.loads(res.stdout)
833
+ except json.JSONDecodeError:
834
+ print(res.stdout, file=sys.stderr, end="")
835
+ raise
836
+ if data.get("audit", {}).get("vulnerabilities", {}).get("total") or data.get("unreviewedScripts"):
837
+ print(f"npm install findings ({root}):\n{res.stdout}", file=sys.stderr, end="")
838
+
839
+ def _sync_js(root: Path, members: list[Path]) -> list[Path]:
840
+ "Install the JS workspace, run every native member's build script, and return those members. Cargo handles incremental compilation."
841
+ tool = _fastws_cfg(root).get("js", "npm")
842
+ if not shutil.which(tool): raise SystemExit(f"{tool} is not installed: install it, or set [tool.fastws].js to a package manager that is")
843
+ if tool == "npm": _npm_install(root)
844
+ else: subprocess.run([tool, "install"], check=True, cwd=root)
845
+ built = _native_js(members)
846
+ cmd = [tool, "run", "--silent", "build"] if tool == "npm" else [tool, "run", "build"]
847
+ for d in built: subprocess.run(cmd, check=True, cwd=d)
848
+ return built
849
+
747
850
  @call_parse
748
851
  async def ws_sync(
749
852
  workspace: str = "", # Workspace root; defaults to active venv parent when available
@@ -765,18 +868,19 @@ async def ws_sync(
765
868
 
766
869
  if missing_repos := _update_repos_file(repos_path, repos): print(f"Added local repos: {', '.join(missing_repos)}")
767
870
  entries = _load_repo_entries(repos_path, root)
768
- ext_dirs = [d for _,d in entries if d.resolve().parent != root.resolve()]
769
- dirs = [d for _,d in entries]
871
+ ext_dirs = [d for _,d,_ in entries if d.resolve().parent != root.resolve()]
872
+ dirs = [d for _,d,_ in entries]
770
873
  try: dirs = await _changed_dirs(dirs)
771
874
  except Exception: pass
772
875
  await _pull(dirs, workers=workers)
773
876
 
774
877
  if not pyproject_path.exists(): _sync_ws_pyproject(pyproject_path, template_path, [])
775
- added_ex, removed_ex = _sync_ws_excludes(pyproject_path, root, {d.name for _,d in entries if d.resolve().parent == root.resolve()})
878
+ added_ex, removed_ex = _sync_ws_excludes(pyproject_path, root, {d.name for _,d,_ in entries if d.resolve().parent == root.resolve()})
776
879
  if added_ex: print(f"Auto-excluded from the workspace: {', '.join(added_ex)}")
777
880
  if removed_ex: print(f"No longer excluded from the workspace: {', '.join(removed_ex)}")
778
881
 
779
- missing_projects = _sync_ws_pyproject(pyproject_path, template_path, _ws_projects(root), _external_projects(root, ext_dirs))
882
+ missing_projects = _sync_ws_pyproject(pyproject_path, template_path, _ws_projects(root), _external_projects(root, ext_dirs),
883
+ _repo_extras(root, entries))
780
884
  if missing_projects: print(f"Added workspace projects: {', '.join(missing_projects)}")
781
885
 
782
886
  wrapper_added = _sync_cargo_wrapper(root)
@@ -785,7 +889,13 @@ async def ws_sync(
785
889
  if removed_p: print(f"Cargo patches removed: {', '.join(removed_p)}")
786
890
  if wrapper_added: print("Cargo builds now use sccache")
787
891
 
788
- if bad := [d.name for d in _ws_dirs(root) if not (d/"pyproject.toml").exists()]:
892
+ js_members = _npm_dirs(root)
893
+ if _native_js(js_members): _check_rustup()
894
+ added_j, removed_j = _sync_ws_package_json(root, js_members)
895
+ if added_j: print(f"JS workspace packages added: {', '.join(added_j)}")
896
+ if removed_j: print(f"JS workspace packages removed: {', '.join(removed_j)}")
897
+
898
+ if bad := _pending_dirs(root):
789
899
  print(f"⚠️ Skipping uv sync, not Python projects yet (scaffold with e.g. nbdev-new or ship-new, or remove): {', '.join(bad)}")
790
900
  return
791
901
  up = upgrade or _should_upgrade(root)
@@ -793,6 +903,7 @@ async def ws_sync(
793
903
  _sync_cargo_keys(root)
794
904
  subprocess.run(["uv", "sync", "-U"] if up else ["uv", "sync"], check=True, cwd=root)
795
905
  if up: _upgrade_stamp(root).touch()
906
+ if js_members: _sync_js(root, js_members)
796
907
 
797
908
  @call_parse
798
909
  async def ws_add(
@@ -848,7 +959,7 @@ def _resolve_removal_target(root: Path, repo: str, repos_path: Path) -> str:
848
959
  "Canonical owner/repo for `repo`, matching an existing folder name when `repo` isn't a valid spec."
849
960
  if _is_repo_spec(repo): return _normalize_repo(repo)
850
961
  if not (root/repo).is_dir(): return _normalize_repo(repo) # invalid spec and no such folder: raise
851
- for r,d in _load_repo_entries(repos_path, root):
962
+ for r,d,_ in _load_repo_entries(repos_path, root):
852
963
  if _repo_dir(r).casefold() == repo.casefold(): return r
853
964
  if (root/repo/".git").exists():
854
965
  url = Git(root/repo).remote("get-url", "origin", mute_errors=True)
@@ -887,7 +998,7 @@ def ws_remove(
887
998
  if _repo_key(repo) in {_repo_key(r) for r in _load_repos(repos_path)}:
888
999
  raise SystemExit(f'{repo} belongs to the shared baseline: edit {repos_path} deliberately to remove it')
889
1000
  d = _resolve_repo_dir(root, repo)
890
- for r,location in _load_repo_entries(repos_path, root):
1001
+ for r,location,_ in _load_repo_entries(repos_path, root):
891
1002
  if _repo_key(r) == _repo_key(repo) and location.resolve() != d:
892
1003
  raise SystemExit(f'Refusing to remove {repo}: custom checkout location {location}; manage it explicitly')
893
1004
  repos_path = _local_repos_path(repos_path)
@@ -10,21 +10,12 @@ from fastcore.parallel import parallel_async
10
10
  from ghapi.core import APIError, GhApi, dep_closure, local_dep_graph
11
11
  from packaging.version import InvalidVersion, Version
12
12
 
13
- from .core import _load_repo_entries, _resolve_path, _ws_root
14
-
15
- try: import tomllib
16
- except ModuleNotFoundError: import tomli as tomllib
13
+ from .core import _fastws_cfg, _load_repo_entries, _resolve_path, _ws_root
17
14
 
18
15
  # Start-of-message regexes for commits that need no release (curation noise, CI config, docs regen)
19
16
  DEFAULT_SKIP = ["bump$", "Bump version to ", "meta", "auto", "README", "regen readme", "nbdev regen",
20
17
  "update .gitignore", r"\.?gitignore$", "workflow", "chkstyle skip", "tests$", "md$", "docs?$", "clean$", "CI$", "ignore$", "allowed_metadata_keys$"]
21
18
 
22
- def _fastws_cfg(root: Path) -> dict:
23
- "The `[tool.fastws]` table from the workspace root pyproject.toml (empty when absent)."
24
- pyproj = root/"pyproject.toml"
25
- if not pyproj.exists(): return {}
26
- return tomllib.loads(pyproj.read_text(encoding="utf-8")).get("tool", {}).get("fastws", {})
27
-
28
19
  def _skip_pats(skip=None, root: Path|None = None) -> list[re.Pattern]:
29
20
  "Compiled skip patterns: defaults + `[tool.fastws].release_skip` from the workspace pyproject + `skip`."
30
21
  pats = list(DEFAULT_SKIP)
@@ -68,14 +59,14 @@ class ReleaseReport(list):
68
59
  def _cwd_project(root: Path, repos_file: str = "repos.txt") -> str|None:
69
60
  "Dir name of the workspace member checkout containing cwd (None at the root or outside every member)."
70
61
  cwd = Path.cwd().resolve()
71
- for _, d in _load_repo_entries(_resolve_path(root, repos_file), root):
62
+ for _, d, _ in _load_repo_entries(_resolve_path(root, repos_file), root):
72
63
  if cwd.is_relative_to(d.resolve()): return d.name
73
64
  return None
74
65
 
75
66
  async def check_releases(project: str = None, skip=None, workspace: str = "", repos_file: str = "repos.txt", nodeps: bool = False) -> ReleaseReport:
76
67
  "Sweep every workspace repo for unreleased commits; a `project` (given, or detected from cwd inside a member checkout) limits the sweep to its transitive dependency closure, or to itself alone with `nodeps`. `[tool.fastws].release_exclude` names repos to leave out (apps that deploy rather than release)."
77
68
  root = _ws_root(workspace)
78
- repos = [r for r,d in _load_repo_entries(_resolve_path(root, repos_file), root)]
69
+ repos = [r for r,_,_ in _load_repo_entries(_resolve_path(root, repos_file), root)]
79
70
  excl = {e.casefold() for e in _fastws_cfg(root).get("release_exclude", [])}
80
71
  repos = [r for r in repos if r.split("/")[-1].casefold() not in excl]
81
72
  if project is None: project = _cwd_project(root, repos_file)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastws-cli
3
- Version: 0.0.14
3
+ Version: 0.0.16
4
4
  Summary: Fast workspace tools for multi-repo management
5
5
  Author: Jeremy Howard
6
6
  License: Apache-2.0
@@ -82,17 +82,31 @@ AnswerDotAI/fastcore
82
82
  jph00/private ~/private
83
83
  ```
84
84
 
85
+ Select Python extras by attaching brackets to the repo name, before any checkout location:
86
+
87
+ ```text
88
+ AnswerDotAI/fastws[dev]
89
+ owner/repo[dev,test] ~/somewhere
90
+ ```
91
+
92
+ These select `[project.optional-dependencies]`, not `[dependency-groups]`. Sync uses the package name from `pyproject.toml`, so `AnswerDotAI/fastws[dev]` installs `fastws-cli[dev]` editably. For a checkout containing several discovered Python packages, the selection applies to each. uv handles the extras and their dependencies.
93
+
94
+ Extras from duplicate entries in `repos.txt` and `repos-local.txt` are combined. For example, a personal `AnswerDotAI/fastws[dev]` entry adds development extras to a shared `AnswerDotAI/fastws` entry. The lists are authoritative for those packages' extras: each sync updates existing workspace dependencies, including removing extras no longer selected. Put personal extra selections in `repos-local.txt`, not the generated `pyproject.toml`. Existing version constraints, markers, sources, and unrelated dependencies are preserved.
95
+
85
96
  ### Shared baseline and personal additions
86
97
 
87
98
  Keep `repos.txt` tracked in the workspace Git repo. Put personal additions in a gitignored `repos-local.txt`, using the same format. Every command reads both lists. `ws-add` and automatic discovery append only to the local list. Edit the shared list deliberately when changing the team's baseline. Duplicate repo names are matched case-insensitively. Conflicting explicit locations, or two different repos targeting the same directory, cause errors.
88
99
 
89
100
  `--repos-file myrepos.txt` uses `myrepos-local.txt` alongside it. Either list may be absent. Removing a repo from the baseline does not delete its checkout. The next sync discovers an existing root checkout as a personal addition. External checkouts also remain on disk; add them to the local list to keep managing them.
90
101
 
91
- The workspace `pyproject.toml` is local generated state. `ws-sync` creates it from the tracked `pyproject.tmpl` when available, or generates a minimal one, then maintains it without replacing personal settings. The template supplies starting defaults only: later template edits do not overwrite existing configuration. Ignore these files in the workspace repo (not in its member repos):
102
+ The workspace `pyproject.toml` is local generated state. `ws-sync` creates it from the tracked `pyproject.tmpl` when available, or generates a minimal one, then maintains it while preserving personal settings except for the repo extras managed above. The template supplies starting defaults only: later template edits do not overwrite existing configuration. Ignore these files in the workspace repo (not in its member repos):
92
103
 
93
104
  ```gitignore
94
105
  /repos-local.txt
95
106
  /pyproject.toml
107
+ /package.json
108
+ /package-lock.json
109
+ /node_modules/
96
110
  /pyrightconfig.json
97
111
  /uv.lock
98
112
  /.cargo/
@@ -194,7 +208,7 @@ By default, `ws-sync` uses the active venv's parent as the workspace root. It cr
194
208
 
195
209
  Project scanning respects `tool.uv.workspace.members` and `exclude`. If a member directory lacks `pyproject.toml`, such as a fresh empty clone, sync warns and skips the `uv sync` step.
196
210
 
197
- The workspace `exclude` list is maintained automatically. An unlisted top-level directory that is not a valid Python project is excluded until it has a `pyproject.toml`. A `repos.txt` checkout is automatically excluded only when it is a Cargo-only Rust crate. A listed checkout with neither `pyproject.toml` nor `Cargo.toml` is treated as a pending member and triggers the warning above.
211
+ Fastws maintains `tool.uv.workspace.exclude`. It excludes unlisted top-level directories without a valid Python project. It also excludes listed Rust or JavaScript projects without `pyproject.toml`. For a listed checkout with none of `pyproject.toml`, `Cargo.toml`, or `package.json`, sync warns and skips installation.
198
212
 
199
213
  Existing globs, entries for missing directories, and entries for checkouts that are still not Python projects are retained. Use `exclude = [...]` under `[tool.fastws]` to specify exclusions the scan cannot infer, such as keeping a valid project out of the workspace. Adding members preserves hand-written `[tool.uv.sources]` entries, including path and Git sources.
200
214
 
@@ -204,6 +218,20 @@ Do not commit a `Cargo.lock` generated under these patches. Its source-less loca
204
218
 
205
219
  When `sccache` is installed, sync also configures it as Cargo's `rustc-wrapper`, allowing unchanged compilation units to be reused across the workspace's otherwise-independent Cargo target directories. An existing wrapper is always preserved.
206
220
 
221
+ Fastws discovers JavaScript packages directly under the workspace root. It reads each package's `workspaces` list for declared nested packages. These lists accept paths and globs. Fastws ignores undeclared nested packages. Lockfiles do not affect discovery. `[tool.fastws].exclude` matches paths relative to the workspace root. Fastws skips `node_modules` and directories whose names start with `.` or `_`.
222
+
223
+ Each sync updates `workspaces` in the root `package.json`. It creates the file when needed. It preserves external paths, globs, and unrelated settings. It reports added and removed packages.
224
+
225
+ An optional tracked `package.json.shared` is merged into the root `package.json` on every sync. Object-valued fields merge one level deep, shared values win conflicts, and arrays replace rather than concatenate. Unrelated local settings remain; removing a shared key does not delete its local value. Workspace discovery still manages `workspaces`. Use this file for shared policy such as version-pinned npm `allowScripts` approvals.
226
+
227
+ After `uv sync`, fastws runs `<tool> install` at the workspace root. It then runs `<tool> run build` in each JavaScript package containing both `Cargo.toml` and a `build` script. Cargo handles incremental compilation. Other build steps still run.
228
+
229
+ For npm, sync hides routine install summaries and build-command banners. Install warnings, audit findings, unreviewed install scripts, and failure diagnostics remain visible. Build scripts retain their own output; configure their tools to suppress routine progress. Other package managers retain their normal output.
230
+
231
+ When Rust-backed JavaScript builds are present, fastws checks before dependency installation that `rustc` and `cargo` on `PATH` are rustup's proxies. Install rustup from https://rustup.rs and select a stable toolchain with `rustup default stable` if not already configured. If another Rust installation shadows rustup, put its bin directory first on `PATH` after Homebrew's shell setup: `export PATH="$HOME/.cargo/bin:$PATH"` (use `$CARGO_HOME/bin` for a custom Cargo home), then open a new shell. Homebrew Rust can remain installed for Homebrew's own use. Fastws reports missing or shadowed rustup without changing your shell or installing a different toolchain. Packages using wasm-pack can then install their WASM target and matching binding tools automatically as part of their build.
232
+
233
+ The package manager defaults to `npm`. Set `js` under `[tool.fastws]` in the workspace `pyproject.toml` to choose another executable, such as `js = "bun"`. Fastws requires a list for `workspaces` in `package.json`, following the npm format. It stops if it cannot find the selected executable.
234
+
207
235
  At most once per day, sync upgrades dependencies with `uv sync -U` and a parallel `cargo update` in every member with a `Cargo.toml`. It prints the changes and records the run in a stamp file inside the workspace's `.git` directory. Pass `--upgrade` to force an upgrade regardless of the last run time.
208
236
 
209
237
  Before every uv sync, `ws-sync` writes `.git/fastws-cargo-key` for each member crate. The key hashes `Cargo.lock` contents and the workspace Cargo patch configuration. For Git dependencies redirected to local paths by `[patch."<url>"]`, it also hashes each patched crate's `Cargo.toml`, `build.rs`, and `src` tree, recursively. The file is rewritten only when that content changes, so projects can use `{ file = ".git/fastws-cargo-key" }` in `tool.uv.cache-keys` without rebuilding after a timestamp-only `Cargo.lock` write.
@@ -1,4 +1,4 @@
1
- import os, pytest
1
+ import json, os, pytest
2
2
  from types import SimpleNamespace
3
3
  from fastgit import Git
4
4
  import fastws.core as core
@@ -40,40 +40,46 @@ def fake_uv(monkeypatch):
40
40
 
41
41
  def test_repos_file_roundtrip(tmp_path):
42
42
  repos_path = tmp_path/'repos.txt'
43
- repos_path.write_text('# header\nAnswerDotAI/fastws\njph00/private ~/private\nfastai/fastai sub/dir\n')
43
+ repos_path.write_text('# header\nAnswerDotAI/fastws[dev]\njph00/private[docs] ~/private\nfastai/fastai sub/dir\n')
44
44
 
45
45
  assert core._load_repos(repos_path) == ['AnswerDotAI/fastws', 'jph00/private', 'fastai/fastai']
46
46
  assert core._load_repo_entries(repos_path, tmp_path) == [
47
- ('AnswerDotAI/fastws', tmp_path/'fastws'),
48
- ('jph00/private', core.Path('~/private').expanduser()),
49
- ('fastai/fastai', tmp_path/'sub'/'dir')]
47
+ ('AnswerDotAI/fastws', tmp_path/'fastws', {'dev'}),
48
+ ('jph00/private', core.Path('~/private').expanduser(), {'docs'}),
49
+ ('fastai/fastai', tmp_path/'sub'/'dir', set())]
50
50
 
51
51
  # discovery and ws-add write only extras, never change the shared baseline
52
52
  original = repos_path.read_text()
53
53
  local = tmp_path/'repos-local.txt'
54
- added = core._update_repos_file(repos_path, ['answerdotai/fastws', 'jph00/private', 'fastai/new'])
55
- assert added == ['fastai/new']
56
- assert local.read_text() == 'fastai/new\n'
54
+ added = core._update_repos_file(repos_path, ['answerdotai/fastws', 'jph00/private', 'fastai/new[dev]'])
55
+ assert added == ['fastai/new[dev]']
56
+ assert local.read_text() == 'fastai/new[dev]\n'
57
57
  assert repos_path.read_text() == original
58
- assert core._load_repo_entries(repos_path, tmp_path)[-1] == ('fastai/new', tmp_path/'new')
58
+ assert core._load_repo_entries(repos_path, tmp_path)[-1] == ('fastai/new', tmp_path/'new', {'dev'})
59
59
 
60
60
  # remove matches case-insensitively, preserves other lines (including comments), and reports absence
61
61
  assert core._remove_from_repos_file(local, 'FASTAI/new') is True
62
62
  assert core._remove_from_repos_file(repos_path, 'AnswerDotAI/gone') is False
63
- assert repos_path.read_text() == '# header\nAnswerDotAI/fastws\njph00/private ~/private\nfastai/fastai sub/dir\n'
63
+ assert repos_path.read_text() == original
64
64
 
65
65
 
66
66
  def test_repo_lists_deduplicate_and_reject_conflicting_locations(tmp_path):
67
67
  base, local = tmp_path/'repos.txt', tmp_path/'repos-local.txt'
68
- base.write_text('org/core\n')
69
- local.write_text('ORG/core\norg/extra ../external\n')
70
- assert core._load_repo_entries(base, tmp_path) == [('org/core', tmp_path/'core'), ('org/extra', tmp_path/'../external')]
68
+ base.write_text('org/core[dev]\norg/extra[docs] ../external\n')
69
+ local.write_text('ORG/core[TEST, dev]\norg/extra[dev]\n')
70
+ assert core._load_repo_entries(base, tmp_path) == [
71
+ ('org/core', tmp_path/'core', {'dev', 'test'}), ('org/extra', tmp_path/'../external', {'docs', 'dev'})]
71
72
  local.write_text('org/core ../elsewhere\n')
72
73
  with pytest.raises(SystemExit, match='location'): core._load_repo_entries(base, tmp_path)
73
74
  local.write_text('different/core\n')
74
75
  with pytest.raises(SystemExit, match='location'): core._load_repo_entries(base, tmp_path)
75
76
  base.unlink()
76
- assert core._load_repo_entries(base, tmp_path) == [('different/core', tmp_path/'core')]
77
+ assert core._load_repo_entries(base, tmp_path) == [('different/core', tmp_path/'core', set())]
78
+
79
+
80
+ @pytest.mark.parametrize('entry', ['org/repo[dev', 'org/repo[dev]]', 'org/repo[]'])
81
+ def test_invalid_repo_extras(entry):
82
+ with pytest.raises(SystemExit, match='Invalid repo entry'): core._parse_repo_line(entry)
77
83
 
78
84
 
79
85
  async def test_sync_updates_baseline_before_cloning_and_keeps_removed_repos(tmp_path, monkeypatch, fake_uv):
@@ -87,21 +93,22 @@ async def test_sync_updates_baseline_before_cloning_and_keeps_removed_repos(tmp_
87
93
  for name in ('old', 'new', 'extra'):
88
94
  d = tmp_path/name
89
95
  d.mkdir()
90
- (d/'pyproject.toml').write_text(f'[project]\nname = "{name}"\n')
96
+ (d/'pyproject.toml').write_text(f'[project]\nname = "{name}-pkg"\n[project.optional-dependencies]\ndev = []\ntest = []\n')
91
97
  mk_repo(d, origin=origins/f'{name}.git')
92
98
  seed.mkdir()
93
- (seed/'repos.txt').write_text('org/old\n')
99
+ (seed/'repos.txt').write_text('org/old[dev]\n')
94
100
  (seed/'.gitignore').write_text('*/\npyproject.toml\nrepos-local.txt\n')
95
101
  (seed/'pyproject.tmpl').write_text('[project]\nname = "workspace"\ndependencies = []\n[tool.uv.workspace]\nmembers = ["./*"]\n')
96
102
  g = mk_repo(seed, origin=origins/'workspace.git')
97
103
  Git(tmp_path, raise_exc=True).clone(str(origins/'workspace.git'), str(root))
98
104
  Git(root, raise_exc=True).clone('git@github.com:org/extra.git', str(root/'extra'))
105
+ (root/'repos-local.txt').write_text('org/old[test]\n')
99
106
 
100
107
  await core.ws_sync(str(root))
101
108
  assert (root/'old'/'pyproject.toml').exists()
102
- assert (root/'repos.txt').read_text() == 'org/old\n'
103
- assert (root/'repos-local.txt').read_text() == 'org/extra\n'
104
- assert set(core.tomllib.loads((root/'pyproject.toml').read_text())['project']['dependencies']) == {'old', 'extra'}
109
+ assert (root/'repos.txt').read_text() == 'org/old[dev]\n'
110
+ assert (root/'repos-local.txt').read_text() == 'org/old[test]\norg/extra\n'
111
+ assert set(core.tomllib.loads((root/'pyproject.toml').read_text())['project']['dependencies']) == {'old-pkg[dev,test]', 'extra-pkg'}
105
112
 
106
113
  (seed/'repos.txt').write_text('org/new\n')
107
114
  g.commit('-a', m='new shared baseline')
@@ -111,7 +118,10 @@ async def test_sync_updates_baseline_before_cloning_and_keeps_removed_repos(tmp_
111
118
  assert (root/'old'/'pyproject.toml').exists()
112
119
  assert (root/'repos.txt').read_text() == 'org/new\n'
113
120
  assert set(core._load_repos(root/'repos-local.txt')) == {'org/extra', 'org/old'}
114
- assert set(core.tomllib.loads((root/'pyproject.toml').read_text())['project']['dependencies']) == {'old', 'new', 'extra'}
121
+ assert set(core.tomllib.loads((root/'pyproject.toml').read_text())['project']['dependencies']) == {'old-pkg[test]', 'new-pkg', 'extra-pkg'}
122
+ (root/'repos-local.txt').write_text('org/extra\n')
123
+ await core.ws_sync(str(root))
124
+ assert set(core.tomllib.loads((root/'pyproject.toml').read_text())['project']['dependencies']) == {'old-pkg', 'new-pkg', 'extra-pkg'}
115
125
 
116
126
  (seed/'repos.txt').write_text('org/missing\n')
117
127
  g.commit('-a', m='unavailable member')
@@ -162,6 +172,23 @@ def test_ws_pyproject_preserves_existing_entries(tmp_path):
162
172
  assert 'alpha = { workspace = true }' in content
163
173
 
164
174
 
175
+ def test_sync_extras_preserves_constraints_markers_and_unrelated_settings(tmp_path):
176
+ pyproject = tmp_path/'pyproject.toml'
177
+ pyproject.write_text('[project]\nname = "ws"\ndependencies = [\'My_Pkg[old]>=1; python_version >= "3.10"\', "other[keep]"]\n'
178
+ '[tool.uv.sources]\nMy_Pkg = { path = "../somewhere", editable = true }\n')
179
+ for extras in ({'dev', 'test'}, set()):
180
+ core._sync_ws_pyproject(pyproject, tmp_path/'pyproject.tmpl', ['my-pkg'], extras={'my-pkg': extras})
181
+ data = core.tomllib.loads(pyproject.read_text())
182
+ dep, other = data['project']['dependencies']
183
+ req = core.Requirement(dep)
184
+ assert req.extras == extras and str(req.specifier) == '>=1' and str(req.marker) == 'python_version >= "3.10"'
185
+ assert other == 'other[keep]' and len(data['project']['dependencies']) == 2
186
+ assert data['tool']['uv']['sources'] == {'My_Pkg': {'path': '../somewhere', 'editable': True}}
187
+ before = pyproject.stat().st_mtime_ns
188
+ core._sync_ws_pyproject(pyproject, tmp_path/'pyproject.tmpl', ['my-pkg'], extras={'my-pkg': extras})
189
+ assert pyproject.stat().st_mtime_ns == before
190
+
191
+
165
192
  def test_ws_excludes_generates_from_intent_and_auto(tmp_path):
166
193
  pyproject = tmp_path/'pyproject.toml'
167
194
  pyproject.write_text(
@@ -205,6 +232,22 @@ def test_external_projects_discovers_root_and_subdir_packages(tmp_path):
205
232
  (multi/name/'pyproject.toml').write_text(f'[project]\nname = "{pkg}"\n')
206
233
 
207
234
  assert core._external_projects(root, [single, multi, tmp_path/'missing']) == [('singlepkg', '../single'), ('tool1', '../multi/tool1')]
235
+ (root/'repos.txt').write_text(f'org/single[dev] {single}\norg/multi[docs] {multi}\n')
236
+ extras = core._repo_extras(root, core._load_repo_entries(root/'repos.txt', root))
237
+ assert extras == {'singlepkg': {'dev'}, 'tool1': {'docs'}}
238
+ core._sync_ws_pyproject(root/'pyproject.toml', root/'pyproject.tmpl', [], core._external_projects(root, [single, multi]), extras)
239
+ data = core.tomllib.loads((root/'pyproject.toml').read_text())
240
+ assert data['project']['dependencies'] == ['singlepkg[dev]', 'tool1[docs]']
241
+ assert data['tool']['uv']['sources']['tool1'] == {'path': '../multi/tool1', 'editable': True}
242
+
243
+
244
+ @pytest.mark.parametrize('manifest', ['pyproject.toml', 'Cargo.toml', 'package.json'])
245
+ def test_project_dirs_expands_patterns_and_filters_manifests(tmp_path, manifest):
246
+ for name in ('a', 'b', 'empty'): (tmp_path/'pkgs'/name).mkdir(parents=True)
247
+ for name in ('a', 'b'): (tmp_path/'pkgs'/name/manifest).write_text('')
248
+ members, exclude = ['pkgs/*', 'pkgs/a'], ['./pkgs/b']
249
+ assert core._project_dirs(tmp_path, manifest, members, exclude) == [tmp_path/'pkgs'/'a']
250
+ assert core._project_dirs(tmp_path, members=members, exclude=exclude) == [tmp_path/'pkgs'/'a', tmp_path/'pkgs'/'empty']
208
251
 
209
252
 
210
253
  def test_ws_projects_skip_excluded_dirs_and_template_names(tmp_path):
@@ -454,3 +497,132 @@ def test_sync_cargo_wrapper(tmp_path, monkeypatch):
454
497
  config.write_text('[build]\nrustc-wrapper = "other-cache"\n')
455
498
  assert not core._sync_cargo_wrapper(tmp_path)
456
499
  assert core.tomllib.loads(config.read_text())['build']['rustc-wrapper'] == 'other-cache'
500
+
501
+
502
+ def test_npm_dirs_discovers_root_and_declared_packages(tmp_path):
503
+ for name in ('app', 'crate', 'crate/wasm', 'crate/frontend', 'crate/docs', '_scratch', 'node_modules', 'crate/node_modules'):
504
+ d = tmp_path/name
505
+ d.mkdir(parents=True, exist_ok=True)
506
+ (d/'package.json').write_text('{}')
507
+ (tmp_path/'app'/'package-lock.json').write_text('{}')
508
+ (tmp_path/'crate'/'package.json').write_text('{"private": true, "workspaces": ["wasm", "front*", "node_modules", "wasm", "missing"]}')
509
+ (tmp_path/'crate'/'frontend'/'bun.lock').write_text('') # lockfiles do not override explicit membership
510
+
511
+ assert core._npm_dirs(tmp_path) == [tmp_path/'app', tmp_path/'crate', tmp_path/'crate'/'wasm', tmp_path/'crate'/'frontend']
512
+
513
+
514
+ def test_npm_dirs_honours_fastws_exclude(tmp_path):
515
+ (tmp_path/'pyproject.toml').write_text('[tool.fastws]\nexclude = ["app", "tool*", "py/examples"]\n')
516
+ for name in ('app', 'lib', 'tools'):
517
+ (tmp_path/name).mkdir()
518
+ (tmp_path/name/'package.json').write_text('{}')
519
+ for name in ('examples', 'wasm'):
520
+ (tmp_path/'py'/name).mkdir(parents=True)
521
+ (tmp_path/'py'/name/'package.json').write_text('{}') # examples is excluded by its root-relative path
522
+ (tmp_path/'py'/'package.json').write_text('{"private": true, "workspaces": ["*"]}')
523
+
524
+ assert core._npm_dirs(tmp_path) == [tmp_path/'lib', tmp_path/'py', tmp_path/'py'/'wasm']
525
+
526
+
527
+ def test_sync_ws_package_json_generates_and_preserves(tmp_path):
528
+ pkg = tmp_path/'package.json'
529
+ pkg.write_text('{\n "name": "ws",\n "private": true,\n "workspaces": ["../outside", "gone", "tools/*"]\n}\n')
530
+ members = [tmp_path/'app', tmp_path/'crate'/'wasm']
531
+
532
+ added, removed = core._sync_ws_package_json(tmp_path, members)
533
+ data = json.loads(pkg.read_text())
534
+ # kept: entries outside the root and globs; managed: existing dirs regenerated from discovery
535
+ assert data['workspaces'] == ['../outside', 'tools/*', 'app', 'crate/wasm']
536
+ assert data['name'] == 'ws' and data['private'] is True # other keys untouched
537
+ assert added == ['app', 'crate/wasm'] and removed == ['gone']
538
+
539
+ content = pkg.read_text()
540
+ assert core._sync_ws_package_json(tmp_path, members) == ([], [])
541
+ assert pkg.read_text() == content
542
+
543
+ # a missing root package.json is created
544
+ bare = tmp_path/'ws2'
545
+ bare.mkdir()
546
+ assert core._sync_ws_package_json(bare, [bare/'a']) == (['a'], [])
547
+ assert json.loads((bare/'package.json').read_text()) == {'private': True, 'workspaces': ['a']}
548
+
549
+
550
+ @pytest.mark.parametrize('workspaces', [{'packages': ['packages/*']}, {}])
551
+ def test_sync_ws_package_json_rejects_object_form(tmp_path, workspaces):
552
+ pkg = tmp_path/'package.json'
553
+ content = json.dumps({'private': True, 'workspaces': workspaces}) + '\n'
554
+ pkg.write_text(content)
555
+
556
+ with pytest.raises(SystemExit, match='workspaces.*list') as exc: core._sync_ws_package_json(tmp_path, [tmp_path/'app'])
557
+ assert str(pkg) in str(exc.value)
558
+ assert pkg.read_text() == content
559
+
560
+
561
+ def test_sync_ws_package_json_merges_shared_settings(tmp_path):
562
+ pkg, shared = tmp_path/'package.json', tmp_path/'package.json.shared'
563
+ pkg.write_text(json.dumps({'name': 'local', 'workspaces': ['app'], 'keywords': ['local'],
564
+ 'allowScripts': {'wasm-pack@0.15.0': False, 'other': False}}))
565
+ shared.write_text(json.dumps({'keywords': ['shared'], 'allowScripts': {'wasm-pack@0.15.0': True}}))
566
+ assert core._sync_ws_package_json(tmp_path, [tmp_path/'app']) == ([], [])
567
+ assert json.loads(pkg.read_text()) == {'name': 'local', 'workspaces': ['app'], 'keywords': ['shared'],
568
+ 'allowScripts': {'wasm-pack@0.15.0': True, 'other': False}}
569
+ content, mtime = pkg.read_text(), pkg.stat().st_mtime_ns
570
+ core._sync_ws_package_json(tmp_path, [tmp_path/'app'])
571
+ assert pkg.stat().st_mtime_ns == mtime
572
+ shared.write_text('{}')
573
+ core._sync_ws_package_json(tmp_path, [tmp_path/'app'])
574
+ assert pkg.read_text() == content
575
+
576
+
577
+ def test_ws_excludes_treat_npm_only_dirs_like_cargo_only(tmp_path):
578
+ pyproject = tmp_path/'pyproject.toml'
579
+ pyproject.write_text('[project]\nname = "uvws"\n\n[tool.uv.workspace]\nmembers = ["./*"]\nexclude = []\n')
580
+ for name in ('app', 'pending'): (tmp_path/name).mkdir()
581
+ (tmp_path/'app'/'package.json').write_text('{}')
582
+
583
+ # a tracked npm-only checkout is a valid JS project: excluded from uv, never pending; an empty tracked dir still awaits scaffolding
584
+ assert core._sync_ws_excludes(pyproject, tmp_path, {'app', 'pending'}) == (['app'], [])
585
+ assert core._pending_dirs(tmp_path) == ['pending']
586
+
587
+
588
+ def test_native_js_requires_rust_and_build_script(tmp_path):
589
+ app, crate, wasm = [tmp_path/n for n in ('app', 'crate', 'wasm')]
590
+ for d in (app, crate, wasm): d.mkdir()
591
+ (app/'package.json').write_text('{"scripts": {"build": "vite build"}}')
592
+ for d in (crate, wasm): (d/'Cargo.toml').write_text('')
593
+ (crate/'package.json').write_text('{}')
594
+ (wasm/'package.json').write_text('{"scripts": {"build": "cargo build"}}')
595
+ assert core._native_js([app, crate, wasm]) == [wasm]
596
+
597
+
598
+ def test_check_rustup_missing(monkeypatch):
599
+ monkeypatch.setattr(core.shutil, 'which', lambda tool: None)
600
+ with pytest.raises(SystemExit, match='Install it from https://rustup.rs'): core._check_rustup()
601
+
602
+
603
+ @pytest.mark.parametrize('shadowed', [None, 'rustc', 'cargo'])
604
+ def test_check_rustup_proxies(tmp_path, monkeypatch, shadowed):
605
+ rustup = tmp_path/'rustup'
606
+ rustup.touch()
607
+ (tmp_path/'rustc').symlink_to(rustup)
608
+ (tmp_path/'cargo').hardlink_to(rustup)
609
+ other = tmp_path/'brew-rust'
610
+ other.touch()
611
+ monkeypatch.setattr(core.shutil, 'which', lambda tool: str(other if tool == shadowed else tmp_path/tool))
612
+ if shadowed:
613
+ with pytest.raises(SystemExit, match=f'{shadowed} resolves to') as err: core._check_rustup()
614
+ assert 'Homebrew Rust can remain installed' in str(err.value)
615
+ assert 'PATH' in str(err.value)
616
+ else: core._check_rustup()
617
+
618
+
619
+ async def test_sync_checks_rustup_before_installs(tmp_path, monkeypatch, fake_uv):
620
+ (tmp_path/'repos.txt').write_text('')
621
+ (tmp_path/'pyproject.toml').write_text('[project]\nname = "ws"\n[tool.uv.workspace]\nmembers = ["*"]\n')
622
+ wasm = tmp_path/'wasm'
623
+ wasm.mkdir()
624
+ (wasm/'Cargo.toml').write_text('[package]\nname = "wasm"\n')
625
+ (wasm/'package.json').write_text('{"name": "wasm", "scripts": {"build": "wasm-pack build"}}')
626
+ monkeypatch.setattr(core.shutil, 'which', lambda tool: None)
627
+ with pytest.raises(SystemExit, match='require rustup'): await core.ws_sync(str(tmp_path))
628
+ assert not fake_uv
File without changes
File without changes
File without changes