fastws-cli 0.0.16__tar.gz → 0.0.17__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.16
3
+ Version: 0.0.17
4
4
  Summary: Fast workspace tools for multi-repo management
5
5
  Author: Jeremy Howard
6
6
  License: Apache-2.0
@@ -10,7 +10,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
10
10
  Requires-Python: >=3.10
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
- Requires-Dist: fastcore>=2.2.6
13
+ Requires-Dist: fastcore>=2.2.29
14
14
  Requires-Dist: fastgit>=0.1.2
15
15
  Requires-Dist: ghapi>=2.0.6
16
16
  Requires-Dist: packaging
@@ -261,15 +261,16 @@ ws-add ~/private # stays where it is, recorded in repos-local.txt with its loca
261
261
 
262
262
  ### `ws-remove`
263
263
 
264
- Delete a personal repo's clone, remove it from `repos-local.txt` and the workspace `pyproject.toml`, then run `uv sync`.
264
+ Delete one or more personal repo clones and remove them from `repos-local.txt` and the workspace `pyproject.toml`. Run `uv sync` once after processing all repos.
265
265
 
266
266
  The command refuses shared baseline members. Edit `repos.txt` deliberately to change the team's baseline. It also refuses external or custom checkout locations, which must be managed explicitly.
267
267
 
268
- Removal requires a clean Git checkout with an `origin` remote and no uncommitted changes or unpushed commits. The command always asks for confirmation before deleting anything:
268
+ Removal requires a clean Git checkout with an `origin` remote and no uncommitted changes or unpushed commits. The command validates all targets before changing anything. It asks for confirmation before deleting each checkout. Declining keeps that checkout but still removes its workspace metadata. Repeated targets are processed once:
269
269
 
270
270
  ```bash
271
271
  ws-remove owner/personal-project
272
272
  ws-remove personal-project # bare folder name also works if the directory exists
273
+ ws-remove owner/project-one project-two project-three
273
274
  ```
274
275
 
275
276
  ### `ws-releases`
@@ -236,15 +236,16 @@ ws-add ~/private # stays where it is, recorded in repos-local.txt with its loca
236
236
 
237
237
  ### `ws-remove`
238
238
 
239
- Delete a personal repo's clone, remove it from `repos-local.txt` and the workspace `pyproject.toml`, then run `uv sync`.
239
+ Delete one or more personal repo clones and remove them from `repos-local.txt` and the workspace `pyproject.toml`. Run `uv sync` once after processing all repos.
240
240
 
241
241
  The command refuses shared baseline members. Edit `repos.txt` deliberately to change the team's baseline. It also refuses external or custom checkout locations, which must be managed explicitly.
242
242
 
243
- Removal requires a clean Git checkout with an `origin` remote and no uncommitted changes or unpushed commits. The command always asks for confirmation before deleting anything:
243
+ Removal requires a clean Git checkout with an `origin` remote and no uncommitted changes or unpushed commits. The command validates all targets before changing anything. It asks for confirmation before deleting each checkout. Declining keeps that checkout but still removes its workspace metadata. Repeated targets are processed once:
244
244
 
245
245
  ```bash
246
246
  ws-remove owner/personal-project
247
247
  ws-remove personal-project # bare folder name also works if the directory exists
248
+ ws-remove owner/project-one project-two project-three
248
249
  ```
249
250
 
250
251
  ### `ws-releases`
@@ -1,4 +1,5 @@
1
- __version__ = "0.0.16"
1
+ __version__ = "0.0.17"
2
+
2
3
 
3
4
 
4
5
 
@@ -984,33 +984,41 @@ def _repo_safety_issues(d: Path) -> list[str]:
984
984
 
985
985
  @call_parse
986
986
  def ws_remove(
987
- repo: str, # Repo to remove, e.g. AnswerDotAI/fastws
987
+ repo: str, # Repo to remove, e.g. owner/repo or a local folder name
988
+ *repos: str, # Additional repos to remove
988
989
  workspace: str = "", # Workspace root; defaults to active venv parent when available
989
990
  repos_file: str = "repos.txt", # Repo list to update
990
991
  pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
991
992
  template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
992
993
  ):
993
- "Remove a personal repo and its local metadata; shared baseline members cannot be removed."
994
+ "Remove personal repos and their local metadata; shared baseline members cannot be removed."
994
995
  root = _ws_root(workspace, repos_file, pyproject_file, template_file)
