workforest 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.
- workforest/__init__.py +3 -0
- workforest/__main__.py +4 -0
- workforest/cli.py +254 -0
- workforest/commands.py +284 -0
- workforest/completions.py +76 -0
- workforest/config.py +188 -0
- workforest/errors.py +34 -0
- workforest/examples/config.yaml +117 -0
- workforest/gitutil.py +194 -0
- workforest/hooks.py +146 -0
- workforest/integrations/__init__.py +2 -0
- workforest/integrations/claude.py +129 -0
- workforest/launch.py +107 -0
- workforest/output.py +75 -0
- workforest/shell/completion.bash +27 -0
- workforest/shell/completion.zsh +38 -0
- workforest/shell/workforest.sh +16 -0
- workforest/shellinit.py +27 -0
- workforest/tui.py +206 -0
- workforest-0.1.0.dist-info/METADATA +188 -0
- workforest-0.1.0.dist-info/RECORD +24 -0
- workforest-0.1.0.dist-info/WHEEL +4 -0
- workforest-0.1.0.dist-info/entry_points.txt +3 -0
- workforest-0.1.0.dist-info/licenses/LICENSE +21 -0
workforest/__init__.py
ADDED
workforest/__main__.py
ADDED
workforest/cli.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""argparse front-end: shortcut dispatch, error → exit-code mapping, and the
|
|
2
|
+
sole writer to stdout (DESIGN §3.3/§5)."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from workforest import __version__, commands, output
|
|
11
|
+
from workforest.commands import CommandResult
|
|
12
|
+
from workforest.errors import EXIT_CANCELLED, EXIT_OK, WorkforestError
|
|
13
|
+
from workforest.launch import ShellAction
|
|
14
|
+
|
|
15
|
+
SUBCOMMANDS = frozenset(
|
|
16
|
+
{
|
|
17
|
+
"create",
|
|
18
|
+
"open",
|
|
19
|
+
"list",
|
|
20
|
+
"delete",
|
|
21
|
+
"checkout",
|
|
22
|
+
"run",
|
|
23
|
+
"tui",
|
|
24
|
+
"init",
|
|
25
|
+
"config",
|
|
26
|
+
"shell-init",
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _claude_available() -> bool:
|
|
32
|
+
"""Feature gate (DESIGN §3.7): the integration is invisible without
|
|
33
|
+
~/.claude. Filesystem check only — core never imports the integration."""
|
|
34
|
+
return (Path.home() / ".claude").is_dir()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _known_subcommands() -> frozenset[str]:
|
|
38
|
+
if _claude_available():
|
|
39
|
+
return SUBCOMMANDS | {"claude"}
|
|
40
|
+
return SUBCOMMANDS
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _emit(result: CommandResult) -> None:
|
|
44
|
+
match result:
|
|
45
|
+
case ShellAction(script=script):
|
|
46
|
+
print(script)
|
|
47
|
+
case str() as text if text:
|
|
48
|
+
print(text)
|
|
49
|
+
case _:
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# --- handlers -----------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _handle_create(ns: argparse.Namespace) -> CommandResult:
|
|
57
|
+
ctx = commands.build_context()
|
|
58
|
+
return commands.cmd_create(
|
|
59
|
+
ctx,
|
|
60
|
+
ns.branch,
|
|
61
|
+
opener=ns.opener,
|
|
62
|
+
path_arg=ns.path,
|
|
63
|
+
no_hooks=ns.no_hooks,
|
|
64
|
+
no_open=ns.no_open,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _handle_open(ns: argparse.Namespace) -> CommandResult:
|
|
69
|
+
ctx = commands.build_context()
|
|
70
|
+
return commands.cmd_open(ctx, ns.name, opener=ns.opener, path_arg=ns.path)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _handle_list(ns: argparse.Namespace) -> CommandResult:
|
|
74
|
+
ctx = commands.build_context()
|
|
75
|
+
return commands.cmd_list(ctx, porcelain=ns.porcelain)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _handle_delete(ns: argparse.Namespace) -> CommandResult:
|
|
79
|
+
ctx = commands.build_context()
|
|
80
|
+
delete_branch: bool | None = None
|
|
81
|
+
if ns.delete_branch:
|
|
82
|
+
delete_branch = True
|
|
83
|
+
elif ns.keep_branch:
|
|
84
|
+
delete_branch = False
|
|
85
|
+
return commands.cmd_delete(ctx, ns.names, force=ns.force, delete_branch=delete_branch)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _handle_checkout(ns: argparse.Namespace) -> CommandResult:
|
|
89
|
+
ctx = commands.build_context()
|
|
90
|
+
return commands.cmd_checkout(ctx, ns.name, force=ns.force)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _handle_run(ns: argparse.Namespace) -> CommandResult:
|
|
94
|
+
ctx = commands.build_context()
|
|
95
|
+
return commands.cmd_run(ctx, ns.script, ns.args)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _handle_init(ns: argparse.Namespace) -> CommandResult:
|
|
99
|
+
ctx = commands.build_context()
|
|
100
|
+
return commands.cmd_init(ctx, local=ns.local)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _handle_config(ns: argparse.Namespace) -> CommandResult:
|
|
104
|
+
return commands.cmd_config_show(as_json=ns.json)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _handle_shell_init(ns: argparse.Namespace) -> CommandResult:
|
|
108
|
+
from workforest import shellinit
|
|
109
|
+
|
|
110
|
+
return shellinit.shell_init(ns.shell)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _handle_tui(ns: argparse.Namespace) -> CommandResult:
|
|
114
|
+
from workforest import tui
|
|
115
|
+
|
|
116
|
+
return tui.run(ns.mode)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _handle_claude(ns: argparse.Namespace) -> CommandResult:
|
|
120
|
+
from workforest.integrations import claude
|
|
121
|
+
|
|
122
|
+
claude.cmd_copy_session(ns.session_id)
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# --- parser -------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
130
|
+
parser = argparse.ArgumentParser(
|
|
131
|
+
prog="workforest",
|
|
132
|
+
description="Git worktree forest management",
|
|
133
|
+
epilog=(
|
|
134
|
+
"shortcut: `workforest OPENER [NAME] [-p PATH]` "
|
|
135
|
+
"is `workforest open [NAME] -o OPENER [-p PATH]`"
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
parser.add_argument("--version", action="version", version=f"workforest {__version__}")
|
|
139
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
140
|
+
|
|
141
|
+
def opener_args(p: argparse.ArgumentParser) -> None:
|
|
142
|
+
p.add_argument("-o", "--opener", help="opener name or command template")
|
|
143
|
+
p.add_argument("-p", "--path", help="path inside the worktree to open")
|
|
144
|
+
|
|
145
|
+
p = sub.add_parser("create", help="create (or reuse) a worktree for a branch and open it")
|
|
146
|
+
p.add_argument("branch", nargs="?", help="branch name (default: current branch)")
|
|
147
|
+
opener_args(p)
|
|
148
|
+
p.add_argument("--no-hooks", action="store_true", help="skip symlinks and setup scripts")
|
|
149
|
+
p.add_argument("--no-open", action="store_true", help="create only, do not open")
|
|
150
|
+
p.set_defaults(func=_handle_create)
|
|
151
|
+
|
|
152
|
+
p = sub.add_parser("open", help="open an existing worktree")
|
|
153
|
+
p.add_argument("name", nargs="?", help="worktree directory name")
|
|
154
|
+
opener_args(p)
|
|
155
|
+
p.set_defaults(func=_handle_open)
|
|
156
|
+
|
|
157
|
+
p = sub.add_parser("list", help="list managed worktrees")
|
|
158
|
+
p.add_argument("--porcelain", action="store_true", help="stable tab-separated output")
|
|
159
|
+
p.set_defaults(func=_handle_list)
|
|
160
|
+
|
|
161
|
+
p = sub.add_parser("delete", help="delete worktree(s)")
|
|
162
|
+
p.add_argument("names", nargs="+", metavar="NAME")
|
|
163
|
+
p.add_argument("--force", action="store_true", help="skip the dirty-worktree confirmation")
|
|
164
|
+
group = p.add_mutually_exclusive_group()
|
|
165
|
+
group.add_argument("--delete-branch", action="store_true", help="also delete the branch")
|
|
166
|
+
group.add_argument("--keep-branch", action="store_true", help="never delete the branch")
|
|
167
|
+
p.set_defaults(func=_handle_delete)
|
|
168
|
+
|
|
169
|
+
p = sub.add_parser("checkout", help="delete a worktree and check its branch out in main")
|
|
170
|
+
p.add_argument("name", metavar="NAME")
|
|
171
|
+
p.add_argument("--force", action="store_true", help="skip the dirty-worktree confirmation")
|
|
172
|
+
p.set_defaults(func=_handle_checkout)
|
|
173
|
+
|
|
174
|
+
p = sub.add_parser("run", help="run a named script from the merged config")
|
|
175
|
+
p.add_argument("script", metavar="SCRIPT")
|
|
176
|
+
p.add_argument(
|
|
177
|
+
"args",
|
|
178
|
+
nargs=argparse.REMAINDER,
|
|
179
|
+
metavar="ARGS",
|
|
180
|
+
help="appended (shell-quoted) to the script command",
|
|
181
|
+
)
|
|
182
|
+
p.set_defaults(func=_handle_run)
|
|
183
|
+
|
|
184
|
+
p = sub.add_parser("init", help="scaffold a .workforest.yaml project config")
|
|
185
|
+
p.add_argument(
|
|
186
|
+
"--local",
|
|
187
|
+
action="store_true",
|
|
188
|
+
help="place it in .vscode/ or .idea/ (untracked per-developer override)",
|
|
189
|
+
)
|
|
190
|
+
p.set_defaults(func=_handle_init)
|
|
191
|
+
|
|
192
|
+
p = sub.add_parser("config", help="show the merged configuration and its sources")
|
|
193
|
+
p.add_argument("--json", action="store_true")
|
|
194
|
+
p.set_defaults(func=_handle_config)
|
|
195
|
+
|
|
196
|
+
p = sub.add_parser("shell-init", help="print the wf shell wrapper (eval in your shell rc)")
|
|
197
|
+
p.add_argument("shell", nargs="?", choices=("bash", "zsh"), help="default: from $SHELL")
|
|
198
|
+
p.set_defaults(func=_handle_shell_init)
|
|
199
|
+
|
|
200
|
+
p = sub.add_parser("tui", help="interactive mode (requires fzf)")
|
|
201
|
+
p.add_argument("mode", nargs="?", help="initial mode (create/open/checkout/delete)")
|
|
202
|
+
p.set_defaults(func=_handle_tui)
|
|
203
|
+
|
|
204
|
+
if _claude_available():
|
|
205
|
+
p = sub.add_parser("claude", help="Claude Code integration")
|
|
206
|
+
claude_sub = p.add_subparsers(dest="claude_command", required=True)
|
|
207
|
+
cp = claude_sub.add_parser(
|
|
208
|
+
"copy-session", help="copy a session from the main worktree into this one"
|
|
209
|
+
)
|
|
210
|
+
cp.add_argument("session_id", metavar="SESSION_ID")
|
|
211
|
+
cp.set_defaults(func=_handle_claude)
|
|
212
|
+
|
|
213
|
+
return parser
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _preprocess(argv: list[str]) -> list[str]:
|
|
217
|
+
"""No args → TUI; unknown first word → opener shortcut for `open`."""
|
|
218
|
+
if not argv:
|
|
219
|
+
return ["tui"]
|
|
220
|
+
first = argv[0]
|
|
221
|
+
if first.startswith("-") or first in _known_subcommands():
|
|
222
|
+
return argv
|
|
223
|
+
return ["open", *argv[1:], "-o", first]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def main(argv: list[str] | None = None) -> int:
|
|
227
|
+
args = _preprocess(list(sys.argv[1:] if argv is None else argv))
|
|
228
|
+
|
|
229
|
+
if args and args[0] == "--complete":
|
|
230
|
+
from workforest import completions
|
|
231
|
+
|
|
232
|
+
topic = args[1] if len(args) > 1 else ""
|
|
233
|
+
for line in completions.complete(topic):
|
|
234
|
+
print(line)
|
|
235
|
+
return EXIT_OK
|
|
236
|
+
|
|
237
|
+
parser = build_parser()
|
|
238
|
+
try:
|
|
239
|
+
ns = parser.parse_args(args)
|
|
240
|
+
except SystemExit as exc: # argparse exits for --help/--version/usage errors
|
|
241
|
+
return int(exc.code) if isinstance(exc.code, int) else EXIT_OK
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
handler: Any = ns.func
|
|
245
|
+
_emit(handler(ns))
|
|
246
|
+
return EXIT_OK
|
|
247
|
+
except WorkforestError as exc:
|
|
248
|
+
if os.environ.get("WORKFOREST_DEBUG"):
|
|
249
|
+
raise
|
|
250
|
+
output.error(str(exc))
|
|
251
|
+
return exc.exit_code
|
|
252
|
+
except KeyboardInterrupt:
|
|
253
|
+
output.info("")
|
|
254
|
+
return EXIT_CANCELLED
|
workforest/commands.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Core commands. Each returns a ShellAction (stdout directive), a str
|
|
2
|
+
(stdout text), or None — cli.py is the sole stdout writer (DESIGN §5)."""
|
|
3
|
+
|
|
4
|
+
import importlib.resources
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from workforest import gitutil, hooks, launch, output
|
|
12
|
+
from workforest.config import (
|
|
13
|
+
PROJECT_LOCAL_DIRS,
|
|
14
|
+
Config,
|
|
15
|
+
load_config,
|
|
16
|
+
resolve_worktrees_dir,
|
|
17
|
+
)
|
|
18
|
+
from workforest.errors import CancelledError, NotARepoError, UsageError, WorkforestError
|
|
19
|
+
from workforest.launch import ShellAction
|
|
20
|
+
|
|
21
|
+
type CommandResult = ShellAction | str | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class Context:
|
|
26
|
+
cwd_root: Path # repo root of the invocation directory
|
|
27
|
+
main: Path # main worktree ($WF_MAIN)
|
|
28
|
+
config: Config
|
|
29
|
+
worktrees_dir: Path
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def repo_name(self) -> str:
|
|
33
|
+
return self.main.name
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_context(cwd: Path | None = None) -> Context:
|
|
37
|
+
cwd_root = gitutil.repo_root(cwd)
|
|
38
|
+
main = gitutil.main_worktree(cwd_root)
|
|
39
|
+
config = load_config(main)
|
|
40
|
+
worktrees_dir = resolve_worktrees_dir(config, main)
|
|
41
|
+
return Context(cwd_root=cwd_root, main=main, config=config, worktrees_dir=worktrees_dir)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def managed_worktrees(ctx: Context) -> list[gitutil.Worktree]:
|
|
45
|
+
"""Worktrees located directly inside the resolved worktrees dir
|
|
46
|
+
(DESIGN §3.5) — the only ones we list, complete, or delete."""
|
|
47
|
+
return [
|
|
48
|
+
worktree
|
|
49
|
+
for worktree in gitutil.list_worktrees(ctx.main)
|
|
50
|
+
if not worktree.is_main and worktree.path.parent == ctx.worktrees_dir
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def find_managed(ctx: Context, name: str) -> gitutil.Worktree:
|
|
55
|
+
for worktree in managed_worktrees(ctx):
|
|
56
|
+
if worktree.name == name:
|
|
57
|
+
return worktree
|
|
58
|
+
raise WorkforestError(f"worktree {name!r} not found in {ctx.worktrees_dir}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def short_branch_name(branch: str) -> str:
|
|
62
|
+
return branch.rsplit("/", 1)[-1]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _script_env(ctx: Context, worktree: Path, branch: str | None) -> dict[str, str]:
|
|
66
|
+
return hooks.script_env(
|
|
67
|
+
main=ctx.main,
|
|
68
|
+
worktree=worktree,
|
|
69
|
+
worktrees_dir=ctx.worktrees_dir,
|
|
70
|
+
branch=branch,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cmd_create(
|
|
75
|
+
ctx: Context,
|
|
76
|
+
branch: str | None,
|
|
77
|
+
*,
|
|
78
|
+
opener: str | None = None,
|
|
79
|
+
path_arg: str | None = None,
|
|
80
|
+
no_hooks: bool = False,
|
|
81
|
+
no_open: bool = False,
|
|
82
|
+
) -> CommandResult:
|
|
83
|
+
if not branch:
|
|
84
|
+
branch = gitutil.current_branch(ctx.cwd_root)
|
|
85
|
+
if branch == "HEAD":
|
|
86
|
+
raise WorkforestError("detached HEAD: specify a branch name")
|
|
87
|
+
|
|
88
|
+
existing = gitutil.find_branch_worktree(branch, ctx.main)
|
|
89
|
+
if existing is not None:
|
|
90
|
+
output.warn(f"branch {branch!r} already checked out at {existing.path}")
|
|
91
|
+
worktree_path = existing.path
|
|
92
|
+
else:
|
|
93
|
+
worktree_path = ctx.worktrees_dir / short_branch_name(branch)
|
|
94
|
+
registered = {w.path for w in gitutil.list_worktrees(ctx.main)}
|
|
95
|
+
if worktree_path in registered:
|
|
96
|
+
# Same directory name, different branch (feat/x vs fix/x): never
|
|
97
|
+
# silently reuse another branch's worktree.
|
|
98
|
+
raise WorkforestError(
|
|
99
|
+
f"{worktree_path} already holds a different branch; "
|
|
100
|
+
f"remove it first or use a different branch name"
|
|
101
|
+
)
|
|
102
|
+
if worktree_path.exists():
|
|
103
|
+
raise WorkforestError(f"directory exists but is not a worktree: {worktree_path}")
|
|
104
|
+
ctx.worktrees_dir.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
gitutil.worktree_add(ctx.main, worktree_path, branch)
|
|
106
|
+
output.success(f"created worktree for {branch!r} at {worktree_path}")
|
|
107
|
+
if not no_hooks:
|
|
108
|
+
env = _script_env(ctx, worktree_path, branch)
|
|
109
|
+
hooks.create_symlinks(ctx.config, main=ctx.main, worktree=worktree_path)
|
|
110
|
+
failures = hooks.run_setup_scripts(ctx.config, worktree=worktree_path, env=env)
|
|
111
|
+
if failures:
|
|
112
|
+
output.warn(f"{failures} setup script(s) failed")
|
|
113
|
+
|
|
114
|
+
if no_open:
|
|
115
|
+
return None
|
|
116
|
+
return launch.launch(
|
|
117
|
+
ctx.config,
|
|
118
|
+
worktree=worktree_path,
|
|
119
|
+
repo_name=ctx.repo_name,
|
|
120
|
+
opener_arg=opener,
|
|
121
|
+
path_arg=path_arg,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def cmd_open(
|
|
126
|
+
ctx: Context,
|
|
127
|
+
name: str | None,
|
|
128
|
+
*,
|
|
129
|
+
opener: str | None = None,
|
|
130
|
+
path_arg: str | None = None,
|
|
131
|
+
) -> CommandResult:
|
|
132
|
+
if not name:
|
|
133
|
+
raise UsageError("worktree name required")
|
|
134
|
+
worktree = find_managed(ctx, name)
|
|
135
|
+
return launch.launch(
|
|
136
|
+
ctx.config,
|
|
137
|
+
worktree=worktree.path,
|
|
138
|
+
repo_name=ctx.repo_name,
|
|
139
|
+
opener_arg=opener,
|
|
140
|
+
path_arg=path_arg,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def cmd_list(ctx: Context, *, porcelain: bool = False) -> CommandResult:
|
|
145
|
+
worktrees = managed_worktrees(ctx)
|
|
146
|
+
if porcelain:
|
|
147
|
+
lines = [
|
|
148
|
+
"\t".join(
|
|
149
|
+
(
|
|
150
|
+
w.name,
|
|
151
|
+
w.branch or "",
|
|
152
|
+
str(w.path),
|
|
153
|
+
"1" if gitutil.status_porcelain(w.path) else "0",
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
for w in worktrees
|
|
157
|
+
]
|
|
158
|
+
return "\n".join(lines)
|
|
159
|
+
if not worktrees:
|
|
160
|
+
output.info(f"no worktrees in {ctx.worktrees_dir} (create one with: wf create BRANCH)")
|
|
161
|
+
return None
|
|
162
|
+
name_width = max(len(w.name) for w in worktrees)
|
|
163
|
+
branch_width = max(len(w.branch or "(detached)") for w in worktrees)
|
|
164
|
+
rows = [
|
|
165
|
+
f"{w.name:<{name_width}} {w.branch or '(detached)':<{branch_width}} "
|
|
166
|
+
f"{'dirty' if gitutil.status_porcelain(w.path) else 'clean'} {w.path}"
|
|
167
|
+
for w in worktrees
|
|
168
|
+
]
|
|
169
|
+
return "\n".join(rows)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _confirm_dirty(worktree: gitutil.Worktree, question: str, *, force: bool) -> None:
|
|
173
|
+
"""Shared delete/checkout guard for uncommitted changes."""
|
|
174
|
+
if force:
|
|
175
|
+
return
|
|
176
|
+
changes = gitutil.status_porcelain(worktree.path)
|
|
177
|
+
if not changes:
|
|
178
|
+
return
|
|
179
|
+
lines = changes.splitlines()
|
|
180
|
+
output.warn(f"worktree {worktree.name!r} has uncommitted changes:")
|
|
181
|
+
for line in lines[:10]:
|
|
182
|
+
output.info(f" {line}")
|
|
183
|
+
if len(lines) > 10:
|
|
184
|
+
output.info(" ...")
|
|
185
|
+
if not output.confirm(question):
|
|
186
|
+
raise CancelledError("cancelled") from None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def cmd_delete(
|
|
190
|
+
ctx: Context,
|
|
191
|
+
names: list[str],
|
|
192
|
+
*,
|
|
193
|
+
force: bool = False,
|
|
194
|
+
delete_branch: bool | None = None,
|
|
195
|
+
) -> CommandResult:
|
|
196
|
+
result: CommandResult = None
|
|
197
|
+
for name in names:
|
|
198
|
+
worktree = find_managed(ctx, name)
|
|
199
|
+
_confirm_dirty(worktree, "Delete anyway?", force=force)
|
|
200
|
+
branch = worktree.branch
|
|
201
|
+
gitutil.worktree_remove(ctx.main, worktree.path, force=True)
|
|
202
|
+
output.success(f"deleted worktree {name!r}")
|
|
203
|
+
if worktree.path == ctx.cwd_root:
|
|
204
|
+
# The shell is standing in the directory we just removed —
|
|
205
|
+
# move it back to the main checkout.
|
|
206
|
+
result = launch.cd_action(ctx.main)
|
|
207
|
+
if branch is None:
|
|
208
|
+
continue
|
|
209
|
+
decision = delete_branch
|
|
210
|
+
if decision is None and output.interactive():
|
|
211
|
+
decision = output.confirm(f"Also delete branch {branch!r}?")
|
|
212
|
+
if decision:
|
|
213
|
+
try:
|
|
214
|
+
gitutil.delete_branch(ctx.main, branch)
|
|
215
|
+
output.success(f"deleted branch {branch!r}")
|
|
216
|
+
except WorkforestError as exc:
|
|
217
|
+
output.warn(str(exc))
|
|
218
|
+
return result
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def cmd_checkout(ctx: Context, name: str, *, force: bool = False) -> CommandResult:
|
|
222
|
+
worktree = find_managed(ctx, name)
|
|
223
|
+
branch = worktree.branch
|
|
224
|
+
if branch is None:
|
|
225
|
+
raise WorkforestError(f"cannot determine branch for worktree {name!r} (detached HEAD)")
|
|
226
|
+
_confirm_dirty(
|
|
227
|
+
worktree,
|
|
228
|
+
"Delete worktree and checkout its branch in the main repo anyway?",
|
|
229
|
+
force=force,
|
|
230
|
+
)
|
|
231
|
+
gitutil.worktree_remove(ctx.main, worktree.path, force=True)
|
|
232
|
+
output.success(f"deleted worktree {name!r}")
|
|
233
|
+
gitutil.checkout(ctx.main, branch)
|
|
234
|
+
output.success(f"checked out {branch!r} in {ctx.main}")
|
|
235
|
+
return launch.cd_action(ctx.main)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def cmd_run(ctx: Context, name: str, extra_args: list[str] | None = None) -> CommandResult:
|
|
239
|
+
branch = gitutil.current_branch(ctx.cwd_root)
|
|
240
|
+
env = _script_env(ctx, ctx.cwd_root, None if branch == "HEAD" else branch)
|
|
241
|
+
hooks.run_named_script(ctx.config, name, cwd=ctx.cwd_root, env=env, extra_args=extra_args)
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _scaffold_template() -> str:
|
|
246
|
+
resource = importlib.resources.files("workforest") / "examples" / ".workforest.yaml"
|
|
247
|
+
return resource.read_text()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def cmd_init(ctx: Context, *, local: bool = False) -> CommandResult:
|
|
251
|
+
if local:
|
|
252
|
+
for candidate in PROJECT_LOCAL_DIRS:
|
|
253
|
+
directory = ctx.main / candidate
|
|
254
|
+
if directory.is_dir():
|
|
255
|
+
break
|
|
256
|
+
else:
|
|
257
|
+
dirs = " or ".join(f"{d}/" for d in PROJECT_LOCAL_DIRS)
|
|
258
|
+
raise WorkforestError(f"--local needs an IDE settings folder ({dirs}) in {ctx.main}")
|
|
259
|
+
else:
|
|
260
|
+
directory = ctx.main
|
|
261
|
+
target = directory / ".workforest.yaml"
|
|
262
|
+
if target.exists():
|
|
263
|
+
raise WorkforestError(f"{target} already exists")
|
|
264
|
+
target.write_text(_scaffold_template())
|
|
265
|
+
output.success(f"scaffolded {target}")
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def cmd_config_show(*, as_json: bool = False) -> CommandResult:
|
|
270
|
+
try:
|
|
271
|
+
ctx = build_context()
|
|
272
|
+
config = ctx.config
|
|
273
|
+
except NotARepoError:
|
|
274
|
+
config = load_config(None)
|
|
275
|
+
sources = [(layer, str(path)) for layer, path in config.sources]
|
|
276
|
+
if as_json:
|
|
277
|
+
return json.dumps({"config": config.as_dict(), "sources": sources}, indent=2)
|
|
278
|
+
dump = yaml.safe_dump(config.as_dict(), sort_keys=False).rstrip("\n")
|
|
279
|
+
lines = [dump, "", "# sources (low -> high):"]
|
|
280
|
+
if sources:
|
|
281
|
+
lines.extend(f"# {layer}: {path}" for layer, path in sources)
|
|
282
|
+
else:
|
|
283
|
+
lines.append("# (built-in defaults only)")
|
|
284
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""`workforest --complete TOPIC` backend.
|
|
2
|
+
|
|
3
|
+
Completion must never break the shell: any error yields an empty candidate
|
|
4
|
+
list, and everything stays on stdout as bare lines.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from workforest import commands, gitutil
|
|
8
|
+
from workforest.config import Config, load_config
|
|
9
|
+
from workforest.errors import WorkforestError
|
|
10
|
+
|
|
11
|
+
TOPICS = ("commands", "branches", "worktrees", "scripts", "openers", "claude-sessions")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def complete(topic: str) -> list[str]:
|
|
15
|
+
try:
|
|
16
|
+
return _complete(topic)
|
|
17
|
+
except WorkforestError, OSError:
|
|
18
|
+
return []
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _complete(topic: str) -> list[str]:
|
|
22
|
+
match topic:
|
|
23
|
+
case "commands":
|
|
24
|
+
return _commands()
|
|
25
|
+
case "branches":
|
|
26
|
+
return _branches()
|
|
27
|
+
case "worktrees":
|
|
28
|
+
return _worktrees()
|
|
29
|
+
case "scripts":
|
|
30
|
+
return sorted(_config().scripts)
|
|
31
|
+
case "openers":
|
|
32
|
+
return sorted(_config().openers)
|
|
33
|
+
case "claude-sessions":
|
|
34
|
+
return _claude_sessions()
|
|
35
|
+
case _:
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _config() -> Config:
|
|
40
|
+
try:
|
|
41
|
+
return commands.build_context().config
|
|
42
|
+
except WorkforestError:
|
|
43
|
+
return load_config(None)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _commands() -> list[str]:
|
|
47
|
+
from workforest.cli import _known_subcommands
|
|
48
|
+
|
|
49
|
+
return sorted(_known_subcommands()) + sorted(_config().openers)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _branches() -> list[str]:
|
|
53
|
+
"""Local then remote branches, minus those already checked out."""
|
|
54
|
+
root = gitutil.repo_root()
|
|
55
|
+
taken = {w.branch for w in gitutil.list_worktrees(root) if w.branch}
|
|
56
|
+
candidates: dict[str, None] = {}
|
|
57
|
+
for branch in (*gitutil.local_branches(root), *gitutil.remote_branches(root)):
|
|
58
|
+
if branch not in taken:
|
|
59
|
+
candidates.setdefault(branch)
|
|
60
|
+
return list(candidates)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _worktrees() -> list[str]:
|
|
64
|
+
ctx = commands.build_context()
|
|
65
|
+
return [w.name for w in commands.managed_worktrees(ctx)]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _claude_sessions() -> list[str]:
|
|
69
|
+
from workforest.integrations import claude
|
|
70
|
+
|
|
71
|
+
if not claude.available():
|
|
72
|
+
return []
|
|
73
|
+
ctx = commands.build_context()
|
|
74
|
+
if ctx.cwd_root == ctx.main:
|
|
75
|
+
return []
|
|
76
|
+
return [sid for sid, _ in claude.list_new_sessions(ctx.main, ctx.cwd_root)]
|