fastws-cli 0.0.4__tar.gz → 0.0.6__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,13 @@
1
1
  <!-- do not remove -->
2
2
 
3
- ## 0.0.4
3
+ ## 0.0.6
4
+
5
+ ### New Features
6
+
7
+ - 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))
8
+
9
+
10
+ ## 0.0.5
4
11
 
5
12
  ### New Features
6
13
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastws-cli
3
- Version: 0.0.4
3
+ Version: 0.0.6
4
4
  Summary: Fast workspace tools for multi-repo management
5
5
  Author: Jeremy Howard
6
6
  License: Apache-2.0
@@ -66,6 +66,7 @@ Show uncommitted changes and unpushed commits:
66
66
 
67
67
  ```bash
68
68
  ws-status
69
+ ws-status --branches
69
70
  ```
70
71
 
71
72
  ### `ws-branches`
@@ -79,7 +80,7 @@ ws-branches --expected develop
79
80
 
80
81
  ### `ws-sync`
81
82
 
82
- Sync the workspace metadata, install updates, and refresh Pyright editable paths.
83
+ Sync the workspace metadata, pull local repos, install updates, and refresh Pyright editable paths.
83
84
  By default it uses the active venv parent as the workspace root, so you do not need to `cd` first:
84
85
  It respects `tool.uv.workspace.members` and `exclude` when scanning local projects.
85
86
 
@@ -45,6 +45,7 @@ Show uncommitted changes and unpushed commits:
45
45
 
46
46
  ```bash
47
47
  ws-status
48
+ ws-status --branches
48
49
  ```
49
50
 
50
51
  ### `ws-branches`
@@ -58,7 +59,7 @@ ws-branches --expected develop
58
59
 
59
60
  ### `ws-sync`
60
61
 
61
- Sync the workspace metadata, install updates, and refresh Pyright editable paths.
62
+ Sync the workspace metadata, pull local repos, install updates, and refresh Pyright editable paths.
62
63
  By default it uses the active venv parent as the workspace root, so you do not need to `cd` first:
63
64
  It respects `tool.uv.workspace.members` and `exclude` when scanning local projects.
64
65
 
@@ -0,0 +1,4 @@
1
+ __version__ = "0.0.6"
2
+
3
+ from .core import *
4
+
@@ -206,22 +206,26 @@ def _write_pyright_pth_files(root: Path) -> list[Path]:
206
206
  created.append(pth)
207
207
  return created
208
208
 
209
- def _clone_one(repo: str) -> str:
210
- d = _repo_dir(repo)
211
- if Path(d).exists(): return f"✓ {d}: already exists"
209
+ def _clone_one(repo: str, root: str = ".") -> str:
210
+ d = Path(root)/_repo_dir(repo)
211
+ if d.exists(): return f"✓ {d.name}: already exists"
212
212
  try:
213
- subprocess.run(["git", "clone", f"git@github.com:{repo}.git"], check=True, capture_output=True)
214
- return f"✓ {d}: cloned"
215
- except subprocess.CalledProcessError as e: return f"✗ {d}: {e.stderr.decode().strip()}"
213
+ subprocess.run(["git", "clone", f"git@github.com:{repo}.git", str(d)], check=True, capture_output=True)
214
+ return f"✓ {d.name}: cloned"
215
+ except subprocess.CalledProcessError as e: return f"✗ {d.name}: {e.stderr.decode().strip()}"
216
216
 
217
- def _pull_one(repo: str) -> str:
218
- d = _repo_dir(repo)
219
- if not Path(d).exists(): return f"✗ {d}: directory not found"
217
+ def _pull_one(repo: str, root: str = ".") -> str:
218
+ d = Path(root)/_repo_dir(repo)
219
+ if not d.exists(): return f"✗ {d.name}: directory not found"
220
220
  try:
221
- res = subprocess.run(["git", "-C", d, "pull", "-q", "--stat"], check=True, capture_output=True, text=True)
222
- return f"✓ {d}" + (f"\n{res.stdout.strip()}" if res.stdout.strip() else "")
221
+ res = subprocess.run(["git", "-C", str(d), "pull", "-q", "--stat"], check=True, capture_output=True, text=True)
222
+ return f"✓ {d.name}" + (f"\n{res.stdout.strip()}" if res.stdout.strip() else "")
223
223
  except subprocess.CalledProcessError as e: return f"✗ {d}: {e.stderr.strip()}"
224
224
 