995
996
  repos_path = _resolve_path(root, repos_file)
996
997
  pyproject_path = _resolve_path(root, pyproject_file)
997
- repo = _resolve_removal_target(root, repo, repos_path)
998
- if _repo_key(repo) in {_repo_key(r) for r in _load_repos(repos_path)}:
999
- raise SystemExit(f'{repo} belongs to the shared baseline: edit {repos_path} deliberately to remove it')
1000
- d = _resolve_repo_dir(root, repo)
1001
- for r,location,_ in _load_repo_entries(repos_path, root):
1002
- if _repo_key(r) == _repo_key(repo) and location.resolve() != d:
1003
- raise SystemExit(f'Refusing to remove {repo}: custom checkout location {location}; manage it explicitly')
1004
- repos_path = _local_repos_path(repos_path)
1005
- name = _read_pyproject_name(d/"pyproject.toml") if (d/"pyproject.toml").exists() else None
1006
- names = [name] if name else [d.name]
1007
- in_repos = _repo_key(repo) in {_repo_key(r) for r in _load_repos(repos_path)}
1008
- if not d.exists() and not in_repos: raise SystemExit(f"Nothing to remove for {repo}")
1009
- if d.exists() and (issues := _repo_safety_issues(d)): raise SystemExit("Refusing to remove:\n" + "\n".join(f" - {i}" for i in issues))
1010
- _remove_from_repos_file(repos_path, repo)
1011
- _remove_from_pyproject(pyproject_path, names)
1012
- if d.exists():
1013
- try: ans = input(f"Remove directory {d}? [y/N] ")
1014
- except EOFError: ans = ""
1015
- if ans.strip().lower() in ("y", "yes"): shutil.rmtree(d)
998
+ local_path = _local_repos_path(repos_path)
999
+ baseline = {_repo_key(r) for r in _load_repos(repos_path)}
1000
+ local = {_repo_key(r) for r in _load_repos(local_path)}
1001
+ entries = _load_repo_entries(repos_path, root)
1002
+ targets = {}
1003
+ for repo in (repo, *repos):
1004
+ repo = _resolve_removal_target(root, repo, repos_path)
1005
+ key = _repo_key(repo)
1006
+ if key in baseline: raise SystemExit(f'{repo} belongs to the shared baseline: edit {repos_path} deliberately to remove it')
1007
+ d = _resolve_repo_dir(root, repo)
1008
+ for r,location,_ in entries:
1009
+ if _repo_key(r) == key and location.resolve() != d:
1010
+ raise SystemExit(f'Refusing to remove {repo}: custom checkout location {location}; manage it explicitly')
1011
+ if d in targets: continue
1012
+ if not d.exists() and key not in local: raise SystemExit(f"Nothing to remove for {repo}")
1013
+ if d.exists() and (issues := _repo_safety_issues(d)):
1014
+ raise SystemExit("Refusing to remove:\n" + "\n".join(f" - {i}" for i in issues))
1015
+ name = _read_pyproject_name(d/"pyproject.toml") if (d/"pyproject.toml").exists() else None
1016
+ targets[d] = repo, name or d.name
1017
+ for d,(repo,name) in targets.items():
1018
+ _remove_from_repos_file(local_path, repo)
1019
+ _remove_from_pyproject(pyproject_path, [name])
1020
+ if d.exists():
1021
+ try: ans = input(f"Remove directory {d}? [y/N] ")
1022
+ except EOFError: ans = ""
1023
+ if ans.strip().lower() in ("y", "yes"): shutil.rmtree(d)
1016
1024
  subprocess.run(["uv", "sync"], check=True, cwd=root)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastws-cli
3
- Version: 0.0.16
3
+ Version: 0.0.17
4
4
  Summary: Fast workspace tools for multi-repo management
5
5
  Author: Jeremy Howard
6
6
  License: Apache-2.0
@@ -10,7 +10,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
10
10
  Requires-Python: >=3.10
11
11
  Description-Content-Type: text/markdown
12
12
  License-File: LICENSE
13
- Requires-Dist: fastcore>=2.2.6
13
+ Requires-Dist: fastcore>=2.2.29
14
14
  Requires-Dist: fastgit>=0.1.2
15
15
  Requires-Dist: ghapi>=2.0.6
16
16
  Requires-Dist: packaging
