workset 0.1.0__py3-none-any.whl
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.
- workset/__init__.py +10 -0
- workset/cli.py +142 -0
- workset/config.py +125 -0
- workset/core.py +134 -0
- workset/env.py +153 -0
- workset/git.py +117 -0
- workset/py.typed +0 -0
- workset-0.1.0.dist-info/METADATA +65 -0
- workset-0.1.0.dist-info/RECORD +11 -0
- workset-0.1.0.dist-info/WHEEL +4 -0
- workset-0.1.0.dist-info/entry_points.txt +2 -0
workset/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Workset — create and initialize isolated git worktree worksets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from workset.config import WorksetError
|
|
6
|
+
from workset.core import RepoResult, WorksetResult, create_workset
|
|
7
|
+
|
|
8
|
+
__all__ = ["WorksetError", "RepoResult", "WorksetResult", "create_workset"]
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
workset/cli.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Command-line interface for workset."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from workset.config import WorksetError
|
|
10
|
+
from workset.core import WorksetResult, create_workset
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
17
|
+
"""Run the workset command-line interface."""
|
|
18
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
19
|
+
try:
|
|
20
|
+
return _main(args)
|
|
21
|
+
except WorksetError as exc:
|
|
22
|
+
print(f"workset: {exc}", file=sys.stderr)
|
|
23
|
+
return 2
|
|
24
|
+
except KeyboardInterrupt:
|
|
25
|
+
return 130
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _main(args: list[str]) -> int:
|
|
29
|
+
if not args or args[0] in {"-h", "--help"}:
|
|
30
|
+
_print_help()
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
command = args[0]
|
|
34
|
+
|
|
35
|
+
if command == "new":
|
|
36
|
+
return _cmd_new(args[1:])
|
|
37
|
+
|
|
38
|
+
print(f"workset: unknown command {command!r}", file=sys.stderr)
|
|
39
|
+
_print_help()
|
|
40
|
+
return 2
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _cmd_new(args: list[str]) -> int:
|
|
44
|
+
"""Handle ``workset new``."""
|
|
45
|
+
if not args or args[0] in {"-h", "--help"}:
|
|
46
|
+
_print_new_help()
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
slug = args[0]
|
|
50
|
+
rest = args[1:]
|
|
51
|
+
|
|
52
|
+
dest: Path | None = None
|
|
53
|
+
no_env = False
|
|
54
|
+
no_smoke = False
|
|
55
|
+
repo_specs: list[str] = []
|
|
56
|
+
|
|
57
|
+
i = 0
|
|
58
|
+
while i < len(rest):
|
|
59
|
+
arg = rest[i]
|
|
60
|
+
if arg in {"--dest", "-d"}:
|
|
61
|
+
if i + 1 >= len(rest):
|
|
62
|
+
print("workset: --dest requires a path", file=sys.stderr)
|
|
63
|
+
return 2
|
|
64
|
+
dest = Path(rest[i + 1]).expanduser()
|
|
65
|
+
i += 2
|
|
66
|
+
elif arg == "--no-env":
|
|
67
|
+
no_env = True
|
|
68
|
+
i += 1
|
|
69
|
+
elif arg == "--no-smoke":
|
|
70
|
+
no_smoke = True
|
|
71
|
+
i += 1
|
|
72
|
+
elif arg.startswith("-"):
|
|
73
|
+
print(f"workset: unknown option {arg!r}", file=sys.stderr)
|
|
74
|
+
return 2
|
|
75
|
+
else:
|
|
76
|
+
repo_specs.append(arg)
|
|
77
|
+
i += 1
|
|
78
|
+
|
|
79
|
+
if not repo_specs:
|
|
80
|
+
print("workset: at least one repo spec required", file=sys.stderr)
|
|
81
|
+
_print_new_help()
|
|
82
|
+
return 2
|
|
83
|
+
|
|
84
|
+
result = create_workset(
|
|
85
|
+
slug=slug,
|
|
86
|
+
repo_specs=repo_specs,
|
|
87
|
+
dest=dest,
|
|
88
|
+
no_env=no_env,
|
|
89
|
+
no_smoke=no_smoke,
|
|
90
|
+
)
|
|
91
|
+
_print_result(result)
|
|
92
|
+
return 0 if result.ok else 1
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _print_result(result: WorksetResult) -> None:
|
|
96
|
+
"""Print a human-readable summary of the workset result."""
|
|
97
|
+
print(f"\nworkset ready: {result.path}")
|
|
98
|
+
for repo in result.repos:
|
|
99
|
+
smoke = _smoke_symbol(repo.smoke_passed)
|
|
100
|
+
env_label = (
|
|
101
|
+
f"[{repo.env_backend}]" if repo.env_backend != "none" else "[no env]"
|
|
102
|
+
)
|
|
103
|
+
print(f" {smoke} {repo.name} {env_label} {repo.branch}")
|
|
104
|
+
if not repo.env_ok:
|
|
105
|
+
print(f" ! {repo.env_message}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _smoke_symbol(smoke_passed: bool | None) -> str:
|
|
109
|
+
"""Return a status symbol for the smoke test result."""
|
|
110
|
+
if smoke_passed is True:
|
|
111
|
+
return "✓"
|
|
112
|
+
if smoke_passed is False:
|
|
113
|
+
return "✗"
|
|
114
|
+
return "~"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _print_help() -> None:
|
|
118
|
+
print(
|
|
119
|
+
"usage: workset <command> [args]\n"
|
|
120
|
+
"\nCommands:\n"
|
|
121
|
+
" new Create a new workset\n"
|
|
122
|
+
"\nRun 'workset new --help' for details.",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _print_new_help() -> None:
|
|
127
|
+
print(
|
|
128
|
+
"usage: workset new <slug> [--dest <path>] [--no-env] [--no-smoke]"
|
|
129
|
+
" <repo>:<branch> ...\n"
|
|
130
|
+
"\nArguments:\n"
|
|
131
|
+
" slug Short identifier for this workset\n"
|
|
132
|
+
" repo:branch Repo name (from config or path) and branch\n"
|
|
133
|
+
"\nOptions:\n"
|
|
134
|
+
" --dest <path> Override destination path\n"
|
|
135
|
+
" --no-env Skip environment setup\n"
|
|
136
|
+
" --no-smoke Skip smoke tests\n"
|
|
137
|
+
"\nExamples:\n"
|
|
138
|
+
" workset new improve-viewer minerva_mujoco_viewer:feat/improve-viewer\n"
|
|
139
|
+
" workset new leansim2sim minerva_lab:feat/leansim2sim"
|
|
140
|
+
" motion_data_processing:main\n"
|
|
141
|
+
" workset new quick-fix --dest /tmp/quick minerva_lab:main",
|
|
142
|
+
)
|
workset/config.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Configuration loading and repo-spec resolution for workset."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import os
|
|
7
|
+
import tomllib
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WorksetError(Exception):
|
|
14
|
+
"""User-facing workset error."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class RepoSpec:
|
|
19
|
+
"""A resolved repo specification."""
|
|
20
|
+
|
|
21
|
+
canonical: Path
|
|
22
|
+
branch: str
|
|
23
|
+
name: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class WorksetConfig:
|
|
28
|
+
"""Loaded workset configuration."""
|
|
29
|
+
|
|
30
|
+
workset_root: Path
|
|
31
|
+
date_prefix: bool
|
|
32
|
+
repos: dict[str, Path]
|
|
33
|
+
|
|
34
|
+
def resolve_dest(self, slug: str, dest_override: Path | None = None) -> Path:
|
|
35
|
+
"""Resolve the workset destination path for a given slug."""
|
|
36
|
+
if dest_override is not None:
|
|
37
|
+
return dest_override
|
|
38
|
+
if self.date_prefix:
|
|
39
|
+
today = datetime.datetime.now(tz=datetime.UTC).date()
|
|
40
|
+
return self.workset_root / today.strftime("%Y/%m/%d") / slug
|
|
41
|
+
return self.workset_root / slug
|
|
42
|
+
|
|
43
|
+
def resolve_spec(self, spec: str) -> RepoSpec:
|
|
44
|
+
"""Resolve a repo-spec string to a canonical path and branch.
|
|
45
|
+
|
|
46
|
+
Accepts ``name:branch`` (looked up in config) or ``/path/to/repo:branch``.
|
|
47
|
+
"""
|
|
48
|
+
if ":" not in spec:
|
|
49
|
+
raise WorksetError(
|
|
50
|
+
f"invalid repo spec {spec!r}: expected <name>:<branch> or <path>:<branch>", # noqa: E501
|
|
51
|
+
)
|
|
52
|
+
repo_part, branch = spec.rsplit(":", 1)
|
|
53
|
+
if not branch.strip():
|
|
54
|
+
raise WorksetError(f"invalid repo spec {spec!r}: branch cannot be empty")
|
|
55
|
+
|
|
56
|
+
canonical, name = self._resolve_canonical(repo_part)
|
|
57
|
+
_require_git_repo(canonical)
|
|
58
|
+
return RepoSpec(canonical=canonical, branch=branch.strip(), name=name)
|
|
59
|
+
|
|
60
|
+
def _resolve_canonical(self, repo_part: str) -> tuple[Path, str]:
|
|
61
|
+
"""Return (canonical_path, short_name) for a repo identifier."""
|
|
62
|
+
candidate = Path(repo_part).expanduser()
|
|
63
|
+
if candidate.is_absolute():
|
|
64
|
+
return candidate.resolve(), candidate.name
|
|
65
|
+
|
|
66
|
+
if repo_part in self.repos:
|
|
67
|
+
return self.repos[repo_part], repo_part
|
|
68
|
+
|
|
69
|
+
repos_dir = os.environ.get("WORKSET_REPOS_DIR")
|
|
70
|
+
if repos_dir:
|
|
71
|
+
via_env = Path(repos_dir).expanduser() / repo_part
|
|
72
|
+
if via_env.is_dir():
|
|
73
|
+
return via_env.resolve(), repo_part
|
|
74
|
+
|
|
75
|
+
tried: list[str] = ["~/.config/workset/repos.toml [repos]"]
|
|
76
|
+
if repos_dir:
|
|
77
|
+
tried.append(f"$WORKSET_REPOS_DIR/{repo_part}")
|
|
78
|
+
raise WorksetError(
|
|
79
|
+
f"repo {repo_part!r} not found. Tried:\n"
|
|
80
|
+
+ "\n".join(f" {t}" for t in tried)
|
|
81
|
+
+ "\nAdd it to [repos] in ~/.config/workset/repos.toml or use a full path.",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_DEFAULT_CONFIG = Path.home() / ".config" / "workset" / "repos.toml"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def load_config(config_path: Path | None = None) -> WorksetConfig:
|
|
89
|
+
"""Load workset configuration, returning defaults if no config file exists."""
|
|
90
|
+
path = config_path or _DEFAULT_CONFIG
|
|
91
|
+
|
|
92
|
+
if not path.is_file():
|
|
93
|
+
return WorksetConfig(
|
|
94
|
+
workset_root=Path.home() / "Projects" / "worksets",
|
|
95
|
+
date_prefix=False,
|
|
96
|
+
repos={},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
with path.open("rb") as f:
|
|
101
|
+
raw: dict[str, Any] = tomllib.load(f)
|
|
102
|
+
except tomllib.TOMLDecodeError as exc:
|
|
103
|
+
raise WorksetError(f"invalid repos.toml at {path}: {exc}") from exc
|
|
104
|
+
|
|
105
|
+
ws = raw.get("workset", {})
|
|
106
|
+
workset_root = (
|
|
107
|
+
Path(str(ws.get("root", "~/Projects/worksets"))).expanduser().resolve()
|
|
108
|
+
)
|
|
109
|
+
date_prefix = bool(ws.get("date_prefix", False))
|
|
110
|
+
|
|
111
|
+
repos = {
|
|
112
|
+
name: Path(str(p)).expanduser().resolve()
|
|
113
|
+
for name, p in raw.get("repos", {}).items()
|
|
114
|
+
}
|
|
115
|
+
return WorksetConfig(
|
|
116
|
+
workset_root=workset_root, date_prefix=date_prefix, repos=repos
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _require_git_repo(path: Path) -> None:
|
|
121
|
+
"""Raise WorksetError if path is not an existing git repository."""
|
|
122
|
+
if not path.is_dir():
|
|
123
|
+
raise WorksetError(f"canonical repo path does not exist: {path}")
|
|
124
|
+
if not (path / ".git").exists():
|
|
125
|
+
raise WorksetError(f"not a git repository: {path}")
|
workset/core.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Core workset creation logic and result types."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from workset.config import RepoSpec, WorksetConfig, WorksetError, load_config
|
|
10
|
+
from workset.env import detect_backend, run_smoke_test, setup_env
|
|
11
|
+
from workset.git import submodule_init, worktree_add
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class RepoResult:
|
|
19
|
+
"""Result for one repo in a workset."""
|
|
20
|
+
|
|
21
|
+
name: str
|
|
22
|
+
path: Path
|
|
23
|
+
branch: str
|
|
24
|
+
env_backend: str
|
|
25
|
+
env_ok: bool
|
|
26
|
+
env_message: str
|
|
27
|
+
smoke_passed: bool | None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class WorksetResult:
|
|
32
|
+
"""Result of a workset creation."""
|
|
33
|
+
|
|
34
|
+
path: Path
|
|
35
|
+
repos: tuple[RepoResult, ...]
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def ok(self) -> bool:
|
|
39
|
+
"""Return True if all repos were set up without errors."""
|
|
40
|
+
return all(r.env_ok for r in self.repos)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def create_workset(
|
|
44
|
+
slug: str,
|
|
45
|
+
repo_specs: list[str],
|
|
46
|
+
dest: Path | None = None,
|
|
47
|
+
*,
|
|
48
|
+
no_env: bool = False,
|
|
49
|
+
no_smoke: bool = False,
|
|
50
|
+
config: WorksetConfig | None = None,
|
|
51
|
+
) -> WorksetResult:
|
|
52
|
+
"""Create a workset: one worktree per repo-spec, env set up, smoke tested.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
slug: Short identifier used in the path and worklog.
|
|
56
|
+
repo_specs: List of ``name:branch`` or ``/path:branch`` strings.
|
|
57
|
+
dest: Override destination path. Uses config default when None.
|
|
58
|
+
no_env: Skip environment setup.
|
|
59
|
+
no_smoke: Skip smoke tests.
|
|
60
|
+
config: Override config. Loaded from disk when None.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
WorksetResult with per-repo status.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
WorksetError: On unrecoverable errors (branch collision, missing repo).
|
|
67
|
+
"""
|
|
68
|
+
cfg = config or load_config()
|
|
69
|
+
workset_path = cfg.resolve_dest(slug, dest)
|
|
70
|
+
|
|
71
|
+
specs = [cfg.resolve_spec(s) for s in repo_specs]
|
|
72
|
+
_check_name_collisions(specs)
|
|
73
|
+
|
|
74
|
+
print(f"Creating workset at {workset_path}", file=sys.stderr)
|
|
75
|
+
|
|
76
|
+
results: list[RepoResult] = []
|
|
77
|
+
for spec in specs:
|
|
78
|
+
result = _setup_repo(spec, workset_path, no_env=no_env, no_smoke=no_smoke)
|
|
79
|
+
results.append(result)
|
|
80
|
+
|
|
81
|
+
return WorksetResult(path=workset_path, repos=tuple(results))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _setup_repo(
|
|
85
|
+
spec: RepoSpec,
|
|
86
|
+
workset_path: Path,
|
|
87
|
+
*,
|
|
88
|
+
no_env: bool,
|
|
89
|
+
no_smoke: bool,
|
|
90
|
+
) -> RepoResult:
|
|
91
|
+
"""Set up a single repo worktree and return its result."""
|
|
92
|
+
worktree_path = workset_path / spec.name
|
|
93
|
+
print(
|
|
94
|
+
f" {spec.name}: {spec.canonical.name} → {spec.branch}",
|
|
95
|
+
file=sys.stderr,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
worktree_add(spec.canonical, worktree_path, spec.branch)
|
|
99
|
+
submodule_init(worktree_path)
|
|
100
|
+
|
|
101
|
+
backend = detect_backend(worktree_path)
|
|
102
|
+
env_ok = True
|
|
103
|
+
env_message = "env skipped"
|
|
104
|
+
|
|
105
|
+
if not no_env and backend != "none":
|
|
106
|
+
env_ok, env_message = setup_env(worktree_path, backend)
|
|
107
|
+
if not env_ok:
|
|
108
|
+
print(f" warning: {env_message}", file=sys.stderr)
|
|
109
|
+
|
|
110
|
+
smoke_passed: bool | None = None
|
|
111
|
+
if not no_smoke and env_ok and backend != "none":
|
|
112
|
+
smoke_passed = run_smoke_test(worktree_path, backend)
|
|
113
|
+
|
|
114
|
+
return RepoResult(
|
|
115
|
+
name=spec.name,
|
|
116
|
+
path=worktree_path,
|
|
117
|
+
branch=spec.branch,
|
|
118
|
+
env_backend=backend,
|
|
119
|
+
env_ok=env_ok,
|
|
120
|
+
env_message=env_message,
|
|
121
|
+
smoke_passed=smoke_passed,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _check_name_collisions(specs: list[RepoSpec]) -> None:
|
|
126
|
+
"""Raise WorksetError if two specs would produce the same worktree name."""
|
|
127
|
+
seen: dict[str, RepoSpec] = {}
|
|
128
|
+
for spec in specs:
|
|
129
|
+
if spec.name in seen:
|
|
130
|
+
raise WorksetError(
|
|
131
|
+
f"two repo specs resolve to the same name {spec.name!r}: "
|
|
132
|
+
f"{seen[spec.name].canonical} and {spec.canonical}",
|
|
133
|
+
)
|
|
134
|
+
seen[spec.name] = spec
|
workset/env.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Environment backend detection, setup, and smoke testing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def detect_backend(worktree_path: Path) -> str:
|
|
11
|
+
"""Detect the Python environment backend for a worktree.
|
|
12
|
+
|
|
13
|
+
Returns ``"veneer"``, ``"uv"``, or ``"none"``.
|
|
14
|
+
Veneer takes priority if ``veneer.toml`` is present.
|
|
15
|
+
If veneer.toml uses ``extends`` (pointer style), still returns ``"veneer"``
|
|
16
|
+
but setup may fail if the stack file does not exist yet.
|
|
17
|
+
"""
|
|
18
|
+
if (worktree_path / "veneer.toml").is_file():
|
|
19
|
+
return "veneer"
|
|
20
|
+
if (worktree_path / "pyproject.toml").is_file():
|
|
21
|
+
return "uv"
|
|
22
|
+
return "none"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def setup_env(worktree_path: Path, backend: str) -> tuple[bool, str]:
|
|
26
|
+
"""Run environment setup for a worktree.
|
|
27
|
+
|
|
28
|
+
Returns (success, message).
|
|
29
|
+
"""
|
|
30
|
+
if backend == "veneer":
|
|
31
|
+
return _setup_veneer(worktree_path)
|
|
32
|
+
if backend == "uv":
|
|
33
|
+
return _setup_uv(worktree_path)
|
|
34
|
+
return True, "no env backend detected"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _setup_veneer(worktree_path: Path) -> tuple[bool, str]:
|
|
38
|
+
"""Run ``veneer update-editables`` in the worktree."""
|
|
39
|
+
result = subprocess.run(
|
|
40
|
+
["veneer", "update-editables"], # noqa: S607
|
|
41
|
+
cwd=worktree_path,
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
check=False,
|
|
45
|
+
)
|
|
46
|
+
if result.returncode != 0:
|
|
47
|
+
stderr = result.stderr.strip()
|
|
48
|
+
if "missing veneer.toml" in stderr or "extends" in stderr:
|
|
49
|
+
return False, (
|
|
50
|
+
"veneer setup skipped: veneer.toml uses 'extends' but stack file "
|
|
51
|
+
"not found — create the parent stack file or run veneer manually"
|
|
52
|
+
)
|
|
53
|
+
return False, f"veneer update-editables failed: {stderr}"
|
|
54
|
+
return True, "veneer ok"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _setup_uv(worktree_path: Path) -> tuple[bool, str]:
|
|
58
|
+
"""Run ``uv sync`` in the worktree."""
|
|
59
|
+
result = subprocess.run(
|
|
60
|
+
["uv", "sync"], # noqa: S607
|
|
61
|
+
cwd=worktree_path,
|
|
62
|
+
capture_output=True,
|
|
63
|
+
text=True,
|
|
64
|
+
check=False,
|
|
65
|
+
)
|
|
66
|
+
if result.returncode != 0:
|
|
67
|
+
return False, f"uv sync failed: {result.stderr.strip()}"
|
|
68
|
+
return True, "uv ok"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def infer_import_name(worktree_path: Path) -> str | None:
|
|
72
|
+
"""Infer the importable package name for a worktree.
|
|
73
|
+
|
|
74
|
+
Uses the first editable package from ``veneer.toml`` if present,
|
|
75
|
+
then looks for a single directory under ``src/`` or ``source/``.
|
|
76
|
+
Returns None if the import name cannot be determined.
|
|
77
|
+
"""
|
|
78
|
+
veneer_toml = worktree_path / "veneer.toml"
|
|
79
|
+
if veneer_toml.is_file():
|
|
80
|
+
name = _import_name_from_veneer_toml(veneer_toml, worktree_path)
|
|
81
|
+
if name:
|
|
82
|
+
return name
|
|
83
|
+
|
|
84
|
+
for src_dir in ("src", "source"):
|
|
85
|
+
candidate = worktree_path / src_dir
|
|
86
|
+
if candidate.is_dir():
|
|
87
|
+
name = _single_package_dir(candidate)
|
|
88
|
+
if name:
|
|
89
|
+
return name
|
|
90
|
+
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _import_name_from_veneer_toml(
|
|
95
|
+
veneer_toml: Path,
|
|
96
|
+
worktree_path: Path,
|
|
97
|
+
) -> str | None:
|
|
98
|
+
"""Extract import name from the first editables.packages entry."""
|
|
99
|
+
try:
|
|
100
|
+
with veneer_toml.open("rb") as f:
|
|
101
|
+
raw = tomllib.load(f)
|
|
102
|
+
except (tomllib.TOMLDecodeError, OSError):
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
packages = raw.get("editables", {}).get("packages", [])
|
|
106
|
+
if not packages:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
first = packages[0]
|
|
110
|
+
resolved = (worktree_path / Path(first).expanduser()).resolve()
|
|
111
|
+
return resolved.name
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _single_package_dir(src_dir: Path) -> str | None:
|
|
115
|
+
"""Return the name of the single Python package directory under src_dir."""
|
|
116
|
+
candidates = [
|
|
117
|
+
d
|
|
118
|
+
for d in src_dir.iterdir()
|
|
119
|
+
if d.is_dir()
|
|
120
|
+
and not d.name.startswith(".")
|
|
121
|
+
and not d.name.endswith(".dist-info")
|
|
122
|
+
and not d.name.endswith(".egg-info")
|
|
123
|
+
and (d / "__init__.py").is_file()
|
|
124
|
+
]
|
|
125
|
+
if len(candidates) == 1:
|
|
126
|
+
return candidates[0].name
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def run_smoke_test(worktree_path: Path, backend: str) -> bool | None:
|
|
131
|
+
"""Try to import the main package to verify the environment works.
|
|
132
|
+
|
|
133
|
+
Returns True on success, False on failure, None if import name unknown.
|
|
134
|
+
Never raises — smoke test failures are advisory, not fatal.
|
|
135
|
+
"""
|
|
136
|
+
name = infer_import_name(worktree_path)
|
|
137
|
+
if name is None:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
if backend == "veneer":
|
|
141
|
+
cmd = ["veneer", "python", "-c", f"import {name}"]
|
|
142
|
+
elif backend == "uv":
|
|
143
|
+
cmd = ["uv", "run", "python", "-c", f"import {name}"]
|
|
144
|
+
else:
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
result = subprocess.run( # noqa: S603
|
|
148
|
+
cmd,
|
|
149
|
+
cwd=worktree_path,
|
|
150
|
+
capture_output=True,
|
|
151
|
+
check=False,
|
|
152
|
+
)
|
|
153
|
+
return result.returncode == 0
|
workset/git.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Git worktree and submodule operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from workset.config import WorksetError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_branch_worktrees(canonical: Path) -> dict[str, Path]:
|
|
13
|
+
"""Return a mapping of branch name → worktree path for a canonical repo.
|
|
14
|
+
|
|
15
|
+
Only includes worktrees that have a branch checked out (not detached HEAD).
|
|
16
|
+
"""
|
|
17
|
+
result = subprocess.run(
|
|
18
|
+
["git", "worktree", "list", "--porcelain"], # noqa: S607
|
|
19
|
+
cwd=canonical,
|
|
20
|
+
text=True,
|
|
21
|
+
capture_output=True,
|
|
22
|
+
check=False,
|
|
23
|
+
)
|
|
24
|
+
if result.returncode != 0:
|
|
25
|
+
raise WorksetError(
|
|
26
|
+
f"git worktree list failed in {canonical}:\n{result.stderr.strip()}",
|
|
27
|
+
)
|
|
28
|
+
return _parse_worktree_list(result.stdout)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_worktree_list(output: str) -> dict[str, Path]:
|
|
32
|
+
"""Parse ``git worktree list --porcelain`` output into {branch: path}."""
|
|
33
|
+
branch_to_path: dict[str, Path] = {}
|
|
34
|
+
current_path: Path | None = None
|
|
35
|
+
|
|
36
|
+
for line in output.splitlines():
|
|
37
|
+
if line.startswith("worktree "):
|
|
38
|
+
current_path = Path(line[len("worktree ") :])
|
|
39
|
+
elif line.startswith("branch refs/heads/") and current_path is not None:
|
|
40
|
+
branch = line[len("branch refs/heads/") :]
|
|
41
|
+
branch_to_path[branch] = current_path
|
|
42
|
+
current_path = None
|
|
43
|
+
elif not line and current_path is not None:
|
|
44
|
+
current_path = None
|
|
45
|
+
|
|
46
|
+
return branch_to_path
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def branch_exists(canonical: Path, branch: str) -> bool:
|
|
50
|
+
"""Return True if a local branch exists in the canonical repo."""
|
|
51
|
+
result = subprocess.run( # noqa: S603
|
|
52
|
+
["git", "show-ref", "--verify", "--quiet", f"refs/heads/{branch}"], # noqa: S607
|
|
53
|
+
cwd=canonical,
|
|
54
|
+
capture_output=True,
|
|
55
|
+
check=False,
|
|
56
|
+
)
|
|
57
|
+
return result.returncode == 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def worktree_add(canonical: Path, dest: Path, branch: str) -> None:
|
|
61
|
+
"""Add a git worktree at dest, checking out or creating branch.
|
|
62
|
+
|
|
63
|
+
Pre-checks for branch collision and raises WorksetError with a clear
|
|
64
|
+
message instead of letting git fail with a raw fatal.
|
|
65
|
+
"""
|
|
66
|
+
checked_out = get_branch_worktrees(canonical)
|
|
67
|
+
if branch in checked_out:
|
|
68
|
+
conflict = checked_out[branch]
|
|
69
|
+
raise WorksetError(
|
|
70
|
+
f"branch {branch!r} is already checked out in:\n"
|
|
71
|
+
f" {conflict}\n\n"
|
|
72
|
+
f"Check out a different branch, or remove that worktree first:\n"
|
|
73
|
+
f" git worktree remove {conflict}",
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
|
|
78
|
+
exists = branch_exists(canonical, branch)
|
|
79
|
+
cmd = (
|
|
80
|
+
["git", "worktree", "add", str(dest), branch]
|
|
81
|
+
if exists
|
|
82
|
+
else ["git", "worktree", "add", "-b", branch, str(dest)]
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
result = subprocess.run( # noqa: S603
|
|
86
|
+
cmd,
|
|
87
|
+
cwd=canonical,
|
|
88
|
+
text=True,
|
|
89
|
+
capture_output=True,
|
|
90
|
+
check=False,
|
|
91
|
+
)
|
|
92
|
+
if result.returncode != 0:
|
|
93
|
+
raise WorksetError(
|
|
94
|
+
f"git worktree add failed for {branch!r} in {canonical}:\n"
|
|
95
|
+
f"{result.stderr.strip()}",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def submodule_init(worktree_path: Path) -> None:
|
|
100
|
+
"""Initialize all submodules in the worktree recursively.
|
|
101
|
+
|
|
102
|
+
This may be slow for repos with large binary submodules (e.g. STL files).
|
|
103
|
+
Progress is streamed to stderr so it does not appear hung.
|
|
104
|
+
"""
|
|
105
|
+
print(
|
|
106
|
+
f" initializing submodules in {worktree_path.name}...",
|
|
107
|
+
file=sys.stderr,
|
|
108
|
+
)
|
|
109
|
+
result = subprocess.run(
|
|
110
|
+
["git", "submodule", "update", "--init", "--recursive"], # noqa: S607
|
|
111
|
+
cwd=worktree_path,
|
|
112
|
+
check=False,
|
|
113
|
+
)
|
|
114
|
+
if result.returncode != 0:
|
|
115
|
+
raise WorksetError(
|
|
116
|
+
f"git submodule update failed in {worktree_path}",
|
|
117
|
+
)
|
workset/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: workset
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Create and initialize isolated git worktree worksets.
|
|
5
|
+
Project-URL: Homepage, https://github.com/alik-git/workset
|
|
6
|
+
Project-URL: Repository, https://github.com/alik-git/workset
|
|
7
|
+
Project-URL: Issues, https://github.com/alik-git/workset/issues
|
|
8
|
+
Author-email: Ali K <akuwajerwala@minervahumanoids.com>
|
|
9
|
+
Maintainer-email: Ali K <akuwajerwala@minervahumanoids.com>
|
|
10
|
+
Keywords: devtools,git,python,workset,worktree
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Typing :: Typed
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Requires-Dist: colorlog>=6.10
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
20
|
+
Requires-Dist: mypy>=1.15; extra == 'dev'
|
|
21
|
+
Requires-Dist: pre-commit>=4.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: ruff>=0.11; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# workset
|
|
27
|
+
|
|
28
|
+
Create and initialize isolated git worktree worksets in one command.
|
|
29
|
+
|
|
30
|
+
A workset is a directory containing one or more git worktrees for related
|
|
31
|
+
repos, each on their own branch, with submodules initialized and the Python
|
|
32
|
+
environment ready to go. `workset` automates the setup so you can go from
|
|
33
|
+
"I have a task" to a working environment without the manual back-and-forth.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uv tool install workset
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or install from source:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
uv tool install --editable path/to/workset
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
workset --help
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Development
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
uv sync --extra dev
|
|
57
|
+
uv run ruff format --check .
|
|
58
|
+
uv run ruff check .
|
|
59
|
+
uv run mypy
|
|
60
|
+
uv run pytest
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Publishing
|
|
64
|
+
|
|
65
|
+
Releases are published to PyPI automatically when a GitHub Release is created.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
workset/__init__.py,sha256=1Tc__O5VrtZ_qSkbVs5zZsQw4EvPzljY1eobHFSMct8,316
|
|
2
|
+
workset/cli.py,sha256=5ejVrYl90BnWoHYPG7vsDxMzG9Fvu-vKhmXwZGwb_-E,3989
|
|
3
|
+
workset/config.py,sha256=Oj9u-rE9zmCZ-oAMPB_4KN16HW9EHi6coTKcy__UF8o,4141
|
|
4
|
+
workset/core.py,sha256=zSWbUao0oxaQ1WB_YIZh0TsTLtjteL6Nwj5B7OttjM0,3778
|
|
5
|
+
workset/env.py,sha256=c49CM9RmirZPA6iiI0u_Ed5QCHCiWItljyOrOAwjEdM,4694
|
|
6
|
+
workset/git.py,sha256=meaay6FLv3lsZEjRxjYsfvekux58cLx6YhpFckedxDE,3760
|
|
7
|
+
workset/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
workset-0.1.0.dist-info/METADATA,sha256=Mfmv4ccfMBmPTO_QBt4lcXHK41gNnhVBxlJphqOOchw,1794
|
|
9
|
+
workset-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
10
|
+
workset-0.1.0.dist-info/entry_points.txt,sha256=vBbftowk1WGZG7jyMUZZ9murKXQejz1awidVM4tj4U0,45
|
|
11
|
+
workset-0.1.0.dist-info/RECORD,,
|