fastws-cli 0.0.2__tar.gz → 0.0.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastws_cli-0.0.4/CHANGELOG.md +17 -0
- {fastws_cli-0.0.2/fastws_cli.egg-info → fastws_cli-0.0.4}/PKG-INFO +33 -12
- fastws_cli-0.0.4/README.md +77 -0
- fastws_cli-0.0.4/fastws/__init__.py +5 -0
- fastws_cli-0.0.4/fastws/core.py +355 -0
- {fastws_cli-0.0.2 → fastws_cli-0.0.4/fastws_cli.egg-info}/PKG-INFO +33 -12
- {fastws_cli-0.0.2 → fastws_cli-0.0.4}/fastws_cli.egg-info/SOURCES.txt +2 -1
- fastws_cli-0.0.4/fastws_cli.egg-info/entry_points.txt +7 -0
- {fastws_cli-0.0.2 → fastws_cli-0.0.4}/fastws_cli.egg-info/requires.txt +3 -0
- {fastws_cli-0.0.2 → fastws_cli-0.0.4}/pyproject.toml +7 -4
- fastws_cli-0.0.4/tests/test_sync.py +168 -0
- fastws_cli-0.0.2/CHANGELOG.md +0 -6
- fastws_cli-0.0.2/README.md +0 -57
- fastws_cli-0.0.2/fastws/__init__.py +0 -3
- fastws_cli-0.0.2/fastws/core.py +0 -115
- fastws_cli-0.0.2/fastws_cli.egg-info/entry_points.txt +0 -5
- {fastws_cli-0.0.2 → fastws_cli-0.0.4}/LICENSE +0 -0
- {fastws_cli-0.0.2 → fastws_cli-0.0.4}/MANIFEST.in +0 -0
- {fastws_cli-0.0.2 → fastws_cli-0.0.4}/fastws_cli.egg-info/dependency_links.txt +0 -0
- {fastws_cli-0.0.2 → fastws_cli-0.0.4}/fastws_cli.egg-info/top_level.txt +0 -0
- {fastws_cli-0.0.2 → fastws_cli-0.0.4}/setup.cfg +0 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!-- do not remove -->
|
|
2
|
+
|
|
3
|
+
## 0.0.4
|
|
4
|
+
|
|
5
|
+
### New Features
|
|
6
|
+
|
|
7
|
+
- Add ws-sync and ws-add commands with workspace metadata sync and Pyright editable path support ([#1](https://github.com/AnswerDotAI/fastws/issues/1))
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## 0.0.3
|
|
11
|
+
|
|
12
|
+
- change names
|
|
13
|
+
|
|
14
|
+
## 0.0.2
|
|
15
|
+
|
|
16
|
+
- init release
|
|
17
|
+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fastws-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.4
|
|
4
4
|
Summary: Fast workspace tools for multi-repo management
|
|
5
5
|
Author: Jeremy Howard
|
|
6
6
|
License: Apache-2.0
|
|
@@ -12,6 +12,7 @@ Description-Content-Type: text/markdown
|
|
|
12
12
|
License-File: LICENSE
|
|
13
13
|
Requires-Dist: fastcore>=1.5.29
|
|
14
14
|
Requires-Dist: fastgit>=0.0.2
|
|
15
|
+
Requires-Dist: tomli; python_version < "3.11"
|
|
15
16
|
Provides-Extra: dev
|
|
16
17
|
Requires-Dist: fastship; extra == "dev"
|
|
17
18
|
Requires-Dist: build; extra == "dev"
|
|
@@ -41,37 +42,57 @@ AnswerDotAI/fastws
|
|
|
41
42
|
|
|
42
43
|
## Commands
|
|
43
44
|
|
|
44
|
-
### `
|
|
45
|
+
### `ws-clone`
|
|
45
46
|
|
|
46
47
|
Clone all repos from your repos file:
|
|
47
48
|
|
|
48
49
|
```bash
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
ws-clone
|
|
51
|
+
ws-clone --repos-file myrepos.txt
|
|
52
|
+
ws-clone --workers 8
|
|
52
53
|
```
|
|
53
54
|
|
|
54
|
-
### `
|
|
55
|
+
### `ws-pull`
|
|
55
56
|
|
|
56
57
|
Pull updates for all repos (parallel):
|
|
57
58
|
|
|
58
59
|
```bash
|
|
59
|
-
|
|
60
|
+
ws-pull
|
|
60
61
|
```
|
|
61
62
|
|
|
62
|
-
### `
|
|
63
|
+
### `ws-status`
|
|
63
64
|
|
|
64
65
|
Show uncommitted changes and unpushed commits:
|
|
65
66
|
|
|
66
67
|
```bash
|
|
67
|
-
|
|
68
|
+
ws-status
|
|
68
69
|
```
|
|
69
70
|
|
|
70
|
-
### `
|
|
71
|
+
### `ws-branches`
|
|
71
72
|
|
|
72
73
|
Check if all repos are on the expected branch:
|
|
73
74
|
|
|
74
75
|
```bash
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
ws-branches
|
|
77
|
+
ws-branches --expected develop
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### `ws-sync`
|
|
81
|
+
|
|
82
|
+
Sync the workspace metadata, install updates, and refresh Pyright editable paths.
|
|
83
|
+
By default it uses the active venv parent as the workspace root, so you do not need to `cd` first:
|
|
84
|
+
It respects `tool.uv.workspace.members` and `exclude` when scanning local projects.
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
ws-sync
|
|
88
|
+
ws-sync --workspace ~/aai-ws
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### `ws-add`
|
|
92
|
+
|
|
93
|
+
Add a repo to `repos.txt`, then run `ws-sync`:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
ws-add AnswerDotAI/fastws
|
|
97
|
+
ws-add answerdotai/fastws
|
|
77
98
|
```
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# fastws
|
|
2
|
+
|
|
3
|
+
Fast workspace tools for multi-repo management.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install fastws-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
Create a `repos.txt` file listing your repos (one per line):
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
AnswerDotAI/fastcore
|
|
17
|
+
AnswerDotAI/fastgit
|
|
18
|
+
AnswerDotAI/fastship
|
|
19
|
+
AnswerDotAI/fastws
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Commands
|
|
23
|
+
|
|
24
|
+
### `ws-clone`
|
|
25
|
+
|
|
26
|
+
Clone all repos from your repos file:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
ws-clone
|
|
30
|
+
ws-clone --repos-file myrepos.txt
|
|
31
|
+
ws-clone --workers 8
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### `ws-pull`
|
|
35
|
+
|
|
36
|
+
Pull updates for all repos (parallel):
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
ws-pull
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### `ws-status`
|
|
43
|
+
|
|
44
|
+
Show uncommitted changes and unpushed commits:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
ws-status
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### `ws-branches`
|
|
51
|
+
|
|
52
|
+
Check if all repos are on the expected branch:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
ws-branches
|
|
56
|
+
ws-branches --expected develop
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### `ws-sync`
|
|
60
|
+
|
|
61
|
+
Sync the workspace metadata, install updates, and refresh Pyright editable paths.
|
|
62
|
+
By default it uses the active venv parent as the workspace root, so you do not need to `cd` first:
|
|
63
|
+
It respects `tool.uv.workspace.members` and `exclude` when scanning local projects.
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
ws-sync
|
|
67
|
+
ws-sync --workspace ~/aai-ws
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### `ws-add`
|
|
71
|
+
|
|
72
|
+
Add a repo to `repos.txt`, then run `ws-sync`:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
ws-add AnswerDotAI/fastws
|
|
76
|
+
ws-add answerdotai/fastws
|
|
77
|
+
```
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"Fast workspace tools for multi-repo management."
|
|
2
|
+
|
|
3
|
+
__all__ = ["ws_clone", "ws_clone_cli", "ws_pull", "ws_pull_cli", "ws_status", "ws_status_cli", "ws_branches", "ws_branches_cli",
|
|
4
|
+
"ws_sync", "ws_sync_cli", "ws_add", "ws_add_cli"]
|
|
5
|
+
|
|
6
|
+
import ast, fnmatch, os, re, shutil, subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
9
|
+
|
|
10
|
+
from fastcore.script import call_parse
|
|
11
|
+
from fastgit import Git
|
|
12
|
+
|
|
13
|
+
try: import tomllib
|
|
14
|
+
except ModuleNotFoundError: import tomli as tomllib
|
|
15
|
+
|
|
16
|
+
def _load_repos(repos_file: str = "repos.txt") -> list[str]:
|
|
17
|
+
p = Path(repos_file)
|
|
18
|
+
if not p.exists(): raise SystemExit(f"File not found: {repos_file}")
|
|
19
|
+
return [line.strip() for line in p.read_text().splitlines() if line.strip() and not line.startswith("#")]
|
|
20
|
+
|
|
21
|
+
def _repo_dir(repo: str) -> str: return repo.split("/")[-1]
|
|
22
|
+
|
|
23
|
+
def _resolve_path(root: Path, path: str) -> Path:
|
|
24
|
+
p = Path(path)
|
|
25
|
+
return p if p.is_absolute() else root/p
|
|
26
|
+
|
|
27
|
+
def _repo_key(repo: str) -> str: return repo.strip().rstrip("/").removesuffix(".git").casefold()
|
|
28
|
+
|
|
29
|
+
def _pkg_key(name: str) -> str: return name.casefold()
|
|
30
|
+
|
|
31
|
+
def _dep_key(dep: str) -> str:
|
|
32
|
+
dep = dep.split(";", 1)[0].strip()
|
|
33
|
+
dep = re.split(r"[\s<>=!~]", dep, maxsplit=1)[0]
|
|
34
|
+
return dep.split("[", 1)[0].casefold()
|
|
35
|
+
|
|
36
|
+
def _ws_root(workspace: str = "", repos_file: str = "repos.txt", pyproject_file: str = "pyproject.toml",
|
|
37
|
+
template_file: str = "pyproject.tmpl") -> Path:
|
|
38
|
+
if workspace: return Path(workspace).expanduser().resolve()
|
|
39
|
+
for env_name in "UV_PROJECT_ENVIRONMENT","VIRTUAL_ENV":
|
|
40
|
+
if not (env := os.environ.get(env_name)): continue
|
|
41
|
+
root = Path(env).expanduser().resolve().parent
|
|
42
|
+
if any((_resolve_path(root, repos_file).exists(), _resolve_path(root, pyproject_file).exists(), _resolve_path(root, template_file).exists())):
|
|
43
|
+
return root
|
|
44
|
+
return Path.cwd().resolve()
|
|
45
|
+
|
|
46
|
+
def _parse_github_repo(remote: str) -> str|None:
|
|
47
|
+
m = re.search(r"github\.com[:/](?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$", remote.strip())
|
|
48
|
+
return f"{m['owner']}/{m['repo']}" if m else None
|
|
49
|
+
|
|
50
|
+
def _normalize_repo(repo: str) -> str:
|
|
51
|
+
repo = repo.strip().rstrip("/").removesuffix(".git")
|
|
52
|
+
if parsed := _parse_github_repo(repo): return parsed
|
|
53
|
+
if re.fullmatch(r"[^/\s]+/[^/\s]+", repo): return repo
|
|
54
|
+
raise SystemExit(f"Invalid repo: {repo}. Expected owner/repo or GitHub URL")
|
|
55
|
+
|
|
56
|
+
def _ws_cfg(root: Path):
|
|
57
|
+
pyproject = root/"pyproject.toml"
|
|
58
|
+
if not pyproject.exists(): return ["./*"], []
|
|
59
|
+
try: data = tomllib.loads(pyproject.read_text())
|
|
60
|
+
except tomllib.TOMLDecodeError: return ["./*"], []
|
|
61
|
+
ws = data.get("tool", {}).get("uv", {}).get("workspace", {})
|
|
62
|
+
members = ws.get("members") or ["./*"]
|
|
63
|
+
exclude = ws.get("exclude") or []
|
|
64
|
+
return members, exclude
|
|
65
|
+
|
|
66
|
+
def _matches_ws(name: str, pattern: str) -> bool:
|
|
67
|
+
pattern = pattern.strip()
|
|
68
|
+
return any(fnmatch.fnmatch(candidate, normalized) for candidate in (name, f"./{name}") for normalized in (pattern, pattern.removeprefix("./")))
|
|
69
|
+
|
|
70
|
+
def _is_ws_dir(d: Path, members, exclude) -> bool:
|
|
71
|
+
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)
|
|
72
|
+
|
|
73
|
+
def _ws_dirs(root: Path) -> list[Path]:
|
|
74
|
+
members, exclude = _ws_cfg(root)
|
|
75
|
+
return [d for d in sorted(root.iterdir()) if _is_ws_dir(d, members, exclude)]
|
|
76
|
+
|
|
77
|
+
def _discover_ws_repos(root: Path) -> list[str]:
|
|
78
|
+
repos = []
|
|
79
|
+
for d in (o for o in _ws_dirs(root) if (o/'.git').exists()):
|
|
80
|
+
try: res = subprocess.run(["git", "-C", str(d), "remote", "get-url", "origin"], check=True, capture_output=True, text=True)
|
|
81
|
+
except subprocess.CalledProcessError: continue
|
|
82
|
+
if repo := _parse_github_repo(res.stdout): repos.append(repo)
|
|
83
|
+
return repos
|
|
84
|
+
|
|
85
|
+
def _update_repos_file(repos_path: Path, repos: list[str]) -> list[str]:
|
|
86
|
+
existing = _load_repos(repos_path) if repos_path.exists() else []
|
|
87
|
+
seen = {_repo_key(repo) for repo in existing}
|
|
88
|
+
missing = []
|
|
89
|
+
for repo in repos:
|
|
90
|
+
if (key := _repo_key(repo)) in seen: continue
|
|
91
|
+
seen.add(key)
|
|
92
|
+
missing.append(repo)
|
|
93
|
+
if not missing: return []
|
|
94
|
+
content = repos_path.read_text() if repos_path.exists() else ""
|
|
95
|
+
if content and not content.endswith("\n"): content += "\n"
|
|
96
|
+
repos_path.write_text(content + "\n".join(missing) + "\n")
|
|
97
|
+
return missing
|
|
98
|
+
|
|
99
|
+
def _read_pyproject_name(path: Path) -> str|None:
|
|
100
|
+
try: data = tomllib.loads(path.read_text())
|
|
101
|
+
except tomllib.TOMLDecodeError:
|
|
102
|
+
print(f"Skipping invalid TOML: {path}")
|
|
103
|
+
return None
|
|
104
|
+
name = data.get("project", {}).get("name")
|
|
105
|
+
return name if isinstance(name, str) and name and "{" not in name and "}" not in name else None
|
|
106
|
+
|
|
107
|
+
def _ws_projects(root: Path) -> list[str]:
|
|
108
|
+
return [name for d in (o for o in _ws_dirs(root) if (o/"pyproject.toml").exists())
|
|
109
|
+
if (name := _read_pyproject_name(d/"pyproject.toml"))]
|
|
110
|
+
|
|
111
|
+
def _table_span(content: str, name: str) -> tuple[int,int]|None:
|
|
112
|
+
m = re.search(rf"(?m)^\[{re.escape(name)}\]\s*$", content)
|
|
113
|
+
if not m: return None
|
|
114
|
+
n = re.search(r"(?m)^\[", content[m.end():])
|
|
115
|
+
end = m.end()+n.start() if n else len(content)
|
|
116
|
+
return m.start(), end
|
|
117
|
+
|
|
118
|
+
def _replace_table(content: str, name: str, body: str) -> str:
|
|
119
|
+
table = f"[{name}]\n{body.rstrip()}\n\n"
|
|
120
|
+
if not (span := _table_span(content, name)): return content.rstrip() + "\n\n" + table
|
|
121
|
+
start,end = span
|
|
122
|
+
return content[:start] + table + content[end:]
|
|
123
|
+
|
|
124
|
+
def _find_array_end(content: str, start: int) -> int:
|
|
125
|
+
depth = 0
|
|
126
|
+
in_str = escaped = False
|
|
127
|
+
for i,ch in enumerate(content[start:], start):
|
|
128
|
+
if in_str:
|
|
129
|
+
if escaped: escaped = False
|
|
130
|
+
elif ch == "\\": escaped = True
|
|
131
|
+
elif ch == '"': in_str = False
|
|
132
|
+
continue
|
|
133
|
+
if ch == '"': in_str = True
|
|
134
|
+
elif ch == "[": depth += 1
|
|
135
|
+
elif ch == "]":
|
|
136
|
+
depth -= 1
|
|
137
|
+
if depth == 0: return i
|
|
138
|
+
raise ValueError("Unterminated TOML array")
|
|
139
|
+
|
|
140
|
+
def _replace_project_dependencies(content: str, deps: list[str]) -> str:
|
|
141
|
+
if not (span := _table_span(content, "project")): raise SystemExit("Missing [project] table in pyproject.toml")
|
|
142
|
+
start,end = span
|
|
143
|
+
section = content[start:end]
|
|
144
|
+
dep_block = "dependencies = [\n" + "".join(f' "{dep}",\n' for dep in deps) + "]"
|
|
145
|
+
if m := re.search(r"(?m)^dependencies\s*=\s*\[", section):
|
|
146
|
+
arr_start = m.end()-1
|
|
147
|
+
arr_end = _find_array_end(section, arr_start)
|
|
148
|
+
section = section[:m.start()] + dep_block + section[arr_end+1:]
|
|
149
|
+
else: section = section.rstrip() + "\n" + dep_block + "\n"
|
|
150
|
+
return content[:start] + section + content[end:]
|
|
151
|
+
|
|
152
|
+
def _sync_ws_pyproject(pyproject_path: Path, template_path: Path, projects: list[str]) -> list[str]:
|
|
153
|
+
if not pyproject_path.exists():
|
|
154
|
+
if not template_path.exists(): raise SystemExit(f"File not found: {template_path}")
|
|
155
|
+
shutil.copyfile(template_path, pyproject_path)
|
|
156
|
+
content = pyproject_path.read_text()
|
|
157
|
+
data = tomllib.loads(content)
|
|
158
|
+
sources = dict(data.get("tool", {}).get("uv", {}).get("sources", {}))
|
|
159
|
+
source_keys = {_pkg_key(proj) for proj in sources}
|
|
160
|
+
missing = [proj for proj in projects if _pkg_key(proj) not in source_keys]
|
|
161
|
+
if not missing: return []
|
|
162
|
+
for proj in missing: sources[proj] = {"workspace": True}
|
|
163
|
+
deps = list(data.get("project", {}).get("dependencies", []))
|
|
164
|
+
dep_keys = {_dep_key(dep) for dep in deps}
|
|
165
|
+
for proj in missing:
|
|
166
|
+
if _pkg_key(proj) in dep_keys: continue
|
|
167
|
+
deps.append(proj)
|
|
168
|
+
dep_keys.add(_pkg_key(proj))
|
|
169
|
+
source_lines = "\n".join(f"{proj} = {{ workspace = true }}" for proj in sources)
|
|
170
|
+
content = _replace_table(content, "tool.uv.sources", source_lines)
|
|
171
|
+
content = _replace_project_dependencies(content, deps)
|
|
172
|
+
pyproject_path.write_text(content)
|
|
173
|
+
return missing
|
|
174
|
+
|
|
175
|
+
def _editable_mapping(path: Path) -> dict[str,str]:
|
|
176
|
+
tree = ast.parse(path.read_text(), filename=str(path))
|
|
177
|
+
for node in tree.body:
|
|
178
|
+
if isinstance(node, ast.Assign) and any(isinstance(o, ast.Name) and o.id == "MAPPING" for o in node.targets):
|
|
179
|
+
data = ast.literal_eval(node.value)
|
|
180
|
+
if isinstance(data, dict): return {str(k): str(v) for k,v in data.items()}
|
|
181
|
+
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name) and node.target.id == "MAPPING":
|
|
182
|
+
data = ast.literal_eval(node.value)
|
|
183
|
+
if isinstance(data, dict): return {str(k): str(v) for k,v in data.items()}
|
|
184
|
+
return {}
|
|
185
|
+
|
|
186
|
+
def _site_packages(root: Path) -> Path|None:
|
|
187
|
+
envs = []
|
|
188
|
+
if uv_env := os.environ.get("UV_PROJECT_ENVIRONMENT"): envs.append(Path(uv_env))
|
|
189
|
+
envs.append(root/".venv")
|
|
190
|
+
if virtual_env := os.environ.get("VIRTUAL_ENV"): envs.append(Path(virtual_env))
|
|
191
|
+
for env in envs:
|
|
192
|
+
candidates = sorted(env.glob("lib/python*/site-packages")) + sorted(env.glob("Lib/site-packages"))
|
|
193
|
+
if candidates: return candidates[0]
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
def _write_pyright_pth_files(root: Path) -> list[Path]:
|
|
197
|
+
site = _site_packages(root)
|
|
198
|
+
if not site:
|
|
199
|
+
print("No site-packages directory found for editable Pyright paths")
|
|
200
|
+
return []
|
|
201
|
+
created = []
|
|
202
|
+
for finder in sorted(site.glob("__editable__*_finder.py")):
|
|
203
|
+
for pkg,path in _editable_mapping(finder).items():
|
|
204
|
+
pth = site/f"_pyright_editable_{pkg}.pth"
|
|
205
|
+
pth.write_text(str(Path(path).parent) + "\n")
|
|
206
|
+
created.append(pth)
|
|
207
|
+
return created
|
|
208
|
+
|
|
209
|
+
def _clone_one(repo: str) -> str:
|
|
210
|
+
d = _repo_dir(repo)
|
|
211
|
+
if Path(d).exists(): return f"✓ {d}: already exists"
|
|
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()}"
|
|
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"
|
|
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 "")
|
|
223
|
+
except subprocess.CalledProcessError as e: return f"✗ {d}: {e.stderr.strip()}"
|
|
224
|
+
|
|
225
|
+
def ws_clone(
|
|
226
|
+
repos_file: str = "repos.txt", # File containing repo list (one per line: owner/repo)
|
|
227
|
+
workers: int = 16, # Number of parallel workers
|
|
228
|
+
):
|
|
229
|
+
"Clone all repos from a repos file."
|
|
230
|
+
repos = _load_repos(repos_file)
|
|
231
|
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
232
|
+
for result in as_completed([ex.submit(_clone_one, r) for r in repos]): print(result.result())
|
|
233
|
+
|
|
234
|
+
@call_parse
|
|
235
|
+
def ws_clone_cli(
|
|
236
|
+
repos_file: str = "repos.txt", # File containing repo list (one per line: owner/repo)
|
|
237
|
+
workers: int = 16, # Number of parallel workers
|
|
238
|
+
): ws_clone(repos_file, workers)
|
|
239
|
+
|
|
240
|
+
def ws_pull(
|
|
241
|
+
repos_file: str = "repos.txt", # File containing repo list
|
|
242
|
+
workers: int = 16, # Number of parallel workers
|
|
243
|
+
):
|
|
244
|
+
"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())
|
|
248
|
+
|
|
249
|
+
@call_parse
|
|
250
|
+
def ws_pull_cli(
|
|
251
|
+
repos_file: str = "repos.txt", # File containing repo list
|
|
252
|
+
workers: int = 16, # Number of parallel workers
|
|
253
|
+
): ws_pull(repos_file, workers)
|
|
254
|
+
|
|
255
|
+
def ws_status(
|
|
256
|
+
repos_file: str = "repos.txt", # File containing repo list
|
|
257
|
+
):
|
|
258
|
+
"Show uncommitted changes and unpushed commits across repos."
|
|
259
|
+
repos = _load_repos(repos_file)
|
|
260
|
+
for repo in repos:
|
|
261
|
+
d = _repo_dir(repo)
|
|
262
|
+
if not Path(d).exists(): continue
|
|
263
|
+
g = Git(d)
|
|
264
|
+
if not g.exists: continue
|
|
265
|
+
changes = g.status('-s') or ""
|
|
266
|
+
if isinstance(changes, list): changes = "\n".join(changes)
|
|
267
|
+
unpushed = ""
|
|
268
|
+
try: unpushed = g.log('--branches', '--not', '--remotes', format='%h %s') or ""
|
|
269
|
+
except Exception: pass
|
|
270
|
+
if isinstance(unpushed, list): unpushed = "\n".join(unpushed)
|
|
271
|
+
if changes or unpushed:
|
|
272
|
+
print(f"\n=== {d} ===")
|
|
273
|
+
if changes: print(changes)
|
|
274
|
+
if unpushed: print(unpushed)
|
|
275
|
+
|
|
276
|
+
@call_parse
|
|
277
|
+
def ws_status_cli(
|
|
278
|
+
repos_file: str = "repos.txt", # File containing repo list
|
|
279
|
+
): ws_status(repos_file)
|
|
280
|
+
|
|
281
|
+
def ws_branches(
|
|
282
|
+
repos_file: str = "repos.txt", # File containing repo list
|
|
283
|
+
expected: str = "main", # Expected branch name
|
|
284
|
+
):
|
|
285
|
+
"Check if all repos are on the expected branch."
|
|
286
|
+
repos = _load_repos(repos_file)
|
|
287
|
+
for repo in repos:
|
|
288
|
+
d = _repo_dir(repo)
|
|
289
|
+
if not Path(d).exists():
|
|
290
|
+
print(f"⚠️ {d}: directory not found")
|
|
291
|
+
continue
|
|
292
|
+
g = Git(d)
|
|
293
|
+
if not g.exists:
|
|
294
|
+
print(f"⚠️ {d}: not a git repo")
|
|
295
|
+
continue
|
|
296
|
+
branch = g.branch(show_current=True).strip()
|
|
297
|
+
print(f"✓ {d}: OK (on {expected})" if branch == expected else f"⚠️ {d}: WARNING (on {branch})")
|
|
298
|
+
|
|
299
|
+
@call_parse
|
|
300
|
+
def ws_branches_cli(
|
|
301
|
+
repos_file: str = "repos.txt", # File containing repo list
|
|
302
|
+
expected: str = "main", # Expected branch name
|
|
303
|
+
): ws_branches(repos_file, expected)
|
|
304
|
+
|
|
305
|
+
def ws_sync(
|
|
306
|
+
workspace: str = "", # Workspace root; defaults to active venv parent when available
|
|
307
|
+
repos_file: str = "repos.txt", # Repo list to update from local git remotes
|
|
308
|
+
pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
|
|
309
|
+
template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
|
|
310
|
+
):
|
|
311
|
+
"Sync workspace metadata, run uv sync -U, and refresh Pyright editable paths."
|
|
312
|
+
root = _ws_root(workspace, repos_file, pyproject_file, template_file)
|
|
313
|
+
repos_path = _resolve_path(root, repos_file)
|
|
314
|
+
pyproject_path = _resolve_path(root, pyproject_file)
|
|
315
|
+
template_path = _resolve_path(root, template_file)
|
|
316
|
+
|
|
317
|
+
if missing_repos := _update_repos_file(repos_path, _discover_ws_repos(root)): print(f"Added repos: {', '.join(missing_repos)}")
|
|
318
|
+
|
|
319
|
+
if missing_projects := _sync_ws_pyproject(pyproject_path, template_path, _ws_projects(root)): print(f"Added workspace projects: {', '.join(missing_projects)}")
|
|
320
|
+
|
|
321
|
+
subprocess.run(["uv", "sync", "-U"], check=True, cwd=root)
|
|
322
|
+
_write_pyright_pth_files(root)
|
|
323
|
+
|
|
324
|
+
@call_parse
|
|
325
|
+
def ws_sync_cli(
|
|
326
|
+
workspace: str = "", # Workspace root; defaults to active venv parent when available
|
|
327
|
+
repos_file: str = "repos.txt", # Repo list to update from local git remotes
|
|
328
|
+
pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
|
|
329
|
+
template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
|
|
330
|
+
): ws_sync(workspace, repos_file, pyproject_file, template_file)
|
|
331
|
+
|
|
332
|
+
def ws_add(
|
|
333
|
+
repo: str, # Repo to add, e.g. AnswerDotAI/fastws
|
|
334
|
+
workspace: str = "", # Workspace root; defaults to active venv parent when available
|
|
335
|
+
repos_file: str = "repos.txt", # Repo list to update
|
|
336
|
+
pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
|
|
337
|
+
template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
|
|
338
|
+
):
|
|
339
|
+
"Add a repo to repos.txt and then run ws-sync."
|
|
340
|
+
root = _ws_root(workspace, repos_file, pyproject_file, template_file)
|
|
341
|
+
repos_path = _resolve_path(root, repos_file)
|
|
342
|
+
repo = _normalize_repo(repo)
|
|
343
|
+
added = _update_repos_file(repos_path, [repo])
|
|
344
|
+
if added: print(f"Added repo: {repo}")
|
|
345
|
+
else: print(f"Repo already present: {repo}")
|
|
346
|
+
ws_sync(str(root), repos_file, pyproject_file, template_file)
|
|
347
|
+
|
|
348
|
+
@call_parse
|
|
349
|
+
def ws_add_cli(
|
|
350
|
+
repo: str, # Repo to add, e.g. AnswerDotAI/fastws
|
|
351
|
+
workspace: str = "", # Workspace root; defaults to active venv parent when available
|
|
352
|
+
repos_file: str = "repos.txt", # Repo list to update
|
|
353
|
+
pyproject_file: str = "pyproject.toml", # Workspace pyproject to update
|
|
354
|
+
template_file: str = "pyproject.tmpl", # Template copied when pyproject.toml is missing
|
|
355
|
+
): 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.
|
|
3
|
+
Version: 0.0.4
|
|
4
4
|
Summary: Fast workspace tools for multi-repo management
|
|
5
5
|
Author: Jeremy Howard
|
|
6
6
|
License: Apache-2.0
|
|
@@ -12,6 +12,7 @@ Description-Content-Type: text/markdown
|
|
|
12
12
|
License-File: LICENSE
|
|
13
13
|
Requires-Dist: fastcore>=1.5.29
|
|
14
14
|
Requires-Dist: fastgit>=0.0.2
|
|
15
|
+
Requires-Dist: tomli; python_version < "3.11"
|
|
15
16
|
Provides-Extra: dev
|
|
16
17
|
Requires-Dist: fastship; extra == "dev"
|
|
17
18
|
Requires-Dist: build; extra == "dev"
|
|
@@ -41,37 +42,57 @@ AnswerDotAI/fastws
|
|
|
41
42
|
|
|
42
43
|
## Commands
|
|
43
44
|
|
|
44
|
-
### `
|
|
45
|
+
### `ws-clone`
|
|
45
46
|
|
|
46
47
|
Clone all repos from your repos file:
|
|
47
48
|
|
|
48
49
|
```bash
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
ws-clone
|
|
51
|
+
ws-clone --repos-file myrepos.txt
|
|
52
|
+
ws-clone --workers 8
|
|
52
53
|
```
|
|
53
54
|
|
|
54
|
-
### `
|
|
55
|
+
### `ws-pull`
|
|
55
56
|
|
|
56
57
|
Pull updates for all repos (parallel):
|
|
57
58
|
|
|
58
59
|
```bash
|
|
59
|
-
|
|
60
|
+
ws-pull
|
|
60
61
|
```
|
|
61
62
|
|
|
62
|
-
### `
|
|
63
|
+
### `ws-status`
|
|
63
64
|
|
|
64
65
|
Show uncommitted changes and unpushed commits:
|
|
65
66
|
|
|
66
67
|
```bash
|
|
67
|
-
|
|
68
|
+
ws-status
|
|
68
69
|
```
|
|
69
70
|
|
|
70
|
-
### `
|
|
71
|
+
### `ws-branches`
|
|
71
72
|
|
|
72
73
|
Check if all repos are on the expected branch:
|
|
73
74
|
|
|
74
75
|
```bash
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
ws-branches
|
|
77
|
+
ws-branches --expected develop
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### `ws-sync`
|
|
81
|
+
|
|
82
|
+
Sync the workspace metadata, install updates, and refresh Pyright editable paths.
|
|
83
|
+
By default it uses the active venv parent as the workspace root, so you do not need to `cd` first:
|
|
84
|
+
It respects `tool.uv.workspace.members` and `exclude` when scanning local projects.
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
ws-sync
|
|
88
|
+
ws-sync --workspace ~/aai-ws
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### `ws-add`
|
|
92
|
+
|
|
93
|
+
Add a repo to `repos.txt`, then run `ws-sync`:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
ws-add AnswerDotAI/fastws
|
|
97
|
+
ws-add answerdotai/fastws
|
|
77
98
|
```
|
|
@@ -18,6 +18,7 @@ classifiers = [
|
|
|
18
18
|
dependencies = [
|
|
19
19
|
"fastcore>=1.5.29",
|
|
20
20
|
"fastgit>=0.0.2",
|
|
21
|
+
"tomli; python_version < '3.11'",
|
|
21
22
|
]
|
|
22
23
|
|
|
23
24
|
[project.optional-dependencies]
|
|
@@ -28,10 +29,12 @@ dev = [
|
|
|
28
29
|
]
|
|
29
30
|
|
|
30
31
|
[project.scripts]
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
ws-clone = "fastws.core:ws_clone_cli"
|
|
33
|
+
ws-pull = "fastws.core:ws_pull_cli"
|
|
34
|
+
ws-status = "fastws.core:ws_status_cli"
|
|
35
|
+
ws-branches = "fastws.core:ws_branches_cli"
|
|
36
|
+
ws-sync = "fastws.core:ws_sync_cli"
|
|
37
|
+
ws-add = "fastws.core:ws_add_cli"
|
|
35
38
|
|
|
36
39
|
[project.urls]
|
|
37
40
|
Homepage = "https://github.com/AnswerDotAI/fastws"
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import fastws.core as core
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_update_repos_file_appends_missing_entries(tmp_path):
|
|
7
|
+
repos_path = tmp_path/"repos.txt"
|
|
8
|
+
repos_path.write_text("AnswerDotAI/existing\n")
|
|
9
|
+
|
|
10
|
+
added = core._update_repos_file(repos_path, ["AnswerDotAI/existing", "fastai/fastai"])
|
|
11
|
+
|
|
12
|
+
assert added == ["fastai/fastai"]
|
|
13
|
+
assert repos_path.read_text() == "AnswerDotAI/existing\nfastai/fastai\n"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_update_repos_file_is_case_insensitive(tmp_path):
|
|
17
|
+
repos_path = tmp_path/"repos.txt"
|
|
18
|
+
repos_path.write_text("AnswerDotAI/fastws\n")
|
|
19
|
+
|
|
20
|
+
added = core._update_repos_file(repos_path, ["answerdotai/fastws"])
|
|
21
|
+
|
|
22
|
+
assert added == []
|
|
23
|
+
assert repos_path.read_text() == "AnswerDotAI/fastws\n"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_sync_workspace_pyproject_copies_template_and_adds_projects(tmp_path):
|
|
27
|
+
(tmp_path/"pyproject.tmpl").write_text('[project]\nname = "uvws"\ndependencies = [\n "ipython>=8.34.0",\n]\n\n[tool.uv.sources]\n\n')
|
|
28
|
+
alpha = tmp_path/"alpha"
|
|
29
|
+
beta = tmp_path/"beta"
|
|
30
|
+
alpha.mkdir()
|
|
31
|
+
beta.mkdir()
|
|
32
|
+
(alpha/"pyproject.toml").write_text('[project]\nname = "alpha"\n')
|
|
33
|
+
(beta/"pyproject.toml").write_text('[project]\nname = "beta"\n')
|
|
34
|
+
|
|
35
|
+
added = core._sync_ws_pyproject(tmp_path/"pyproject.toml", tmp_path/"pyproject.tmpl", ["alpha", "beta"])
|
|
36
|
+
content = (tmp_path/"pyproject.toml").read_text()
|
|
37
|
+
|
|
38
|
+
assert added == ["alpha", "beta"]
|
|
39
|
+
assert 'alpha = { workspace = true }' in content
|
|
40
|
+
assert 'beta = { workspace = true }' in content
|
|
41
|
+
assert '"alpha"' in content
|
|
42
|
+
assert '"beta"' in content
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_sync_workspace_pyproject_skips_case_only_source_differences(tmp_path):
|
|
46
|
+
pyproject = tmp_path/"pyproject.toml"
|
|
47
|
+
pyproject.write_text('[project]\nname = "uvws"\ndependencies = ["FastWS"]\n\n[tool.uv.sources]\nFastWS = { workspace = true }\n')
|
|
48
|
+
|
|
49
|
+
added = core._sync_ws_pyproject(pyproject, tmp_path/"pyproject.tmpl", ["fastws"])
|
|
50
|
+
|
|
51
|
+
assert added == []
|
|
52
|
+
assert pyproject.read_text() == '[project]\nname = "uvws"\ndependencies = ["FastWS"]\n\n[tool.uv.sources]\nFastWS = { workspace = true }\n'
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_workspace_projects_skip_excluded_dirs_and_template_names(tmp_path):
|
|
56
|
+
(tmp_path/"pyproject.toml").write_text('[tool.uv.workspace]\nmembers = ["./*"]\nexclude = ["skip-*"]\n')
|
|
57
|
+
keep = tmp_path/"keep"
|
|
58
|
+
skip = tmp_path/"skip-template"
|
|
59
|
+
templ = tmp_path/"template"
|
|
60
|
+
keep.mkdir()
|
|
61
|
+
skip.mkdir()
|
|
62
|
+
templ.mkdir()
|
|
63
|
+
(keep/"pyproject.toml").write_text('[project]\nname = "keepme"\n')
|
|
64
|
+
(skip/"pyproject.toml").write_text('[project]\nname = "skipme"\n')
|
|
65
|
+
(templ/"pyproject.toml").write_text('[project]\nname = "{repo}"\n')
|
|
66
|
+
|
|
67
|
+
assert core._ws_projects(tmp_path) == ["keepme"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_write_pyright_pth_files_from_editable_finder(tmp_path):
|
|
71
|
+
site = tmp_path/".venv"/"lib"/"python3.12"/"site-packages"
|
|
72
|
+
site.mkdir(parents=True)
|
|
73
|
+
(site/"__editable___demo_finder.py").write_text("MAPPING: dict[str, str] = {'demo': '/tmp/workspace/src/demo/__init__.py', 'tool': '/tmp/workspace/tool.py'}\n")
|
|
74
|
+
|
|
75
|
+
created = core._write_pyright_pth_files(tmp_path)
|
|
76
|
+
|
|
77
|
+
assert [p.name for p in created] == ["_pyright_editable_demo.pth", "_pyright_editable_tool.pth"]
|
|
78
|
+
assert (site/"_pyright_editable_demo.pth").read_text() == "/tmp/workspace/src/demo\n"
|
|
79
|
+
assert (site/"_pyright_editable_tool.pth").read_text() == "/tmp/workspace\n"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_ws_sync_updates_workspace_and_runs_uv(tmp_path, monkeypatch):
|
|
83
|
+
(tmp_path/"repos.txt").write_text("AnswerDotAI/existing\n")
|
|
84
|
+
(tmp_path/"pyproject.tmpl").write_text('[project]\nname = "uvws"\ndependencies = [\n]\n\n[tool.uv.sources]\n\n')
|
|
85
|
+
pkg = tmp_path/"newpkg"
|
|
86
|
+
repo = tmp_path/"repo1"
|
|
87
|
+
pkg.mkdir()
|
|
88
|
+
repo.mkdir()
|
|
89
|
+
(pkg/"pyproject.toml").write_text('[project]\nname = "newpkg"\n')
|
|
90
|
+
(repo/".git").write_text("gitdir: .git/worktrees/repo1\n")
|
|
91
|
+
site = tmp_path/".venv"/"lib"/"python3.12"/"site-packages"
|
|
92
|
+
site.mkdir(parents=True)
|
|
93
|
+
(site/"__editable___newpkg_finder.py").write_text("MAPPING: dict[str, str] = {'newpkg': '/tmp/ws/src/newpkg/__init__.py'}\n")
|
|
94
|
+
calls = []
|
|
95
|
+
|
|
96
|
+
def fake_run(cmd, **kwargs):
|
|
97
|
+
calls.append((cmd, kwargs))
|
|
98
|
+
if cmd[:5] == ["git", "-C", str(repo), "remote", "get-url"]:
|
|
99
|
+
class Res: stdout = "git@github.com:AnswerDotAI/repo1.git\n"
|
|
100
|
+
return Res()
|
|
101
|
+
if cmd == ["uv", "sync", "-U"]:
|
|
102
|
+
class Res: stdout = ""
|
|
103
|
+
return Res()
|
|
104
|
+
raise AssertionError(f"Unexpected command: {cmd}")
|
|
105
|
+
|
|
106
|
+
monkeypatch.setattr(core.subprocess, "run", fake_run)
|
|
107
|
+
|
|
108
|
+
core.ws_sync(workspace=str(tmp_path))
|
|
109
|
+
|
|
110
|
+
assert "AnswerDotAI/repo1" in (tmp_path/"repos.txt").read_text()
|
|
111
|
+
pyproject = (tmp_path/"pyproject.toml").read_text()
|
|
112
|
+
assert 'newpkg = { workspace = true }' in pyproject
|
|
113
|
+
assert '"newpkg"' in pyproject
|
|
114
|
+
assert (site/"_pyright_editable_newpkg.pth").read_text() == "/tmp/ws/src/newpkg\n"
|
|
115
|
+
assert any(cmd == ["uv", "sync", "-U"] and kwargs["cwd"] == tmp_path for cmd,kwargs in calls)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_ws_sync_uses_active_venv_parent_by_default(tmp_path, monkeypatch):
|
|
119
|
+
workspace = tmp_path/"workspace"
|
|
120
|
+
elsewhere = tmp_path/"elsewhere"
|
|
121
|
+
workspace.mkdir()
|
|
122
|
+
elsewhere.mkdir()
|
|
123
|
+
(workspace/"repos.txt").write_text("AnswerDotAI/existing\n")
|
|
124
|
+
(workspace/"pyproject.tmpl").write_text('[project]\nname = "uvws"\ndependencies = [\n]\n\n[tool.uv.sources]\n\n')
|
|
125
|
+
pkg = workspace/"newpkg"
|
|
126
|
+
repo = workspace/"repo1"
|
|
127
|
+
pkg.mkdir()
|
|
128
|
+
repo.mkdir()
|
|
129
|
+
(pkg/"pyproject.toml").write_text('[project]\nname = "newpkg"\n')
|
|
130
|
+
(repo/".git").write_text("gitdir: .git/worktrees/repo1\n")
|
|
131
|
+
site = workspace/".venv"/"lib"/"python3.12"/"site-packages"
|
|
132
|
+
site.mkdir(parents=True)
|
|
133
|
+
(site/"__editable___newpkg_finder.py").write_text("MAPPING: dict[str, str] = {'newpkg': '/tmp/ws/src/newpkg/__init__.py'}\n")
|
|
134
|
+
calls = []
|
|
135
|
+
|
|
136
|
+
def fake_run(cmd, **kwargs):
|
|
137
|
+
calls.append((cmd, kwargs))
|
|
138
|
+
if cmd[:5] == ["git", "-C", str(repo), "remote", "get-url"]:
|
|
139
|
+
class Res: stdout = "git@github.com:AnswerDotAI/repo1.git\n"
|
|
140
|
+
return Res()
|
|
141
|
+
if cmd == ["uv", "sync", "-U"]:
|
|
142
|
+
class Res: stdout = ""
|
|
143
|
+
return Res()
|
|
144
|
+
raise AssertionError(f"Unexpected command: {cmd}")
|
|
145
|
+
|
|
146
|
+
monkeypatch.setattr(core.subprocess, "run", fake_run)
|
|
147
|
+
monkeypatch.setenv("VIRTUAL_ENV", str(workspace/".venv"))
|
|
148
|
+
monkeypatch.chdir(elsewhere)
|
|
149
|
+
|
|
150
|
+
core.ws_sync()
|
|
151
|
+
|
|
152
|
+
assert "AnswerDotAI/repo1" in (workspace/"repos.txt").read_text()
|
|
153
|
+
assert any(cmd == ["uv", "sync", "-U"] and kwargs["cwd"] == workspace for cmd,kwargs in calls)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_ws_add_updates_repos_then_runs_sync(tmp_path, monkeypatch):
|
|
157
|
+
(tmp_path/"repos.txt").write_text("AnswerDotAI/existing\n")
|
|
158
|
+
calls = []
|
|
159
|
+
|
|
160
|
+
def fake_sync(workspace, repos_file, pyproject_file, template_file):
|
|
161
|
+
calls.append((workspace, repos_file, pyproject_file, template_file))
|
|
162
|
+
|
|
163
|
+
monkeypatch.setattr(core, "ws_sync", fake_sync)
|
|
164
|
+
|
|
165
|
+
core.ws_add("answerdotai/fastws", workspace=str(tmp_path))
|
|
166
|
+
|
|
167
|
+
assert (tmp_path/"repos.txt").read_text() == "AnswerDotAI/existing\nanswerdotai/fastws\n"
|
|
168
|
+
assert calls == [(str(tmp_path), "repos.txt", "pyproject.toml", "pyproject.tmpl")]
|
fastws_cli-0.0.2/CHANGELOG.md
DELETED
fastws_cli-0.0.2/README.md
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
# fastws
|
|
2
|
-
|
|
3
|
-
Fast workspace tools for multi-repo management.
|
|
4
|
-
|
|
5
|
-
## Install
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
pip install fastws-cli
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
## Setup
|
|
12
|
-
|
|
13
|
-
Create a `repos.txt` file listing your repos (one per line):
|
|
14
|
-
|
|
15
|
-
```
|
|
16
|
-
AnswerDotAI/fastcore
|
|
17
|
-
AnswerDotAI/fastgit
|
|
18
|
-
AnswerDotAI/fastship
|
|
19
|
-
AnswerDotAI/fastws
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
## Commands
|
|
23
|
-
|
|
24
|
-
### `ws_clone`
|
|
25
|
-
|
|
26
|
-
Clone all repos from your repos file:
|
|
27
|
-
|
|
28
|
-
```bash
|
|
29
|
-
ws_clone
|
|
30
|
-
ws_clone --repos-file myrepos.txt
|
|
31
|
-
ws_clone --workers 8
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
### `ws_pull`
|
|
35
|
-
|
|
36
|
-
Pull updates for all repos (parallel):
|
|
37
|
-
|
|
38
|
-
```bash
|
|
39
|
-
ws_pull
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
### `ws_status`
|
|
43
|
-
|
|
44
|
-
Show uncommitted changes and unpushed commits:
|
|
45
|
-
|
|
46
|
-
```bash
|
|
47
|
-
ws_status
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
### `ws_branches`
|
|
51
|
-
|
|
52
|
-
Check if all repos are on the expected branch:
|
|
53
|
-
|
|
54
|
-
```bash
|
|
55
|
-
ws_branches
|
|
56
|
-
ws_branches --expected develop
|
|
57
|
-
```
|
fastws_cli-0.0.2/fastws/core.py
DELETED
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
"Fast workspace tools for multi-repo management."
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
__all__ = ["ws_clone", "ws_clone_cli", "ws_pull", "ws_pull_cli", "ws_status", "ws_status_cli", "ws_branches", "ws_branches_cli"]
|
|
6
|
-
|
|
7
|
-
import subprocess
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
10
|
-
|
|
11
|
-
from fastcore.script import call_parse
|
|
12
|
-
from fastgit import Git
|
|
13
|
-
|
|
14
|
-
def _load_repos(repos_file: str = "repos.txt") -> list[str]:
|
|
15
|
-
p = Path(repos_file)
|
|
16
|
-
if not p.exists(): raise SystemExit(f"File not found: {repos_file}")
|
|
17
|
-
return [line.strip() for line in p.read_text().splitlines() if line.strip() and not line.startswith("#")]
|
|
18
|
-
|
|
19
|
-
def _repo_dir(repo: str) -> str: return repo.split("/")[-1]
|
|
20
|
-
|
|
21
|
-
def _clone_one(repo: str) -> str:
|
|
22
|
-
d = _repo_dir(repo)
|
|
23
|
-
if Path(d).exists(): return f"✓ {d}: already exists"
|
|
24
|
-
try:
|
|
25
|
-
subprocess.run(["git", "clone", f"git@github.com:{repo}.git"], check=True, capture_output=True)
|
|
26
|
-
return f"✓ {d}: cloned"
|
|
27
|
-
except subprocess.CalledProcessError as e: return f"✗ {d}: {e.stderr.decode().strip()}"
|
|
28
|
-
|
|
29
|
-
def _pull_one(repo: str) -> str:
|
|
30
|
-
d = _repo_dir(repo)
|
|
31
|
-
if not Path(d).exists(): return f"✗ {d}: directory not found"
|
|
32
|
-
try:
|
|
33
|
-
res = subprocess.run(["git", "-C", d, "pull", "-q", "--stat"], check=True, capture_output=True, text=True)
|
|
34
|
-
return f"✓ {d}" + (f"\n{res.stdout.strip()}" if res.stdout.strip() else "")
|
|
35
|
-
except subprocess.CalledProcessError as e: return f"✗ {d}: {e.stderr.strip()}"
|
|
36
|
-
|
|
37
|
-
def ws_clone(
|
|
38
|
-
repos_file: str = "repos.txt", # File containing repo list (one per line: owner/repo)
|
|
39
|
-
workers: int = 16, # Number of parallel workers
|
|
40
|
-
):
|
|
41
|
-
"Clone all repos from a repos file."
|
|
42
|
-
repos = _load_repos(repos_file)
|
|
43
|
-
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
44
|
-
for result in as_completed([ex.submit(_clone_one, r) for r in repos]): print(result.result())
|
|
45
|
-
|
|
46
|
-
@call_parse
|
|
47
|
-
def ws_clone_cli(
|
|
48
|
-
repos_file: str = "repos.txt", # File containing repo list (one per line: owner/repo)
|
|
49
|
-
workers: int = 16, # Number of parallel workers
|
|
50
|
-
): ws_clone(repos_file, workers)
|
|
51
|
-
|
|
52
|
-
def ws_pull(
|
|
53
|
-
repos_file: str = "repos.txt", # File containing repo list
|
|
54
|
-
workers: int = 16, # Number of parallel workers
|
|
55
|
-
):
|
|
56
|
-
"Pull updates for all repos."
|
|
57
|
-
repos = _load_repos(repos_file)
|
|
58
|
-
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
59
|
-
for result in as_completed([ex.submit(_pull_one, r) for r in repos]): print(result.result())
|
|
60
|
-
|
|
61
|
-
@call_parse
|
|
62
|
-
def ws_pull_cli(
|
|
63
|
-
repos_file: str = "repos.txt", # File containing repo list
|
|
64
|
-
workers: int = 16, # Number of parallel workers
|
|
65
|
-
): ws_pull(repos_file, workers)
|
|
66
|
-
|
|
67
|
-
def ws_status(
|
|
68
|
-
repos_file: str = "repos.txt", # File containing repo list
|
|
69
|
-
):
|
|
70
|
-
"Show uncommitted changes and unpushed commits across repos."
|
|
71
|
-
repos = _load_repos(repos_file)
|
|
72
|
-
for repo in repos:
|
|
73
|
-
d = _repo_dir(repo)
|
|
74
|
-
if not Path(d).exists(): continue
|
|
75
|
-
g = Git(d)
|
|
76
|
-
if not g.exists: continue
|
|
77
|
-
changes = g.status('-s') or ""
|
|
78
|
-
if isinstance(changes, list): changes = "\n".join(changes)
|
|
79
|
-
unpushed = ""
|
|
80
|
-
try: unpushed = g.log('--branches', '--not', '--remotes', format='%h %s') or ""
|
|
81
|
-
except Exception: pass
|
|
82
|
-
if isinstance(unpushed, list): unpushed = "\n".join(unpushed)
|
|
83
|
-
if changes or unpushed:
|
|
84
|
-
print(f"\n=== {d} ===")
|
|
85
|
-
if changes: print(changes)
|
|
86
|
-
if unpushed: print(unpushed)
|
|
87
|
-
|
|
88
|
-
@call_parse
|
|
89
|
-
def ws_status_cli(
|
|
90
|
-
repos_file: str = "repos.txt", # File containing repo list
|
|
91
|
-
): ws_status(repos_file)
|
|
92
|
-
|
|
93
|
-
def ws_branches(
|
|
94
|
-
repos_file: str = "repos.txt", # File containing repo list
|
|
95
|
-
expected: str = "main", # Expected branch name
|
|
96
|
-
):
|
|
97
|
-
"Check if all repos are on the expected branch."
|
|
98
|
-
repos = _load_repos(repos_file)
|
|
99
|
-
for repo in repos:
|
|
100
|
-
d = _repo_dir(repo)
|
|
101
|
-
if not Path(d).exists():
|
|
102
|
-
print(f"⚠️ {d}: directory not found")
|
|
103
|
-
continue
|
|
104
|
-
g = Git(d)
|
|
105
|
-
if not g.exists:
|
|
106
|
-
print(f"⚠️ {d}: not a git repo")
|
|
107
|
-
continue
|
|
108
|
-
branch = g.branch(show_current=True).strip()
|
|
109
|
-
print(f"✓ {d}: OK (on {expected})" if branch == expected else f"⚠️ {d}: WARNING (on {branch})")
|
|
110
|
-
|
|
111
|
-
@call_parse
|
|
112
|
-
def ws_branches_cli(
|
|
113
|
-
repos_file: str = "repos.txt", # File containing repo list
|
|
114
|
-
expected: str = "main", # Expected branch name
|
|
115
|
-
): ws_branches(repos_file, expected)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|