@@ -261,15 +261,16 @@ ws-add ~/private # stays where it is, recorded in repos-local.txt with its loca
261
261
 
262
262
  ### `ws-remove`
263
263
 
264
- Delete a personal repo's clone, remove it from `repos-local.txt` and the workspace `pyproject.toml`, then run `uv sync`.
264
+ Delete one or more personal repo clones and remove them from `repos-local.txt` and the workspace `pyproject.toml`. Run `uv sync` once after processing all repos.
265
265
 
266
266
  The command refuses shared baseline members. Edit `repos.txt` deliberately to change the team's baseline. It also refuses external or custom checkout locations, which must be managed explicitly.
267
267
 
268
- Removal requires a clean Git checkout with an `origin` remote and no uncommitted changes or unpushed commits. The command always asks for confirmation before deleting anything:
268
+ Removal requires a clean Git checkout with an `origin` remote and no uncommitted changes or unpushed commits. The command validates all targets before changing anything. It asks for confirmation before deleting each checkout. Declining keeps that checkout but still removes its workspace metadata. Repeated targets are processed once:
269
269
 
270
270
  ```bash
271
271
  ws-remove owner/personal-project
272
272
  ws-remove personal-project # bare folder name also works if the directory exists
273
+ ws-remove owner/project-one project-two project-three
273
274
  ```
274
275
 
275
276
  ### `ws-releases`
@@ -1,4 +1,4 @@
1
- fastcore>=2.2.6
1
+ fastcore>=2.2.29
2
2
  fastgit>=0.1.2
3
3
  ghapi>=2.0.6
4
4
  packaging
@@ -16,7 +16,7 @@ classifiers = [
16
16
  ]
17
17
 