225
+ def _pull(repos: list[str], workers: int = 16, root: str = "."):
226
+ with ThreadPoolExecutor(max_workers=workers) as ex:
227
+ for result in as_completed([ex.submit(_pull_one, r, root) for r in repos]): print(result.result())
228
+
225
229
  def ws_clone(
226
230
  repos_file: str = "repos.txt", # File containing repo list (one per line: owner/repo)
227
231
  workers: int = 16, # Number of parallel workers
@@ -235,27 +239,30 @@ def ws_clone(
235
239
  def ws_clone_cli(
236
240
  repos_file: str = "repos.txt", # File containing repo list (one per line: owner/repo)
237
241
  workers: int = 16, # Number of parallel workers
238
- ): ws_clone(repos_file, workers)
242
+ ):
243
+ "Clone all repos from a repos file."
244
+ ws_clone(repos_file, workers)
239
245
 
240
246
  def ws_pull(
241
247
  repos_file: str = "repos.txt", # File containing repo list
242
248
  workers: int = 16, # Number of parallel workers
243
249
  ):
244
250
  "Pull updates for all repos."
245
- repos = _load_repos(repos_file)
246
- with ThreadPoolExecutor(max_workers=workers) as ex:
247
- for result in as_completed([ex.submit(_pull_one, r) for r in repos]): print(result.result())
251
+ _pull(_load_repos(repos_file), workers)
248
252
 
249
253
  @call_parse
250
254
  def ws_pull_cli(
251
255
  repos_file: str = "repos.txt", # File containing repo list
252
256
  workers: int = 16, # Number of parallel workers
253
- ): ws_pull(repos_file, workers)
257
+ ):
258
+ "Pull updates for all repos."
259
+ ws_pull(repos_file, workers)
254
260
 
255
261
  def ws_status(
256
262
  repos_file: str = "repos.txt", # File containing repo list
263
+ branches: bool = False, # Show unpushed commit details
257
264
  ):
258
- "Show uncommitted changes and unpushed commits across repos."
265
+ "Show uncommitted changes and optionally unpushed commit details across repos."
259
266
  repos = _load_repos(repos_file)
260
267
  for repo in repos:
261
268
  d = _repo_dir(repo)
@@ -265,18 +272,24 @@ def ws_status(
265
272
  changes = g.status('-s') or ""
266
273
  if isinstance(changes, list): changes = "\n".join(changes)
267
274
  unpushed = ""
268
- try: unpushed = g.log('--branches', '--not', '--remotes', format='%h %s') or ""
275
+ try:
276
+ 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 ""
269
279
  except Exception: pass
270
280
  if isinstance(unpushed, list): unpushed = "\n".join(unpushed)
271
281
  if changes or unpushed:
272
282
  print(f"\n=== {d} ===")
273
283
  if changes: print(changes)
274
- if unpushed: print(unpushed)
284
+ if unpushed: print(unpushed if branches else "unpushed commits")
275
285
 
276
286
  @call_parse
277
287
  def ws_status_cli(
278
288
  repos_file: str = "repos.txt", # File containing repo list
279
- ): ws_status(repos_file)
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)
280
293
 
