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/config.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Configuration: schema, layered loading, merging, template resolution.
|
|
2
|
+
|
|
3
|
+
Layers (low → high, DESIGN §4.1): built-in defaults → system → user →
|
|
4
|
+
project-shared (main worktree root) → project-local (.vscode/ then .idea/) →
|
|
5
|
+
environment → CLI flags (applied by commands, not here).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import string
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from workforest.errors import ConfigError
|
|
18
|
+
|
|
19
|
+
SYSTEM_CONFIG_DIR = Path("/etc/workforest")
|
|
20
|
+
GLOBAL_BASENAMES = ("config.yaml", "config.yml", "config.json")
|
|
21
|
+
PROJECT_BASENAMES = (".workforest.yaml", ".workforest.yml", ".workforest.json")
|
|
22
|
+
PROJECT_LOCAL_DIRS = (".vscode", ".idea")
|
|
23
|
+
|
|
24
|
+
# key -> (kind, default). Kinds: "str", "list", "map" (str -> str, where a
|
|
25
|
+
# null value deletes the inherited entry during merge).
|
|
26
|
+
_SCHEMA: dict[str, tuple[str, Any]] = {
|
|
27
|
+
"worktrees_dir": ("str", "$WF_MAIN/../worktrees/$WF_NAME"),
|
|
28
|
+
"opener": ("str", ""),
|
|
29
|
+
"openers": ("map", {}),
|
|
30
|
+
"window_command": ("str", ""),
|
|
31
|
+
"symlinks": ("list", []),
|
|
32
|
+
"setup_scripts": ("list", []),
|
|
33
|
+
"scripts": ("map", {}),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(slots=True)
|
|
38
|
+
class Config:
|
|
39
|
+
worktrees_dir: str = "$WF_MAIN/../worktrees/$WF_NAME"
|
|
40
|
+
opener: str = ""
|
|
41
|
+
openers: dict[str, str] = field(default_factory=dict)
|
|
42
|
+
window_command: str = ""
|
|
43
|
+
symlinks: list[str] = field(default_factory=list)
|
|
44
|
+
setup_scripts: list[str] = field(default_factory=list)
|
|
45
|
+
scripts: dict[str, str] = field(default_factory=dict)
|
|
46
|
+
sources: list[tuple[str, Path]] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
def as_dict(self) -> dict[str, Any]:
|
|
49
|
+
return {
|
|
50
|
+
"worktrees_dir": self.worktrees_dir,
|
|
51
|
+
"opener": self.opener,
|
|
52
|
+
"openers": self.openers,
|
|
53
|
+
"window_command": self.window_command,
|
|
54
|
+
"symlinks": self.symlinks,
|
|
55
|
+
"setup_scripts": self.setup_scripts,
|
|
56
|
+
"scripts": self.scripts,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_file(path: Path) -> dict[str, Any]:
|
|
61
|
+
text = path.read_text()
|
|
62
|
+
if path.suffix == ".json":
|
|
63
|
+
try:
|
|
64
|
+
data = json.loads(text)
|
|
65
|
+
except json.JSONDecodeError as exc:
|
|
66
|
+
raise ConfigError(f"{path}: invalid JSON: {exc}") from exc
|
|
67
|
+
else:
|
|
68
|
+
try:
|
|
69
|
+
data = yaml.safe_load(text)
|
|
70
|
+
except yaml.YAMLError as exc:
|
|
71
|
+
raise ConfigError(f"{path}: invalid YAML: {exc}") from exc
|
|
72
|
+
if data is None:
|
|
73
|
+
return {}
|
|
74
|
+
if not isinstance(data, dict):
|
|
75
|
+
raise ConfigError(f"{path}: top level must be a mapping, got {type(data).__name__}")
|
|
76
|
+
return data
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _validate(data: dict[str, Any], path: Path) -> None:
|
|
80
|
+
for key, value in data.items():
|
|
81
|
+
if key not in _SCHEMA:
|
|
82
|
+
known = ", ".join(sorted(_SCHEMA))
|
|
83
|
+
raise ConfigError(f"{path}: unknown key {key!r} (known keys: {known})")
|
|
84
|
+
kind, _ = _SCHEMA[key]
|
|
85
|
+
match kind:
|
|
86
|
+
case "str":
|
|
87
|
+
if not isinstance(value, str):
|
|
88
|
+
raise ConfigError(
|
|
89
|
+
f"{path}: {key!r} must be a string, got {type(value).__name__}"
|
|
90
|
+
)
|
|
91
|
+
case "list":
|
|
92
|
+
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
|
93
|
+
raise ConfigError(f"{path}: {key!r} must be a list of strings")
|
|
94
|
+
case "map":
|
|
95
|
+
if not isinstance(value, dict) or not all(
|
|
96
|
+
isinstance(k, str) and (v is None or isinstance(v, str))
|
|
97
|
+
for k, v in value.items()
|
|
98
|
+
):
|
|
99
|
+
raise ConfigError(
|
|
100
|
+
f"{path}: {key!r} must be a mapping of string to string (or null)"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
|
|
105
|
+
"""Scalars/lists replace; mappings merge per key with null deleting."""
|
|
106
|
+
merged = dict(base)
|
|
107
|
+
for key, value in overlay.items():
|
|
108
|
+
kind, _ = _SCHEMA[key]
|
|
109
|
+
if kind == "map":
|
|
110
|
+
combined: dict[str, str] = dict(merged.get(key, {}))
|
|
111
|
+
for name, entry in value.items():
|
|
112
|
+
if entry is None:
|
|
113
|
+
combined.pop(name, None)
|
|
114
|
+
else:
|
|
115
|
+
combined[name] = entry
|
|
116
|
+
merged[key] = combined
|
|
117
|
+
else:
|
|
118
|
+
merged[key] = value
|
|
119
|
+
return merged
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _first_existing(directory: Path, basenames: tuple[str, ...]) -> Path | None:
|
|
123
|
+
for basename in basenames:
|
|
124
|
+
candidate = directory / basename
|
|
125
|
+
if candidate.is_file():
|
|
126
|
+
return candidate
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _layer_files(main_worktree: Path | None) -> list[tuple[str, Path]]:
|
|
131
|
+
layers: list[tuple[str, Path]] = []
|
|
132
|
+
if found := _first_existing(SYSTEM_CONFIG_DIR, GLOBAL_BASENAMES):
|
|
133
|
+
layers.append(("system", found))
|
|
134
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
135
|
+
user_dir = (Path(xdg) if xdg else Path.home() / ".config") / "workforest"
|
|
136
|
+
if found := _first_existing(user_dir, GLOBAL_BASENAMES):
|
|
137
|
+
layers.append(("user", found))
|
|
138
|
+
if main_worktree is not None:
|
|
139
|
+
if found := _first_existing(main_worktree, PROJECT_BASENAMES):
|
|
140
|
+
layers.append(("project", found))
|
|
141
|
+
for local_dir in PROJECT_LOCAL_DIRS:
|
|
142
|
+
if found := _first_existing(main_worktree / local_dir, PROJECT_BASENAMES):
|
|
143
|
+
layers.append(("project-local", found))
|
|
144
|
+
break
|
|
145
|
+
return layers
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def load_config(main_worktree: Path | None = None) -> Config:
|
|
149
|
+
"""Load and merge all layers; main_worktree=None skips project layers."""
|
|
150
|
+
merged = {key: default for key, (_, default) in _SCHEMA.items()}
|
|
151
|
+
sources: list[tuple[str, Path]] = []
|
|
152
|
+
for layer, path in _layer_files(main_worktree):
|
|
153
|
+
data = _parse_file(path)
|
|
154
|
+
_validate(data, path)
|
|
155
|
+
merged = _merge(merged, data)
|
|
156
|
+
sources.append((layer, path))
|
|
157
|
+
|
|
158
|
+
# Set-but-empty is meaningful: WORKFOREST_WINDOW_COMMAND="" forces the
|
|
159
|
+
# current-shell mode in sessions where the configured window_command
|
|
160
|
+
# doesn't apply (ssh, plain tty); likewise an empty WORKFOREST_OPENER
|
|
161
|
+
# resets to the $VISUAL/$EDITOR chain.
|
|
162
|
+
if (opener := os.environ.get("WORKFOREST_OPENER")) is not None:
|
|
163
|
+
merged["opener"] = opener
|
|
164
|
+
if (window := os.environ.get("WORKFOREST_WINDOW_COMMAND")) is not None:
|
|
165
|
+
merged["window_command"] = window
|
|
166
|
+
|
|
167
|
+
return Config(**merged, sources=sources)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def template_vars(main_worktree: Path) -> dict[str, str]:
|
|
171
|
+
"""The WF_* family as template variables (DESIGN §3.5/§3.6)."""
|
|
172
|
+
return {
|
|
173
|
+
"WF_MAIN": str(main_worktree),
|
|
174
|
+
"WF_NAME": main_worktree.name,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def resolve_worktrees_dir(config: Config, main_worktree: Path) -> Path:
|
|
179
|
+
"""Expand $WF_* and environment variables, then normalize the path."""
|
|
180
|
+
mapping = {**os.environ, **template_vars(main_worktree)}
|
|
181
|
+
try:
|
|
182
|
+
expanded = string.Template(config.worktrees_dir).substitute(mapping)
|
|
183
|
+
except (KeyError, ValueError) as exc:
|
|
184
|
+
raise ConfigError(f"worktrees_dir {config.worktrees_dir!r}: {exc}") from exc
|
|
185
|
+
path = Path(expanded).expanduser()
|
|
186
|
+
if not path.is_absolute():
|
|
187
|
+
path = main_worktree / path
|
|
188
|
+
return Path(os.path.normpath(path))
|
workforest/errors.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Error hierarchy. cli.py maps these to messages and exit codes (DESIGN §5)."""
|
|
2
|
+
|
|
3
|
+
EXIT_OK = 0
|
|
4
|
+
EXIT_ERROR = 1
|
|
5
|
+
EXIT_USAGE = 2
|
|
6
|
+
EXIT_CANCELLED = 3
|
|
7
|
+
EXIT_CONFIG = 4
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WorkforestError(Exception):
|
|
11
|
+
"""Operational error; the message is shown to the user."""
|
|
12
|
+
|
|
13
|
+
exit_code = EXIT_ERROR
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UsageError(WorkforestError):
|
|
17
|
+
exit_code = EXIT_USAGE
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CancelledError(WorkforestError):
|
|
21
|
+
exit_code = EXIT_CANCELLED
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConfigError(WorkforestError):
|
|
25
|
+
exit_code = EXIT_CONFIG
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GitError(WorkforestError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NotARepoError(GitError):
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
super().__init__("Not inside a git repository")
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Workforest — global configuration example
|
|
2
|
+
#
|
|
3
|
+
# Locations (first existing basename wins: config.yaml → config.yml → config.json):
|
|
4
|
+
# system layer: /etc/workforest/config.yaml
|
|
5
|
+
# user layer: $XDG_CONFIG_HOME/workforest/config.yaml (~/.config/workforest/config.yaml)
|
|
6
|
+
#
|
|
7
|
+
# Layer precedence (low → high):
|
|
8
|
+
# built-in defaults → system → user → project shared (repo root)
|
|
9
|
+
# → project local (.vscode/ or .idea/) → env vars → CLI flags
|
|
10
|
+
#
|
|
11
|
+
# Merge semantics:
|
|
12
|
+
# scalars and lists REPLACE the lower layer;
|
|
13
|
+
# mappings (`openers`, `scripts`) MERGE per key, a key set to `null` removes
|
|
14
|
+
# the inherited entry.
|
|
15
|
+
#
|
|
16
|
+
# Every key is optional. The values shown at the top of each section are the
|
|
17
|
+
# built-in defaults — a missing key and an explicitly-default key behave the
|
|
18
|
+
# same. The global layers are the natural home for ENVIRONMENT concerns
|
|
19
|
+
# (terminal, editor, personal helper scripts); repo concerns belong in the
|
|
20
|
+
# project config (see examples/.workforest.yaml).
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Where managed worktrees live — a path template expanded with the WF_*
|
|
24
|
+
# variables (the same family your scripts receive) plus ordinary environment
|
|
25
|
+
# variables, then path-normalized:
|
|
26
|
+
# $WF_MAIN absolute path of the main worktree
|
|
27
|
+
# $WF_NAME repo name (the main checkout's directory name)
|
|
28
|
+
#
|
|
29
|
+
# Default: a `worktrees/` folder next to the main checkout, namespaced per
|
|
30
|
+
# repo, so ~/src/api → ~/src/worktrees/api/<branch-short-name>.
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
worktrees_dir: "$WF_MAIN/../worktrees/$WF_NAME"
|
|
33
|
+
|
|
34
|
+
# Alternatives:
|
|
35
|
+
# worktrees_dir: "$HOME/worktrees/$WF_NAME" # all forests in one place
|
|
36
|
+
# worktrees_dir: "$WF_MAIN/../worktrees" # bash-MVP flat layout
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Default opener — what runs when you `wf create`/`wf open` without -o.
|
|
40
|
+
#
|
|
41
|
+
# Value is either a name from the `openers` mapping below, or a command
|
|
42
|
+
# template used verbatim.
|
|
43
|
+
#
|
|
44
|
+
# Default: "" — falls back to $VISUAL, then $EDITOR, then errors out asking
|
|
45
|
+
# you to set one. Workforest never assumes a specific editor.
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
opener: ""
|
|
48
|
+
|
|
49
|
+
# opener: edit # a name defined in `openers`
|
|
50
|
+
# opener: "$EDITOR {path}" # or an inline template
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Named openers — command templates for -o NAME, the shortcut form
|
|
54
|
+
# (`wf NAME <worktree>`), and the TUI opener carousel.
|
|
55
|
+
#
|
|
56
|
+
# Template rules:
|
|
57
|
+
# * environment variables are expanded ($EDITOR, $SHELL, …)
|
|
58
|
+
# * {path} is replaced with the shell-quoted target path
|
|
59
|
+
# * a template WITHOUT {path} simply runs with the target directory as its
|
|
60
|
+
# working directory
|
|
61
|
+
#
|
|
62
|
+
# Default: {} — the TUI then falls back to two derived entries:
|
|
63
|
+
# the default opener and $SHELL.
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
openers: {}
|
|
66
|
+
|
|
67
|
+
# openers:
|
|
68
|
+
# edit: "$EDITOR {path}" # editors usually want the path argument
|
|
69
|
+
# shell: "$SHELL" # just a shell in the worktree
|
|
70
|
+
# git: "lazygit" # runs with cwd = worktree, no argument
|
|
71
|
+
# ide: "xdg-open {path}" # hand off to the desktop default handler
|
|
72
|
+
# agent: "claude"
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Window command — WHERE the opener runs.
|
|
76
|
+
#
|
|
77
|
+
# Default: "" — the opener runs in your current shell via the `wf` wrapper
|
|
78
|
+
# (`cd <worktree> && <opener>`).
|
|
79
|
+
#
|
|
80
|
+
# Set a template to spawn a new terminal window / multiplexer pane instead.
|
|
81
|
+
# Placeholders: {title} "repo: worktree", shell-quoted
|
|
82
|
+
# {path} target directory
|
|
83
|
+
# {command} the resolved opener command
|
|
84
|
+
# The process is spawned fully detached.
|
|
85
|
+
#
|
|
86
|
+
# This key is the environment-specific bit par excellence — it belongs in
|
|
87
|
+
# your USER config, never in a project config.
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
window_command: ""
|
|
90
|
+
|
|
91
|
+
# window_command: "kitty --title {title} --directory {path} {command}"
|
|
92
|
+
# window_command: "foot --title={title} --working-directory={path} {command}"
|
|
93
|
+
# window_command: "alacritty --title {title} --working-directory {path} -e {command}"
|
|
94
|
+
# window_command: "tmux new-window -c {path} -n {title} {command}"
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Personal named scripts — available via `workforest run NAME` in every repo.
|
|
98
|
+
# Merged per key with project `scripts` (project wins on conflict).
|
|
99
|
+
#
|
|
100
|
+
# Scripts run from the current worktree root via $SHELL -c with:
|
|
101
|
+
# WF_MAIN, WF_WORKTREE, WF_WORKTREES_DIR, WF_BRANCH
|
|
102
|
+
#
|
|
103
|
+
# Default: {}
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
scripts: {}
|
|
106
|
+
|
|
107
|
+
# scripts:
|
|
108
|
+
# sync: "git fetch --all --prune && git status -sb"
|
|
109
|
+
# diff-main: "git diff $(git -C \"$WF_MAIN\" branch --show-current)..."
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# `symlinks` and `setup_scripts` are valid here too (any key is valid at any
|
|
113
|
+
# layer), but they are repo concerns — you almost always want them in the
|
|
114
|
+
# project config instead. Defaults: [] and [].
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# symlinks: []
|
|
117
|
+
# setup_scripts: []
|
workforest/gitutil.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Typed subprocess wrappers around git plumbing.
|
|
2
|
+
|
|
3
|
+
The only module that spawns git (DESIGN §5). Consumers get typed results;
|
|
4
|
+
worktree data comes from `--porcelain -z` output, never from parsing the
|
|
5
|
+
human-readable form.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from workforest.errors import GitError, NotARepoError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_git(
|
|
16
|
+
args: list[str],
|
|
17
|
+
*,
|
|
18
|
+
cwd: Path | None = None,
|
|
19
|
+
check: bool = True,
|
|
20
|
+
) -> subprocess.CompletedProcess[str]:
|
|
21
|
+
"""Run git and return the completed process; raise GitError on failure."""
|
|
22
|
+
cmd = ["git", *args]
|
|
23
|
+
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, check=False)
|
|
24
|
+
if check and result.returncode != 0:
|
|
25
|
+
detail = result.stderr.strip() or result.stdout.strip() or "unknown error"
|
|
26
|
+
raise GitError(f"`{' '.join(cmd)}` failed: {detail}")
|
|
27
|
+
return result
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def git_output(args: list[str], *, cwd: Path | None = None) -> str:
|
|
31
|
+
return run_git(args, cwd=cwd).stdout.strip()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def repo_root(cwd: Path | None = None) -> Path:
|
|
35
|
+
result = run_git(["rev-parse", "--show-toplevel"], cwd=cwd, check=False)
|
|
36
|
+
if result.returncode != 0:
|
|
37
|
+
raise NotARepoError()
|
|
38
|
+
return Path(result.stdout.strip())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True, frozen=True)
|
|
42
|
+
class Worktree:
|
|
43
|
+
path: Path
|
|
44
|
+
head: str
|
|
45
|
+
branch: str | None # short name; None when detached or bare
|
|
46
|
+
is_main: bool
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def name(self) -> str:
|
|
50
|
+
return self.path.name
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_worktree_porcelain(data: str) -> list[Worktree]:
|
|
54
|
+
"""Parse `git worktree list --porcelain -z` output.
|
|
55
|
+
|
|
56
|
+
Records are groups of NUL-terminated attribute lines separated by an
|
|
57
|
+
empty entry. Unknown attributes (locked, prunable, future ones) are
|
|
58
|
+
ignored. The first record is the main worktree — a git guarantee.
|
|
59
|
+
"""
|
|
60
|
+
worktrees: list[Worktree] = []
|
|
61
|
+
record: dict[str, str] = {}
|
|
62
|
+
for token in data.split("\0"):
|
|
63
|
+
if token == "":
|
|
64
|
+
if record:
|
|
65
|
+
worktrees.append(_record_to_worktree(record, is_main=not worktrees))
|
|
66
|
+
record = {}
|
|
67
|
+
continue
|
|
68
|
+
key, _, value = token.partition(" ")
|
|
69
|
+
record[key] = value
|
|
70
|
+
if record:
|
|
71
|
+
worktrees.append(_record_to_worktree(record, is_main=not worktrees))
|
|
72
|
+
return worktrees
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _record_to_worktree(record: dict[str, str], *, is_main: bool) -> Worktree:
|
|
76
|
+
branch = record.get("branch")
|
|
77
|
+
if branch is not None:
|
|
78
|
+
branch = branch.removeprefix("refs/heads/")
|
|
79
|
+
return Worktree(
|
|
80
|
+
path=Path(record["worktree"]),
|
|
81
|
+
head=record.get("HEAD", ""),
|
|
82
|
+
branch=branch,
|
|
83
|
+
is_main=is_main,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def list_worktrees(cwd: Path | None = None) -> list[Worktree]:
|
|
88
|
+
result = run_git(["worktree", "list", "--porcelain", "-z"], cwd=cwd, check=False)
|
|
89
|
+
if result.returncode != 0:
|
|
90
|
+
raise NotARepoError()
|
|
91
|
+
return parse_worktree_porcelain(result.stdout)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main_worktree(cwd: Path | None = None) -> Path:
|
|
95
|
+
return list_worktrees(cwd)[0].path
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def current_branch(cwd: Path | None = None) -> str:
|
|
99
|
+
"""Short branch name, or "HEAD" when detached."""
|
|
100
|
+
return git_output(["rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def local_branches(cwd: Path | None = None) -> list[str]:
|
|
104
|
+
out = git_output(["for-each-ref", "--format=%(refname:short)", "refs/heads"], cwd=cwd)
|
|
105
|
+
return out.splitlines() if out else []
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def remote_branches(cwd: Path | None = None) -> list[str]:
|
|
109
|
+
"""Remote branch names with the remote prefix stripped, deduplicated."""
|
|
110
|
+
out = git_output(["for-each-ref", "--format=%(refname:short)", "refs/remotes"], cwd=cwd)
|
|
111
|
+
seen: dict[str, None] = {}
|
|
112
|
+
for ref in out.splitlines():
|
|
113
|
+
_, _, name = ref.partition("/")
|
|
114
|
+
if name and name != "HEAD":
|
|
115
|
+
seen.setdefault(name)
|
|
116
|
+
return list(seen)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def branch_exists(branch: str, cwd: Path | None = None) -> bool:
|
|
120
|
+
result = run_git(
|
|
121
|
+
["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"], cwd=cwd, check=False
|
|
122
|
+
)
|
|
123
|
+
return result.returncode == 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def remote_branch_exists(branch: str, cwd: Path | None = None) -> bool:
|
|
127
|
+
result = run_git(
|
|
128
|
+
["show-ref", "--verify", "--quiet", f"refs/remotes/origin/{branch}"],
|
|
129
|
+
cwd=cwd,
|
|
130
|
+
check=False,
|
|
131
|
+
)
|
|
132
|
+
return result.returncode == 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def find_branch_worktree(branch: str, cwd: Path | None = None) -> Worktree | None:
|
|
136
|
+
for worktree in list_worktrees(cwd):
|
|
137
|
+
if worktree.branch == branch:
|
|
138
|
+
return worktree
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def status_porcelain(path: Path) -> str:
|
|
143
|
+
"""Empty string means clean."""
|
|
144
|
+
return run_git(["status", "--porcelain"], cwd=path).stdout.rstrip("\n")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def worktree_add(repo: Path, path: Path, branch: str) -> None:
|
|
148
|
+
"""Add a worktree, resolving the branch local → origin remote → new."""
|
|
149
|
+
if branch_exists(branch, repo) or remote_branch_exists(branch, repo):
|
|
150
|
+
# git checks out the local branch, or DWIMs a tracking branch from
|
|
151
|
+
# the remote one.
|
|
152
|
+
run_git(["worktree", "add", str(path), branch], cwd=repo)
|
|
153
|
+
else:
|
|
154
|
+
run_git(["worktree", "add", "-b", branch, str(path)], cwd=repo)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def worktree_remove(repo: Path, path: Path, *, force: bool = False) -> None:
|
|
158
|
+
args = ["worktree", "remove"]
|
|
159
|
+
if force:
|
|
160
|
+
args.append("--force")
|
|
161
|
+
run_git([*args, str(path)], cwd=repo)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def checkout(path: Path, branch: str) -> None:
|
|
165
|
+
run_git(["checkout", branch], cwd=path)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def delete_branch(repo: Path, branch: str) -> None:
|
|
169
|
+
run_git(["branch", "-D", branch], cwd=repo)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def git_dir(worktree: Path) -> Path:
|
|
173
|
+
"""Per-worktree git dir (.git/worktrees/<name> for linked worktrees)."""
|
|
174
|
+
return Path(git_output(["rev-parse", "--absolute-git-dir"], cwd=worktree))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def set_config(worktree: Path, key: str, value: str, *, per_worktree: bool = False) -> None:
|
|
178
|
+
args = ["config"]
|
|
179
|
+
if per_worktree:
|
|
180
|
+
args.append("--worktree")
|
|
181
|
+
run_git([*args, key, value], cwd=worktree)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def global_excludes_file() -> Path | None:
|
|
185
|
+
"""The user's global core.excludesFile, following git's own default."""
|
|
186
|
+
result = run_git(["config", "--global", "--get", "core.excludesFile"], check=False)
|
|
187
|
+
value = result.stdout.strip()
|
|
188
|
+
if value:
|
|
189
|
+
return Path(value).expanduser()
|
|
190
|
+
import os
|
|
191
|
+
|
|
192
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
193
|
+
base = Path(xdg) if xdg else Path.home() / ".config"
|
|
194
|
+
return base / "git" / "ignore"
|
workforest/hooks.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Creation hooks (symlinks, setup scripts) and named-script execution.
|
|
2
|
+
|
|
3
|
+
Scripts get exactly one environment variable family, WF_* (DESIGN §3.6), and
|
|
4
|
+
run via $SHELL -c (sh -c fallback). Their stdout is routed to our stderr so
|
|
5
|
+
the cd protocol on stdout stays clean.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import io
|
|
9
|
+
import os
|
|
10
|
+
import shlex
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from workforest import gitutil, output
|
|
16
|
+
from workforest.config import Config
|
|
17
|
+
from workforest.errors import WorkforestError
|
|
18
|
+
|
|
19
|
+
EXCLUDE_FILE_NAME = "workforest.exclude"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def script_env(
|
|
23
|
+
*,
|
|
24
|
+
main: Path,
|
|
25
|
+
worktree: Path,
|
|
26
|
+
worktrees_dir: Path,
|
|
27
|
+
branch: str | None,
|
|
28
|
+
) -> dict[str, str]:
|
|
29
|
+
env = os.environ.copy()
|
|
30
|
+
env.update(
|
|
31
|
+
{
|
|
32
|
+
"WF_MAIN": str(main),
|
|
33
|
+
"WF_NAME": main.name,
|
|
34
|
+
"WF_WORKTREE": str(worktree),
|
|
35
|
+
"WF_WORKTREES_DIR": str(worktrees_dir),
|
|
36
|
+
"WF_BRANCH": branch or "",
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
return env
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _shell() -> str:
|
|
43
|
+
return os.environ.get("SHELL") or "sh"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run_snippet(snippet: str, *, cwd: Path, env: dict[str, str]) -> int:
|
|
47
|
+
"""Run a config-defined shell snippet with stdout diverted to stderr."""
|
|
48
|
+
argv = [_shell(), "-c", snippet]
|
|
49
|
+
try:
|
|
50
|
+
stderr_fd: int | None = sys.stderr.fileno()
|
|
51
|
+
except io.UnsupportedOperation, AttributeError:
|
|
52
|
+
stderr_fd = None
|
|
53
|
+
if stderr_fd is not None:
|
|
54
|
+
result = subprocess.run(argv, cwd=cwd, env=env, stdout=stderr_fd, check=False)
|
|
55
|
+
return result.returncode
|
|
56
|
+
captured = subprocess.run(argv, cwd=cwd, env=env, capture_output=True, text=True, check=False)
|
|
57
|
+
if captured.stdout:
|
|
58
|
+
sys.stderr.write(captured.stdout)
|
|
59
|
+
if captured.stderr:
|
|
60
|
+
sys.stderr.write(captured.stderr)
|
|
61
|
+
return captured.returncode
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def create_symlinks(config: Config, *, main: Path, worktree: Path) -> list[str]:
|
|
65
|
+
"""Symlink configured repo-root-relative paths from main into the
|
|
66
|
+
worktree; returns the created relative paths."""
|
|
67
|
+
created: list[str] = []
|
|
68
|
+
for rel in config.symlinks:
|
|
69
|
+
rel = rel.strip("/")
|
|
70
|
+
if not rel:
|
|
71
|
+
continue
|
|
72
|
+
src = main / rel
|
|
73
|
+
dst = worktree / rel
|
|
74
|
+
if not src.exists():
|
|
75
|
+
output.warn(f"symlink source does not exist, skipping: {src}")
|
|
76
|
+
continue
|
|
77
|
+
if dst.exists() and not dst.is_symlink():
|
|
78
|
+
output.warn(f"destination exists and is not a symlink, skipping: {dst}")
|
|
79
|
+
continue
|
|
80
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
if dst.is_symlink():
|
|
82
|
+
dst.unlink()
|
|
83
|
+
dst.symlink_to(src)
|
|
84
|
+
output.success(f"symlinked {rel} -> {src}")
|
|
85
|
+
created.append(rel)
|
|
86
|
+
if created:
|
|
87
|
+
exclude_from_git(worktree, created)
|
|
88
|
+
return created
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def exclude_from_git(worktree: Path, rel_paths: list[str]) -> None:
|
|
92
|
+
"""Hide the given root-relative paths from git status in this worktree
|
|
93
|
+
only, via a per-worktree core.excludesFile seeded with the user's global
|
|
94
|
+
excludes (so overriding the file loses nothing)."""
|
|
95
|
+
git_dir = gitutil.git_dir(worktree)
|
|
96
|
+
exclude_file = git_dir / EXCLUDE_FILE_NAME
|
|
97
|
+
|
|
98
|
+
gitutil.set_config(worktree, "extensions.worktreeConfig", "true")
|
|
99
|
+
gitutil.set_config(worktree, "core.excludesFile", str(exclude_file), per_worktree=True)
|
|
100
|
+
|
|
101
|
+
lines = ["# Managed by workforest: symlinks from the `symlinks` config key"]
|
|
102
|
+
global_excludes = gitutil.global_excludes_file()
|
|
103
|
+
if global_excludes is not None and global_excludes.is_file():
|
|
104
|
+
lines.append(f"# --- inherited from global core.excludesFile: {global_excludes} ---")
|
|
105
|
+
lines.append(global_excludes.read_text().rstrip("\n"))
|
|
106
|
+
lines.append("# --- workforest symlinks ---")
|
|
107
|
+
lines.extend(f"/{rel}" for rel in rel_paths)
|
|
108
|
+
exclude_file.write_text("\n".join(lines) + "\n")
|
|
109
|
+
output.success(f"excluded {len(rel_paths)} symlink(s) from git in this worktree")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def run_setup_scripts(config: Config, *, worktree: Path, env: dict[str, str]) -> int:
|
|
113
|
+
"""Run setup_scripts in order; failures warn but do not abort. Returns
|
|
114
|
+
the number of failed scripts."""
|
|
115
|
+
failures = 0
|
|
116
|
+
for snippet in config.setup_scripts:
|
|
117
|
+
output.success(f"running setup script: {snippet}")
|
|
118
|
+
if run_snippet(snippet, cwd=worktree, env=env) != 0:
|
|
119
|
+
output.warn(f"setup script failed: {snippet}")
|
|
120
|
+
failures += 1
|
|
121
|
+
return failures
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def run_named_script(
|
|
125
|
+
config: Config,
|
|
126
|
+
name: str,
|
|
127
|
+
*,
|
|
128
|
+
cwd: Path,
|
|
129
|
+
env: dict[str, str],
|
|
130
|
+
extra_args: list[str] | None = None,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Run a `scripts` entry from the merged config; raise on failure.
|
|
133
|
+
|
|
134
|
+
extra_args are shell-quoted and appended to the snippet, so
|
|
135
|
+
`wf run make check` runs `make check` for a script defined as `make`.
|
|
136
|
+
"""
|
|
137
|
+
snippet = config.scripts.get(name)
|
|
138
|
+
if snippet is None:
|
|
139
|
+
available = ", ".join(sorted(config.scripts)) or "none defined"
|
|
140
|
+
raise WorkforestError(f"no script named {name!r} (available: {available})")
|
|
141
|
+
if extra_args:
|
|
142
|
+
snippet = f"{snippet} {' '.join(shlex.quote(arg) for arg in extra_args)}"
|
|
143
|
+
output.success(f"running {name!r} in {cwd}: {snippet}")
|
|
144
|
+
code = run_snippet(snippet, cwd=cwd, env=env)
|
|
145
|
+
if code != 0:
|
|
146
|
+
raise WorkforestError(f"script {name!r} failed with exit code {code}")
|