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/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}")
@@ -0,0 +1,2 @@
1
+ """Optional integrations (DESIGN §3.7). Core never imports this subpackage;
2
+ each integration is feature-gated on its own environment being present."""