18
18
  dependencies = [
19
- "fastcore>=2.2.6",
19
+ "fastcore>=2.2.29",
20
20
  "fastgit>=0.1.2",
21
21
  "ghapi>=2.0.6",
22
22
  "packaging",
@@ -1,4 +1,4 @@
1
- import json, os, pytest
1
+ import json, os, runpy, shutil, sys, pytest
2
2
  from types import SimpleNamespace
3
3
  from fastgit import Git
4
4
  import fastws.core as core
@@ -6,7 +6,7 @@ import fastws.core as core
6
6
  os.environ.update(GIT_AUTHOR_NAME='fastws', GIT_AUTHOR_EMAIL='fastws@example.com',
7
7
  GIT_COMMITTER_NAME='fastws', GIT_COMMITTER_EMAIL='fastws@example.com')
8
8
 
9
- WS_META = '[project]\nname = "uvws"\ndependencies = [\n "repo1pkg",\n "keeppkg",\n]\n\n[tool.uv.sources]\nrepo1pkg = { workspace = true }\nkeeppkg = { workspace = true }\n'
9
+ WS_META = '[project]\nname = "uvws"\ndependencies = [\n "repo1pkg",\n "repo2pkg",\n "keeppkg",\n]\n\n[tool.uv.sources]\nrepo1pkg = { workspace = true }\nrepo2pkg = { workspace = true }\nkeeppkg = { workspace = true }\n'
10
10
 
11
11
 
12
12
  def mk_repo(d, origin=None):
@@ -301,47 +301,50 @@ async def test_git_repo_resolution(tmp_path):
301
301
  def test_ws_remove_workflow(tmp_path, monkeypatch, fake_uv):
302
302
  (tmp_path/'repos.txt').write_text('AnswerDotAI/keep\n')
303
303
  local = tmp_path/'repos-local.txt'
304
- local.write_text('AnswerDotAI/repo1\norg/external ../external\n')
304
+ local.write_text('AnswerDotAI/repo1\nAnswerDotAI/repo2\norg/external ../external\n')
305
305
  (tmp_path/'pyproject.toml').write_text(WS_META)
306
- repo = tmp_path/'repo1'
307
- repo.mkdir()
308
- (repo/'pyproject.toml').write_text('[project]\nname = "repo1pkg"\n')
309
- g = mk_repo(repo, origin=tmp_path/'origins'/'repo1')
310
- answer = ['y']
311
- monkeypatch.setattr('builtins.input', lambda *a: answer[0])
306
+ repo, repo2 = tmp_path/'repo1', tmp_path/'repo2'
307
+ for d in repo, repo2:
308
+ d.mkdir()
309
+ (d/'pyproject.toml').write_text(f'[project]\nname = "{d.name}pkg"\n')
310
+ g = mk_repo(d, origin=tmp_path/'origins'/d.name)
311
+ answer = ['n', 'y', 'y']
312
+ monkeypatch.setattr('builtins.input', lambda *a: answer.pop(0))
312
313
 
313
- with pytest.raises(SystemExit, match='baseline'): core.ws_remove('AnswerDotAI/keep', workspace=str(tmp_path))
314
- with pytest.raises(SystemExit, match='location'): core.ws_remove('org/external', workspace=str(tmp_path))
314
+ with pytest.raises(SystemExit, match='baseline'): core.ws_remove('repo1', 'AnswerDotAI/keep', workspace=str(tmp_path))
315
+ with pytest.raises(SystemExit, match='location'): core.ws_remove('repo1', 'org/external', workspace=str(tmp_path))
315
316
  assert 'org/external ../external' in local.read_text()
316
317
  assert (tmp_path/'repos.txt').read_text() == 'AnswerDotAI/keep\n'
317
318
  assert not fake_uv
318
319
 
319
320
  # a dirty tree refuses before any mutation
320
- (repo/'pyproject.toml').write_text('[project]\nname = "repo1pkg"\nversion = "1"\n')
321
- with pytest.raises(SystemExit): core.ws_remove('AnswerDotAI/repo1', workspace=str(tmp_path))
322
- assert repo.exists() and 'repo1' in local.read_text()
321
+ (repo2/'pyproject.toml').write_text('[project]\nname = "repo2pkg"\nversion = "1"\n')
322
+ with pytest.raises(SystemExit): core.ws_remove('AnswerDotAI/repo1', 'repo2', workspace=str(tmp_path))
323
+ assert repo.exists() and repo2.exists() and 'repo1' in local.read_text() and 'repo2' in local.read_text()
324
+ assert (tmp_path/'pyproject.toml').read_text() == WS_META
323
325
 
324
326
  # so does an unpushed commit on a clean tree
325
327
  g.commit('-a', m='ahead')
326
- with pytest.raises(SystemExit): core.ws_remove('AnswerDotAI/repo1', workspace=str(tmp_path))
327
- assert repo.exists()
328
+ with pytest.raises(SystemExit): core.ws_remove('AnswerDotAI/repo1', 'repo2', workspace=str(tmp_path))
329
+ assert repo.exists() and repo2.exists()
328
330
 
329
- # pushed and clean: answering 'n' keeps the directory but still removes the metadata and re-syncs
331
+ # Each checkout has its own prompt; duplicate targets are removed once, with one sync for the batch.
330
332
  g.push()
331
- answer[0] = 'n'
332
- core.ws_remove('AnswerDotAI/repo1', workspace=str(tmp_path))
333
- assert repo.exists()
334
- assert 'repo1' not in local.read_text()
335
- assert 'repo1pkg' not in (tmp_path/'pyproject.toml').read_text()
336
- assert ['uv', 'sync'] in fake_uv
333
+ monkeypatch.setattr(sys, 'argv', ['ws-remove', 'AnswerDotAI/repo1', 'repo2', 'repo1', '--workspace', str(tmp_path)])
334
+ with pytest.raises(SystemExit) as exc: runpy.run_path(shutil.which('ws-remove'), run_name='__main__')
335
+ assert exc.value.code in (None, 0)
336
+ assert repo.exists() and not repo2.exists()
337
+ assert 'repo1' not in local.read_text() and 'repo2' not in local.read_text()
338
+ assert 'repo1pkg' not in (content := (tmp_path/'pyproject.toml').read_text()) and 'repo2pkg' not in content
339
+ assert 'keeppkg' in content and fake_uv == [['uv', 'sync']]
337
340
 
338
341
  # answering 'y' (by folder name this time) also deletes the checkout
339
342
  local.write_text('AnswerDotAI/repo1\n')
340
343
  (tmp_path/'pyproject.toml').write_text(WS_META)
341
- answer[0] = 'y'
342
344
  core.ws_remove('repo1', workspace=str(tmp_path))
343
345
  assert not repo.exists()
344
346
  assert (tmp_path/'repos.txt').read_text() == 'AnswerDotAI/keep\n'
347
+ assert not answer
345
348
 
346
349
 
347
350
  def test_upgrade_stamp(tmp_path):
File without changes
File without changes