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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ workset = workset.cli:main