281
294
  def ws_branches(
282
295
  repos_file: str = "repos.txt", # File containing repo list
@@ -300,7 +313,9 @@ def ws_branches(
300
313
  def ws_branches_cli(
301
314
  repos_file: str = "repos.txt", # File containing repo list
302
315
  expected: str = "main", # Expected branch name
303
- ): ws_branches(repos_file, expected)
316
+ ):
317
+ "Check if all repos are on the expected branch."
318
+ ws_branches(repos_file, expected)
304
319
 
305
320
  def ws_sync(
306
321
  workspace: str = "", # Workspace root; defaults to active venv parent when available
@@ -313,8 +328,10 @@ def ws_sync(
313
328
  repos_path = _resolve_path(root, repos_file)
314
329
  pyproject_path = _resolve_path(root, pyproject_file)
315
330
  template_path = _resolve_path(root, template_file)
331
+ repos = _discover_ws_repos(root)
316
332
 
317
- if missing_repos := _update_repos_file(repos_path, _discover_ws_repos(root)): print(f"Added repos: {', '.join(missing_repos)}")
333
+ if missing_repos := _update_repos_file(repos_path, repos): print(f"Added repos: {', '.join(missing_repos)}")
334
+ _pull(repos, root=str(root))
318
335
 
319
336
  if missing_projects := _sync_ws_pyproject(pyproject_path, template_path, _ws_projects(root)): print(f"Added workspace projects: {', '.join(missing_projects)}")
320
337
 
@@ -327,7 +344,9 @@ def ws_sync_cli(
327
344
  repos_file: str = "repos.txt", # Repo list to update from local git remotes
328
345
  pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
329
346
  template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
330
- ): ws_sync(workspace, repos_file, pyproject_file, template_file)
347
+ ):
348
+ "Sync workspace metadata, run uv sync -U, and refresh Pyright editable paths."
349
+ ws_sync(workspace, repos_file, pyproject_file, template_file)
331
350
 
332
351
  def ws_add(
333
352
  repo: str, # Repo to add, e.g. AnswerDotAI/fastws
@@ -343,6 +362,7 @@ def ws_add(
343
362
  added = _update_repos_file(repos_path, [repo])
344
363
  if added: print(f"Added repo: {repo}")
345
364
  else: print(f"Repo already present: {repo}")
365
+ print(_clone_one(repo, str(root)))
346
366
  ws_sync(str(root), repos_file, pyproject_file, template_file)
347
367
 
348
368
  @call_parse
@@ -352,4 +372,6 @@ def ws_add_cli(
352
372
  repos_file: str = "repos.txt", # Repo list to update
353
373
  pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
354
374
  template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
355
- ): ws_add(repo, workspace, repos_file, pyproject_file, template_file)
375
+ ):
376
+ "Add a repo to repos.txt and then run ws-sync."
377
+ ws_add(repo, workspace, repos_file, pyproject_file, template_file)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastws-cli
3
- Version: 0.0.4
3
+ Version: 0.0.6
4
4
  Summary: Fast workspace tools for multi-repo management
5
5
  Author: Jeremy Howard
6
6
  License: Apache-2.0
@@ -66,6 +66,7 @@ Show uncommitted changes and unpushed commits:
66
66
 
67
67
  ```bash
68
68
  ws-status
69
+ ws-status --branches
69
70
  ```
70
71
 
71
72
  ### `ws-branches`
@@ -79,7 +80,7 @@ ws-branches --expected develop
79
80
 
80
81
  ### `ws-sync`
81
82
 
82
- Sync the workspace metadata, install updates, and refresh Pyright editable paths.
83
+ Sync the workspace metadata, pull local repos, install updates, and refresh Pyright editable paths.
83
84
  By default it uses the active venv parent as the workspace root, so you do not need to `cd` first:
84
85
  It respects `tool.uv.workspace.members` and `exclude` when scanning local projects.
85
86
 
@@ -1,5 +1,3 @@
1
- from pathlib import Path
2
-
3
1
  import fastws.core as core
4
2
 
5
3
 
@@ -98,6 +96,9 @@ def test_ws_sync_updates_workspace_and_runs_uv(tmp_path, monkeypatch):
98
96
  if cmd[:5] == ["git", "-C", str(repo), "remote", "get-url"]:
99
97
  class Res: stdout = "git@github.com:AnswerDotAI/repo1.git\n"
100
98
  return Res()
99
+ if cmd[:4] == ["git", "-C", str(repo), "pull"]:
100
+ class Res: stdout = ""
101
+ return Res()
101
102
  if cmd == ["uv", "sync", "-U"]:
102
103
  class Res: stdout = ""
103
104
  return Res()
@@ -112,6 +113,7 @@ def test_ws_sync_updates_workspace_and_runs_uv(tmp_path, monkeypatch):
112
113
  assert 'newpkg = { workspace = true }' in pyproject
113
114
  assert '"newpkg"' in pyproject
114
115
  assert (site/"_pyright_editable_newpkg.pth").read_text() == "/tmp/ws/src/newpkg\n"
116
+ assert next(i for i,(cmd,_) in enumerate(calls) if cmd[:4] == ["git", "-C", str(repo), "pull"]) < next(i for i,(cmd,_) in enumerate(calls) if cmd == ["uv", "sync", "-U"])
115
117
  assert any(cmd == ["uv", "sync", "-U"] and kwargs["cwd"] == tmp_path for cmd,kwargs in calls)
116
118
 
117
119
 
@@ -138,6 +140,9 @@ def test_ws_sync_uses_active_venv_parent_by_default(tmp_path, monkeypatch):
138
140
  if cmd[:5] == ["git", "-C", str(repo), "remote", "get-url"]:
139
141
  class Res: stdout = "git@github.com:AnswerDotAI/repo1.git\n"
140
142
  return Res()
143
+ if cmd[:4] == ["git", "-C", str(repo), "pull"]:
144
+ class Res: stdout = ""
145
+ return Res()
141
146
  if cmd == ["uv", "sync", "-U"]:
142
147
  class Res: stdout = ""
143
148
  return Res()
@@ -153,16 +158,79 @@ def test_ws_sync_uses_active_venv_parent_by_default(tmp_path, monkeypatch):
153
158
  assert any(cmd == ["uv", "sync", "-U"] and kwargs["cwd"] == workspace for cmd,kwargs in calls)
154
159
 
155
160
 
156
- def test_ws_add_updates_repos_then_runs_sync(tmp_path, monkeypatch):
161
+ def test_ws_add_clones_and_syncs(tmp_path, monkeypatch):
157
162
  (tmp_path/"repos.txt").write_text("AnswerDotAI/existing\n")
158
- calls = []
163
+ sync_calls, clone_calls = [], []
159
164
 
160
- def fake_sync(workspace, repos_file, pyproject_file, template_file):
161
- calls.append((workspace, repos_file, pyproject_file, template_file))
165
+ def fake_sync(*a): sync_calls.append(a)
166
+ def fake_clone(repo, root="."):
167
+ clone_calls.append((repo, root))
168
+ return "✓ cloned"
162
169
 
163
170
  monkeypatch.setattr(core, "ws_sync", fake_sync)
171
+ monkeypatch.setattr(core, "_clone_one", fake_clone)
164
172
 
165
173
  core.ws_add("answerdotai/fastws", workspace=str(tmp_path))
166
174
 
167
175
  assert (tmp_path/"repos.txt").read_text() == "AnswerDotAI/existing\nanswerdotai/fastws\n"
168
- assert calls == [(str(tmp_path), "repos.txt", "pyproject.toml", "pyproject.tmpl")]
176
+ assert clone_calls == [("answerdotai/fastws", str(tmp_path))]
177
+ assert sync_calls == [(str(tmp_path), "repos.txt", "pyproject.toml", "pyproject.tmpl")]
178
+
179
+
180
+ def test_ws_status_summarizes_unpushed_commits_by_default(tmp_path, monkeypatch, capsys):
181
+ (tmp_path/"repo1").mkdir()
182
+
183
+ class FakeGit:
184
+ exists = True
185
+ def __init__(self, d): pass
186
+ def branch(self, **kwargs): return "main"
187
+ def status(self, *args): return ""
188
+ def log(self, *args, **kwargs): return "abc first commit"
189
+
190
+ monkeypatch.chdir(tmp_path)
191
+ monkeypatch.setattr(core, "_load_repos", lambda repos_file="repos.txt": ["AnswerDotAI/repo1"])
192
+ monkeypatch.setattr(core, "Git", FakeGit)
193
+
194
+ core.ws_status()
195
+ out = capsys.readouterr().out
196
+
197
+ assert "unpushed commits" in out
198
+ assert "abc first commit" not in out
199
+
200
+
201
+ def test_ws_status_ignores_unpushed_commits_off_main_by_default(tmp_path, monkeypatch, capsys):
202
+ (tmp_path/"repo1").mkdir()
203
+
204
+ class FakeGit:
205
+ exists = True
206
+ def __init__(self, d): pass
207
+ def branch(self, **kwargs): return "feature"
208
+ def status(self, *args): return ""
209
+ def log(self, *args, **kwargs): return "abc first commit"
210
+
211
+ monkeypatch.chdir(tmp_path)
212
+ monkeypatch.setattr(core, "_load_repos", lambda repos_file="repos.txt": ["AnswerDotAI/repo1"])
213
+ monkeypatch.setattr(core, "Git", FakeGit)
214
+
215
+ core.ws_status()
216
+
217
+ assert capsys.readouterr().out == ""
218
+
219
+
220
+ def test_ws_status_shows_unpushed_commits_with_branches_flag(tmp_path, monkeypatch, capsys):
221
+ (tmp_path/"repo1").mkdir()
222
+
223
+ class FakeGit:
224
+ exists = True
225
+ def __init__(self, d): pass
226
+ def branch(self, **kwargs): return "feature"
227
+ def status(self, *args): return ""
228
+ def log(self, *args, **kwargs): return "abc first commit"
229
+
230
+ monkeypatch.chdir(tmp_path)
231
+ monkeypatch.setattr(core, "_load_repos", lambda repos_file="repos.txt": ["AnswerDotAI/repo1"])
232
+ monkeypatch.setattr(core, "Git", FakeGit)
233
+
234
+ core.ws_status(branches=True)
235
+
236
+ assert "abc first commit" in capsys.readouterr().out
@@ -1,5 +0,0 @@
1
- __version__ = "0.0.4"
2
-
3
-
4
- from .core import *
5
-
File without changes
File without changes
File without changes
File without changes