new-feature 0.1.0__tar.gz

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.
@@ -0,0 +1,17 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Tool-generated local artifacts
13
+ .coverage
14
+ .mypy_cache/
15
+ .pytest_cache/
16
+ .ruff_cache/
17
+ htmlcov/
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.4
2
+ Name: new-feature
3
+ Version: 0.1.0
4
+ Summary: Create isolated feature worktrees with allocated runtime environments.
5
+ Project-URL: Homepage, https://github.com/crypdick/new-feature
6
+ Project-URL: Issues, https://github.com/crypdick/new-feature/issues
7
+ Project-URL: Source, https://github.com/crypdick/new-feature
8
+ Author-email: Ricardo Decal <public@ricardodecal.com>
9
+ Keywords: automation,cli,codex,git,worktree
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Software Development :: Version Control :: Git
17
+ Requires-Python: >=3.13
18
+ Requires-Dist: beartype>=0.22.9
19
+ Requires-Dist: filelock>=3.29.7
20
+ Requires-Dist: tomli-w>=1.2.0
21
+ Description-Content-Type: text/markdown
22
+
23
+ # new-feature
24
+
25
+ `new-feature` creates isolated git worktree environments for feature work, allocates conflict-free runtime values, and launches an interactive Codex session in the new worktree.
26
+
27
+ ## Usage
28
+
29
+ ```bash
30
+ uvx new-feature my-feature
31
+ uvx new-feature merge-feature my-feature
32
+ uvx new-feature teardown my-feature
33
+ uvx new-feature teardown my-feature --force
34
+ ```
35
+
36
+ ## Project Config
37
+
38
+ Add config to the target repo's `pyproject.toml`:
39
+
40
+ ```toml
41
+ [tool.new-feature]
42
+ target_branch = "main"
43
+ branch_prefix = "feature/"
44
+ agent = "codex"
45
+ push = false
46
+ setup = ["uv sync"]
47
+ pre_merge = ["uv run pytest"]
48
+ post_merge = ["uv run pytest"]
49
+ teardown = []
50
+
51
+ [tool.new-feature.env]
52
+ WEB_PORT = { allocate = "port", min = 3000, max = 3999 }
53
+ API_PORT = { allocate = "port", min = 4000, max = 4999 }
54
+ DATABASE_NAME = { allocate = "name", prefix = "myapp", max_length = 63 }
55
+ CACHE_DIR = { allocate = "path", base = ".new-feature/cache" }
56
+ ```
57
+
58
+ Supported env entries:
59
+
60
+ - `{ value = "literal" }`
61
+ - `{ allocate = "port", min = 3000, max = 3999 }`
62
+ - `{ allocate = "integer", min = 1, max = 15 }`
63
+ - `{ allocate = "name", prefix = "myapp", max_length = 63 }`
64
+ - `{ allocate = "slug", prefix = "myapp" }`
65
+ - `{ allocate = "path", base = ".new-feature/cache" }`
66
+
67
+ ## Lifecycle
68
+
69
+ `new-feature my-feature` creates `.worktrees/my-feature`, reserves env values in `.new-feature/manifest.toml`, runs setup, and launches interactive Codex in the worktree. It automatically adds `.new-feature/` and `.worktrees/` to `.gitignore`.
70
+
71
+ `new-feature merge-feature my-feature` runs pre-merge checks in the feature worktree, starts a no-commit merge into the target branch, runs post-merge checks on the merged target checkout, commits the merge only if those checks pass, and pushes only when `push = true`.
72
+
73
+ `new-feature teardown my-feature` runs teardown commands, removes the worktree, deletes the branch, and removes the manifest entry. If the feature has not gone through `merge-feature`, pass `--force` to abandon it deliberately.
74
+
75
+ ## Generated State
76
+
77
+ The generated manifest is a single ignored TOML file in the main checkout:
78
+
79
+ ```text
80
+ .new-feature/manifest.toml
81
+ .new-feature/manifest.lock
82
+ ```
83
+
84
+ The manifest stores all active feature env allocations, so ports, names, paths, and namespaces can be reserved without scanning generated worktrees.
85
+
86
+ ## Publishing
87
+
88
+ See [docs/PUBLISHING.md](docs/PUBLISHING.md) for PyPI release setup and verification.
@@ -0,0 +1,66 @@
1
+ # new-feature
2
+
3
+ `new-feature` creates isolated git worktree environments for feature work, allocates conflict-free runtime values, and launches an interactive Codex session in the new worktree.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ uvx new-feature my-feature
9
+ uvx new-feature merge-feature my-feature
10
+ uvx new-feature teardown my-feature
11
+ uvx new-feature teardown my-feature --force
12
+ ```
13
+
14
+ ## Project Config
15
+
16
+ Add config to the target repo's `pyproject.toml`:
17
+
18
+ ```toml
19
+ [tool.new-feature]
20
+ target_branch = "main"
21
+ branch_prefix = "feature/"
22
+ agent = "codex"
23
+ push = false
24
+ setup = ["uv sync"]
25
+ pre_merge = ["uv run pytest"]
26
+ post_merge = ["uv run pytest"]
27
+ teardown = []
28
+
29
+ [tool.new-feature.env]
30
+ WEB_PORT = { allocate = "port", min = 3000, max = 3999 }
31
+ API_PORT = { allocate = "port", min = 4000, max = 4999 }
32
+ DATABASE_NAME = { allocate = "name", prefix = "myapp", max_length = 63 }
33
+ CACHE_DIR = { allocate = "path", base = ".new-feature/cache" }
34
+ ```
35
+
36
+ Supported env entries:
37
+
38
+ - `{ value = "literal" }`
39
+ - `{ allocate = "port", min = 3000, max = 3999 }`
40
+ - `{ allocate = "integer", min = 1, max = 15 }`
41
+ - `{ allocate = "name", prefix = "myapp", max_length = 63 }`
42
+ - `{ allocate = "slug", prefix = "myapp" }`
43
+ - `{ allocate = "path", base = ".new-feature/cache" }`
44
+
45
+ ## Lifecycle
46
+
47
+ `new-feature my-feature` creates `.worktrees/my-feature`, reserves env values in `.new-feature/manifest.toml`, runs setup, and launches interactive Codex in the worktree. It automatically adds `.new-feature/` and `.worktrees/` to `.gitignore`.
48
+
49
+ `new-feature merge-feature my-feature` runs pre-merge checks in the feature worktree, starts a no-commit merge into the target branch, runs post-merge checks on the merged target checkout, commits the merge only if those checks pass, and pushes only when `push = true`.
50
+
51
+ `new-feature teardown my-feature` runs teardown commands, removes the worktree, deletes the branch, and removes the manifest entry. If the feature has not gone through `merge-feature`, pass `--force` to abandon it deliberately.
52
+
53
+ ## Generated State
54
+
55
+ The generated manifest is a single ignored TOML file in the main checkout:
56
+
57
+ ```text
58
+ .new-feature/manifest.toml
59
+ .new-feature/manifest.lock
60
+ ```
61
+
62
+ The manifest stores all active feature env allocations, so ports, names, paths, and namespaces can be reserved without scanning generated worktrees.
63
+
64
+ ## Publishing
65
+
66
+ See [docs/PUBLISHING.md](docs/PUBLISHING.md) for PyPI release setup and verification.
@@ -0,0 +1,242 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "new-feature"
7
+ version = "0.1.0"
8
+ description = "Create isolated feature worktrees with allocated runtime environments."
9
+ readme = "README.md"
10
+ requires-python = ">=3.13"
11
+ authors = [
12
+ { name = "Ricardo Decal", email = "public@ricardodecal.com" },
13
+ ]
14
+ keywords = ["codex", "git", "worktree", "automation", "cli"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: POSIX :: Linux",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Software Development :: Version Control :: Git",
23
+ ]
24
+ dependencies = [
25
+ "beartype>=0.22.9",
26
+ "filelock>=3.29.7",
27
+ "tomli-w>=1.2.0",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/crypdick/new-feature"
32
+ Issues = "https://github.com/crypdick/new-feature/issues"
33
+ Source = "https://github.com/crypdick/new-feature"
34
+
35
+ [project.scripts]
36
+ new-feature = "new_feature.app:main"
37
+
38
+ [tool.hatch.build.targets.sdist]
39
+ include = [
40
+ "/pyproject.toml",
41
+ "/README.md",
42
+ "/src/new_feature",
43
+ ]
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["src/new_feature"]
47
+
48
+ [dependency-groups]
49
+ dev = [
50
+ "coverage>=7.15.0",
51
+ "detect-secrets>=1.5.0",
52
+ "flynt>=1.0.6",
53
+ "mypy>=2.2.0",
54
+ "pre-commit>=4.6.0",
55
+ "pytest>=9.1.1",
56
+ "pytest-asyncio>=1.4.0",
57
+ "pytest-cov>=7.1.0",
58
+ "pytest-timeout>=2.4.0",
59
+ "pytest-xdist>=3.8.0",
60
+ "pyupgrade>=3.21.2",
61
+ "ruff>=0.15.21",
62
+ "vulture>=2.16",
63
+ ]
64
+
65
+ [tool.ruff]
66
+ line-length = 110
67
+ preview = true
68
+
69
+ [tool.ruff.lint]
70
+ select = [
71
+ "A",
72
+ "ANN001",
73
+ "ANN002",
74
+ "ANN003",
75
+ "ANN201",
76
+ "ANN202",
77
+ "ANN204",
78
+ "ANN205",
79
+ "ANN206",
80
+ "ANN401",
81
+ "ARG",
82
+ "ASYNC",
83
+ "B",
84
+ "BLE",
85
+ "C4",
86
+ "COM818",
87
+ "C90",
88
+ "D402",
89
+ "D414",
90
+ "D418",
91
+ "D419",
92
+ "DOC102",
93
+ "DOC202",
94
+ "DOC403",
95
+ "DTZ",
96
+ "E",
97
+ "ERA",
98
+ "EXE",
99
+ "F",
100
+ "FA",
101
+ "FBT",
102
+ "FURB101",
103
+ "FURB103",
104
+ "FURB122",
105
+ "FURB129",
106
+ "FURB132",
107
+ "FURB157",
108
+ "FURB161",
109
+ "FURB162",
110
+ "FURB168",
111
+ "FURB169",
112
+ "FURB171",
113
+ "FURB177",
114
+ "FURB181",
115
+ "FURB188",
116
+ "G",
117
+ "I",
118
+ "INP",
119
+ "ISC004",
120
+ "LOG",
121
+ "N",
122
+ "PERF",
123
+ "PGH",
124
+ "PIE",
125
+ "PLC",
126
+ "PLE",
127
+ "PLR0124",
128
+ "PLR0133",
129
+ "PLR0206",
130
+ "PLR0911",
131
+ "PLR0912",
132
+ "PLR0913",
133
+ "PLR0915",
134
+ "PLR1702",
135
+ "PLR1704",
136
+ "PLR1711",
137
+ "PLR1714",
138
+ "PLR1716",
139
+ "PLR1722",
140
+ "PLR1733",
141
+ "PLR1736",
142
+ "PLR5501",
143
+ "PLW",
144
+ "PT",
145
+ "PTH",
146
+ "RET",
147
+ "RSE",
148
+ "RUF",
149
+ "S",
150
+ "SIM",
151
+ "SLF",
152
+ "SLOT",
153
+ "T10",
154
+ "T20",
155
+ "TC",
156
+ "TID",
157
+ "TRY004",
158
+ "TRY201",
159
+ "TRY203",
160
+ "TRY300",
161
+ "TRY400",
162
+ "TRY401",
163
+ "UP",
164
+ "W",
165
+ "YTT",
166
+ ]
167
+ ignore = [
168
+ "E501",
169
+ "TRY003",
170
+ ]
171
+
172
+ [tool.ruff.lint.mccabe]
173
+ max-complexity = 10
174
+
175
+ [tool.ruff.lint.per-file-ignores]
176
+ "src/new_feature/__init__.py" = ["RUF067"]
177
+ "tests/**/*.py" = ["ALL"]
178
+ "scripts/**/*.py" = ["C901", "PLR0912", "PLR0915", "S603", "S607", "T20"]
179
+ "src/new_feature/agent.py" = ["S404", "S603", "TC003"]
180
+ "src/new_feature/allocator.py" = ["PLR0913", "TC001"]
181
+ "src/new_feature/cli.py" = ["T201"]
182
+ "src/new_feature/commands.py" = ["S404", "S602", "S603", "T201", "TC003"]
183
+ "src/new_feature/config.py" = ["TC003"]
184
+ "src/new_feature/git.py" = ["S404", "S603", "S607"]
185
+ "src/new_feature/gitignore.py" = ["TC003"]
186
+ "src/new_feature/manifest.py" = ["TC003"]
187
+
188
+ [tool.ruff.format]
189
+ quote-style = "double"
190
+
191
+ [tool.mypy]
192
+ strict = true
193
+ warn_return_any = true
194
+ warn_unused_configs = true
195
+ show_error_codes = true
196
+ pretty = true
197
+ disable_error_code = ["no-untyped-call", "no-untyped-def"]
198
+
199
+ [[tool.mypy.overrides]]
200
+ module = ["tests.*"]
201
+ disallow_untyped_defs = false
202
+ disallow_untyped_calls = false
203
+ check_untyped_defs = false
204
+ ignore_errors = true
205
+
206
+ [tool.pytest.ini_options]
207
+ asyncio_mode = "auto"
208
+ testpaths = ["tests"]
209
+ pythonpath = ["src"]
210
+ addopts = "--no-header -n auto -q --durations=5 --durations-min=1.0 --cov=new_feature --cov-report=term-missing --cov-report=html --cov-fail-under=100 --failed-first"
211
+ timeout = 20
212
+ timeout_method = "thread"
213
+
214
+ [tool.coverage.run]
215
+ branch = true
216
+ source = ["new_feature"]
217
+ omit = [
218
+ "*/tests/*",
219
+ "*/test_*.py",
220
+ "*/__pycache__/*",
221
+ "*/conftest.py",
222
+ ]
223
+
224
+ [tool.coverage.report]
225
+ fail_under = 100
226
+ show_missing = true
227
+ skip_empty = true
228
+ exclude_lines = [
229
+ "pragma: no cover",
230
+ "def __repr__",
231
+ "raise AssertionError",
232
+ "raise NotImplementedError",
233
+ "if __name__ == .__main__.:",
234
+ "if TYPE_CHECKING:",
235
+ "@abstractmethod",
236
+ "@abc.abstractmethod",
237
+ ]
238
+
239
+ [tool.vulture]
240
+ min_confidence = 80
241
+ exclude = [".venv/"]
242
+ paths = ["src", "tests"]
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ import warnings
4
+
5
+ from beartype import BeartypeConf
6
+ from beartype.claw import beartype_this_package
7
+ from beartype.roar import BeartypeClawDecorWarning
8
+
9
+ warnings.filterwarnings("ignore", category=BeartypeClawDecorWarning)
10
+
11
+ beartype_this_package(
12
+ conf=BeartypeConf(
13
+ claw_is_pep526=False,
14
+ warning_cls_on_decorator_exception=BeartypeClawDecorWarning,
15
+ ),
16
+ )
@@ -0,0 +1,3 @@
1
+ from new_feature.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+
8
+ def build_initial_prompt(name: str) -> str:
9
+ return (
10
+ f"Interview Ricardo to turn `{name}` into a concise PRD for this repository. "
11
+ "Inspect the local repo context first. If the feature name is descriptive enough "
12
+ "and the repo context makes the likely implementation clear, summarize your "
13
+ "inferred plan briefly and ask whether to get right to work."
14
+ )
15
+
16
+
17
+ def launch_interactive_agent(agent: str, worktree: Path, env: dict[str, str], prompt: str) -> int:
18
+ return subprocess.call([agent, prompt], cwd=worktree, env={**os.environ, **env})
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import re
5
+ import socket
6
+ from pathlib import Path
7
+
8
+ from new_feature.config import EnvSpec, ProjectConfig
9
+ from new_feature.errors import NewFeatureError
10
+ from new_feature.manifest import Manifest
11
+
12
+
13
+ def allocate_env(
14
+ *,
15
+ config: ProjectConfig,
16
+ manifest: Manifest,
17
+ name: str,
18
+ slug: str,
19
+ branch: str,
20
+ worktree: Path,
21
+ repo_root: Path,
22
+ ) -> dict[str, str]:
23
+ env = {
24
+ "NEW_FEATURE_NAME": name,
25
+ "NEW_FEATURE_SLUG": slug,
26
+ "NEW_FEATURE_BRANCH": branch,
27
+ "NEW_FEATURE_WORKTREE": str(worktree),
28
+ "NEW_FEATURE_REPO_ROOT": str(repo_root),
29
+ }
30
+ for key, spec in config.env.items():
31
+ env[key] = _allocate_value(key, spec, manifest, slug, repo_root)
32
+ return env
33
+
34
+
35
+ def _allocate_value(key: str, spec: EnvSpec, manifest: Manifest, slug: str, repo_root: Path) -> str:
36
+ if spec.value is not None:
37
+ return spec.value
38
+ reserved = {record.env[key] for record in manifest.features.values() if key in record.env}
39
+ match spec.allocate:
40
+ case "port":
41
+ return _allocate_port(key, spec, reserved)
42
+ case "integer":
43
+ return _allocate_integer(key, spec, reserved)
44
+ case "name":
45
+ return _allocate_name(key, spec, slug, repo_root)
46
+ case "slug":
47
+ prefix = _safe_token(spec.prefix or key.lower(), separator="-")
48
+ return f"{prefix}-{slug}" if prefix else slug
49
+ case "path":
50
+ base = spec.base or ".new-feature"
51
+ return str(Path(base) / slug)
52
+ case None:
53
+ raise NewFeatureError(f"env spec {key} must set value or allocate")
54
+ case other:
55
+ raise NewFeatureError(f"unsupported allocator for {key}: {other}")
56
+
57
+
58
+ def _allocate_port(key: str, spec: EnvSpec, reserved: set[str]) -> str:
59
+ minimum = spec.minimum if spec.minimum is not None else 1024
60
+ maximum = spec.maximum if spec.maximum is not None else 65535
61
+ for port in range(minimum, maximum + 1):
62
+ if str(port) in reserved:
63
+ continue
64
+ if _port_available(port):
65
+ return str(port)
66
+ raise NewFeatureError(f"no available port for {key} in range {minimum}-{maximum}")
67
+
68
+
69
+ def _allocate_integer(key: str, spec: EnvSpec, reserved: set[str]) -> str:
70
+ minimum = spec.minimum if spec.minimum is not None else 0
71
+ maximum = spec.maximum if spec.maximum is not None else 65535
72
+ for value in range(minimum, maximum + 1):
73
+ if str(value) not in reserved:
74
+ return str(value)
75
+ raise NewFeatureError(f"no available integer for {key} in range {minimum}-{maximum}")
76
+
77
+
78
+ def _allocate_name(key: str, spec: EnvSpec, slug: str, repo_root: Path) -> str:
79
+ prefix = _safe_token(spec.prefix or key.lower(), separator="_")
80
+ body = _safe_token(slug, separator="_")
81
+ digest = hashlib.sha256(f"{repo_root}:{slug}:{key}".encode()).hexdigest()[:8]
82
+ value = f"{prefix}_{body}_{digest}" if prefix else f"{body}_{digest}"
83
+ if spec.max_length and len(value) > spec.max_length:
84
+ suffix = f"_{digest}"
85
+ value = value[: spec.max_length - len(suffix)].rstrip("_") + suffix
86
+ return value
87
+
88
+
89
+ def _safe_token(value: str, *, separator: str) -> str:
90
+ token = re.sub(r"[^a-zA-Z0-9]+", separator, value).strip(separator).lower()
91
+ return re.sub(rf"{re.escape(separator)}+", separator, token)
92
+
93
+
94
+ def _port_available(port: int) -> bool:
95
+ with socket.socket() as sock:
96
+ try:
97
+ sock.bind(("127.0.0.1", port))
98
+ except OSError:
99
+ return False
100
+ return True
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+
5
+ from new_feature.cli import main as cli_main
6
+
7
+
8
+ def main(argv: list[str] | None = None) -> int:
9
+ return cli_main(sys.argv[1:] if argv is None else argv)
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+
8
+ from new_feature.agent import build_initial_prompt, launch_interactive_agent
9
+ from new_feature.allocator import allocate_env
10
+ from new_feature.commands import run_commands
11
+ from new_feature.config import load_project_config
12
+ from new_feature.errors import NewFeatureError
13
+ from new_feature.git import (
14
+ abort_merge,
15
+ begin_merge_without_commit,
16
+ commit_merge,
17
+ create_worktree,
18
+ push_target,
19
+ remove_worktree_and_branch,
20
+ repo_root,
21
+ worktree_is_clean,
22
+ )
23
+ from new_feature.gitignore import ensure_generated_paths_ignored
24
+ from new_feature.manifest import FeatureRecord, load_manifest, manifest_lock, save_manifest
25
+ from new_feature.slug import feature_key, slugify
26
+
27
+
28
+ def build_parser() -> argparse.ArgumentParser:
29
+ parser = argparse.ArgumentParser(
30
+ prog="new-feature",
31
+ description="Create isolated feature worktrees and launch an interactive agent.",
32
+ )
33
+ subparsers = parser.add_subparsers(dest="command")
34
+
35
+ create = subparsers.add_parser("create", help="create a feature worktree")
36
+ create.add_argument("name")
37
+ create.add_argument("--no-launch", action="store_true")
38
+ create.add_argument("--dry-run", action="store_true")
39
+ create.set_defaults(command="create")
40
+
41
+ merge = subparsers.add_parser("merge-feature")
42
+ merge.add_argument("name")
43
+ merge.set_defaults(command="merge-feature")
44
+
45
+ teardown = subparsers.add_parser("teardown")
46
+ teardown.add_argument("name")
47
+ teardown.add_argument("--force", action="store_true")
48
+ teardown.set_defaults(command="teardown")
49
+ return parser
50
+
51
+
52
+ def parse_args(argv: list[str]) -> argparse.Namespace:
53
+ if argv and argv[0] not in {"create", "merge-feature", "teardown"} and not argv[0].startswith("-"):
54
+ argv = ["create", *argv]
55
+ parser = build_parser()
56
+ args = parser.parse_args(argv)
57
+ if args.command is None:
58
+ parser.error("feature name or subcommand is required")
59
+ return args
60
+
61
+
62
+ def main(argv: list[str] | None = None) -> int:
63
+ args = parse_args(sys.argv[1:] if argv is None else argv)
64
+ try:
65
+ root = repo_root(Path.cwd())
66
+ if args.command == "create":
67
+ return _create(root, args.name, no_launch=args.no_launch, dry_run=args.dry_run)
68
+ if args.command == "merge-feature":
69
+ return _merge_feature(root, args.name)
70
+ if args.command == "teardown":
71
+ return _teardown(root, args.name, force=args.force)
72
+ raise NewFeatureError(f"unknown command: {args.command}")
73
+ except NewFeatureError as exc:
74
+ print(f"new-feature: {exc}", file=sys.stderr)
75
+ return 1
76
+
77
+
78
+ def _create(root: Path, name: str, *, no_launch: bool, dry_run: bool) -> int:
79
+ config = load_project_config(root)
80
+ slug = slugify(name)
81
+ key = feature_key(slug)
82
+ branch = f"{config.branch_prefix}{slug}"
83
+ worktree = root / ".worktrees" / slug
84
+
85
+ ensure_generated_paths_ignored(root)
86
+ with manifest_lock(root):
87
+ manifest = load_manifest(root)
88
+ if key in manifest.features:
89
+ raise NewFeatureError(f"feature already exists: {slug}")
90
+ env = allocate_env(
91
+ config=config,
92
+ manifest=manifest,
93
+ name=name,
94
+ slug=slug,
95
+ branch=branch,
96
+ worktree=worktree,
97
+ repo_root=root,
98
+ )
99
+ if dry_run:
100
+ for env_key, env_value in sorted(env.items()):
101
+ print(f"{env_key}={env_value}")
102
+ return 0
103
+ create_worktree(root, branch=branch, worktree=worktree, target_branch=config.target_branch)
104
+ manifest.features[key] = FeatureRecord(
105
+ name=name,
106
+ slug=slug,
107
+ branch=branch,
108
+ worktree=str(worktree.relative_to(root)),
109
+ target_branch=config.target_branch,
110
+ status="active",
111
+ created_at=_now(),
112
+ env=env,
113
+ )
114
+ save_manifest(root, manifest)
115
+
116
+ run_commands(config.setup, cwd=worktree, env=env)
117
+ if no_launch:
118
+ return 0
119
+ prompt = build_initial_prompt(slug)
120
+ return launch_interactive_agent(config.agent, worktree, env, prompt)
121
+
122
+
123
+ def _merge_feature(root: Path, name: str) -> int:
124
+ config = load_project_config(root)
125
+ key = feature_key(slugify(name))
126
+ with manifest_lock(root):
127
+ manifest = load_manifest(root)
128
+ record = manifest.features.get(key)
129
+ if record is None:
130
+ raise NewFeatureError(f"unknown feature: {name}")
131
+ worktree = root / record.worktree
132
+ run_commands(config.pre_merge, cwd=worktree, env=record.env)
133
+ if not worktree_is_clean(worktree):
134
+ raise NewFeatureError("feature worktree has uncommitted changes; commit them before merging")
135
+ if not worktree_is_clean(root):
136
+ raise NewFeatureError("target checkout has uncommitted changes; commit or stash them before merging")
137
+ begin_merge_without_commit(root, branch=record.branch, target_branch=record.target_branch)
138
+ try:
139
+ run_commands(config.post_merge, cwd=root, env=record.env)
140
+ except NewFeatureError:
141
+ abort_merge(root)
142
+ raise
143
+ commit_merge(root, name=record.name)
144
+ if config.push:
145
+ push_target(root, target_branch=record.target_branch)
146
+ with manifest_lock(root):
147
+ manifest = load_manifest(root)
148
+ record = manifest.features.get(key)
149
+ if record is None:
150
+ raise NewFeatureError(f"unknown feature after merge: {name}")
151
+ record.status = "merged"
152
+ record.merged_at = _now()
153
+ save_manifest(root, manifest)
154
+ return 0
155
+
156
+
157
+ def _teardown(root: Path, name: str, *, force: bool) -> int:
158
+ config = load_project_config(root)
159
+ key = feature_key(slugify(name))
160
+ with manifest_lock(root):
161
+ manifest = load_manifest(root)
162
+ record = manifest.features.get(key)
163
+ if record is None:
164
+ raise NewFeatureError(f"unknown feature: {name}")
165
+ merged = record.status == "merged"
166
+ if not merged and not force:
167
+ raise NewFeatureError("feature is not merged; pass --force to abandon it")
168
+ worktree = root / record.worktree
169
+ run_commands(config.teardown, cwd=worktree, env=record.env)
170
+ remove_worktree_and_branch(root, branch=record.branch, worktree=worktree, force=force or not merged)
171
+ with manifest_lock(root):
172
+ manifest = load_manifest(root)
173
+ del manifest.features[key]
174
+ save_manifest(root, manifest)
175
+ return 0
176
+
177
+
178
+ def _now() -> str:
179
+ return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from new_feature.errors import NewFeatureError
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def run_commands(commands: list[str], *, cwd: Path, env: dict[str, str]) -> None:
14
+ process_env = {**os.environ, **env}
15
+ for command in commands:
16
+ logger.info("running configured command", extra={"command": command})
17
+ result = subprocess.run(command, shell=True, cwd=cwd, env=process_env, check=False)
18
+ if result.returncode != 0:
19
+ raise NewFeatureError(f"command failed with exit code {result.returncode}: {command}")
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from new_feature.errors import NewFeatureError
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class EnvSpec:
13
+ allocate: str | None = None
14
+ value: str | None = None
15
+ minimum: int | None = None
16
+ maximum: int | None = None
17
+ prefix: str | None = None
18
+ max_length: int | None = None
19
+ base: str | None = None
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class ProjectConfig:
24
+ target_branch: str = "main"
25
+ branch_prefix: str = "feature/"
26
+ agent: str = "codex"
27
+ push: bool = False
28
+ setup: list[str] = field(default_factory=list)
29
+ pre_merge: list[str] = field(default_factory=list)
30
+ post_merge: list[str] = field(default_factory=list)
31
+ teardown: list[str] = field(default_factory=list)
32
+ env: dict[str, EnvSpec] = field(default_factory=dict)
33
+
34
+
35
+ def _string_list(raw: dict[str, Any], name: str) -> list[str]:
36
+ value = raw.get(name, [])
37
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
38
+ raise NewFeatureError(f"tool.new-feature.{name} must be a list of strings")
39
+ return list(value)
40
+
41
+
42
+ def load_project_config(repo_root: Path) -> ProjectConfig:
43
+ pyproject = repo_root / "pyproject.toml"
44
+ if not pyproject.exists():
45
+ return ProjectConfig()
46
+ data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
47
+ raw = data.get("tool", {}).get("new-feature", {})
48
+ if not isinstance(raw, dict):
49
+ raise NewFeatureError("[tool.new-feature] must be a TOML table")
50
+
51
+ env_raw = raw.get("env", {})
52
+ if not isinstance(env_raw, dict):
53
+ raise NewFeatureError("[tool.new-feature.env] must be a TOML table")
54
+
55
+ env: dict[str, EnvSpec] = {}
56
+ for key, value in env_raw.items():
57
+ if not isinstance(value, dict):
58
+ raise NewFeatureError(f"env spec {key} must be a TOML inline table")
59
+ env[key] = EnvSpec(
60
+ allocate=value.get("allocate"),
61
+ value=value.get("value"),
62
+ minimum=value.get("min"),
63
+ maximum=value.get("max"),
64
+ prefix=value.get("prefix"),
65
+ max_length=value.get("max_length"),
66
+ base=value.get("base"),
67
+ )
68
+
69
+ return ProjectConfig(
70
+ target_branch=str(raw.get("target_branch", "main")),
71
+ branch_prefix=str(raw.get("branch_prefix", "feature/")),
72
+ agent=str(raw.get("agent", "codex")),
73
+ push=bool(raw.get("push", False)),
74
+ setup=_string_list(raw, "setup"),
75
+ pre_merge=_string_list(raw, "pre_merge"),
76
+ post_merge=_string_list(raw, "post_merge"),
77
+ teardown=_string_list(raw, "teardown"),
78
+ env=env,
79
+ )
@@ -0,0 +1,2 @@
1
+ class NewFeatureError(RuntimeError):
2
+ """User-facing error raised by the new-feature CLI."""
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from new_feature.errors import NewFeatureError
8
+
9
+
10
+ def repo_root(cwd: Path) -> Path:
11
+ result = _git(cwd, "rev-parse", "--show-toplevel", capture=True)
12
+ return Path(result.stdout.strip())
13
+
14
+
15
+ def ensure_repo_has_commits(root: Path) -> None:
16
+ result = subprocess.run(
17
+ ["git", "rev-parse", "--verify", "HEAD"], cwd=root, capture_output=True, text=True, check=False
18
+ )
19
+ if result.returncode != 0:
20
+ raise NewFeatureError("repository has no commits; make an initial commit before creating worktrees")
21
+
22
+
23
+ def create_worktree(root: Path, *, branch: str, worktree: Path, target_branch: str) -> None:
24
+ ensure_repo_has_commits(root)
25
+ worktree.parent.mkdir(parents=True, exist_ok=True)
26
+ _git(root, "worktree", "add", "-b", branch, str(worktree), target_branch)
27
+
28
+
29
+ def worktree_is_clean(worktree: Path) -> bool:
30
+ result = _git(worktree, "status", "--porcelain", capture=True)
31
+ return not result.stdout.strip()
32
+
33
+
34
+ def is_branch_merged(root: Path, *, branch: str, target_branch: str) -> bool:
35
+ result = subprocess.run(
36
+ ["git", "merge-base", "--is-ancestor", branch, target_branch], cwd=root, check=False
37
+ )
38
+ return result.returncode == 0
39
+
40
+
41
+ def begin_merge_without_commit(root: Path, *, branch: str, target_branch: str) -> None:
42
+ _git(root, "checkout", target_branch)
43
+ _git(root, "merge", "--no-commit", "--no-ff", branch)
44
+
45
+
46
+ def commit_merge(root: Path, *, name: str) -> None:
47
+ _git(root, "commit", "-m", f"Merge feature {name}")
48
+
49
+
50
+ def abort_merge(root: Path) -> None:
51
+ subprocess.run(["git", "merge", "--abort"], cwd=root, check=False, env=_git_env())
52
+
53
+
54
+ def push_target(root: Path, *, target_branch: str) -> None:
55
+ _git(root, "push", "origin", target_branch)
56
+
57
+
58
+ def remove_worktree_and_branch(root: Path, *, branch: str, worktree: Path, force: bool) -> None:
59
+ args = ["worktree", "remove"]
60
+ if force:
61
+ args.append("--force")
62
+ args.append(str(worktree))
63
+ _git(root, *args)
64
+ _git(root, "branch", "-D" if force else "-d", branch)
65
+
66
+
67
+ def _git(cwd: Path, *args: str, capture: bool = False) -> subprocess.CompletedProcess[str]:
68
+ try:
69
+ result = subprocess.run(
70
+ ["git", *args], cwd=cwd, text=True, capture_output=capture, check=False, env=_git_env()
71
+ )
72
+ except OSError as exc:
73
+ raise NewFeatureError(f"git command failed: {exc}") from exc
74
+ if result.returncode != 0:
75
+ detail = result.stderr.strip() if capture and result.stderr else " ".join(args)
76
+ raise NewFeatureError(f"git command failed: {detail}")
77
+ return result
78
+
79
+
80
+ def _git_env() -> dict[str, str]:
81
+ env = os.environ.copy()
82
+ for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"):
83
+ env.pop(key, None)
84
+ return env
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ GENERATED_PATTERNS = [".new-feature/", ".worktrees/"]
6
+
7
+
8
+ def ensure_generated_paths_ignored(repo_root: Path) -> None:
9
+ gitignore = repo_root / ".gitignore"
10
+ existing = gitignore.read_text(encoding="utf-8").splitlines() if gitignore.exists() else []
11
+ changed = False
12
+ for pattern in GENERATED_PATTERNS:
13
+ if pattern not in existing:
14
+ existing.append(pattern)
15
+ changed = True
16
+ if changed or not gitignore.exists():
17
+ gitignore.write_text("\n".join(existing) + "\n", encoding="utf-8")
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from collections.abc import Iterator
5
+ from contextlib import contextmanager
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ import tomli_w
10
+ from filelock import FileLock
11
+
12
+ MANIFEST_DIR = ".new-feature"
13
+ MANIFEST_FILE = "manifest.toml"
14
+ LOCK_FILE = "manifest.lock"
15
+
16
+
17
+ @dataclass
18
+ class FeatureRecord:
19
+ name: str
20
+ slug: str
21
+ branch: str
22
+ worktree: str
23
+ target_branch: str
24
+ status: str
25
+ created_at: str
26
+ merged_at: str = ""
27
+ env: dict[str, str] = field(default_factory=dict)
28
+
29
+
30
+ @dataclass
31
+ class Manifest:
32
+ version: int = 1
33
+ features: dict[str, FeatureRecord] = field(default_factory=dict)
34
+
35
+
36
+ def manifest_path(repo_root: Path) -> Path:
37
+ return repo_root / MANIFEST_DIR / MANIFEST_FILE
38
+
39
+
40
+ @contextmanager
41
+ def manifest_lock(repo_root: Path) -> Iterator[None]:
42
+ lock_path = repo_root / MANIFEST_DIR / LOCK_FILE
43
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
44
+ with FileLock(str(lock_path)):
45
+ yield
46
+
47
+
48
+ def load_manifest(repo_root: Path) -> Manifest:
49
+ path = manifest_path(repo_root)
50
+ if not path.exists():
51
+ return Manifest()
52
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
53
+ features: dict[str, FeatureRecord] = {}
54
+ for key, raw in data.get("features", {}).items():
55
+ features[key] = FeatureRecord(
56
+ name=str(raw["name"]),
57
+ slug=str(raw["slug"]),
58
+ branch=str(raw["branch"]),
59
+ worktree=str(raw["worktree"]),
60
+ target_branch=str(raw["target_branch"]),
61
+ status=str(raw["status"]),
62
+ created_at=str(raw.get("created_at", "")),
63
+ merged_at=str(raw.get("merged_at", "")),
64
+ env={str(env_key): str(env_value) for env_key, env_value in raw.get("env", {}).items()},
65
+ )
66
+ return Manifest(version=int(data.get("version", 1)), features=features)
67
+
68
+
69
+ def save_manifest(repo_root: Path, manifest: Manifest) -> None:
70
+ path = manifest_path(repo_root)
71
+ path.parent.mkdir(parents=True, exist_ok=True)
72
+ data = {
73
+ "version": manifest.version,
74
+ "features": {
75
+ key: {
76
+ "name": record.name,
77
+ "slug": record.slug,
78
+ "branch": record.branch,
79
+ "worktree": record.worktree,
80
+ "target_branch": record.target_branch,
81
+ "status": record.status,
82
+ "created_at": record.created_at,
83
+ "merged_at": record.merged_at,
84
+ "env": record.env,
85
+ }
86
+ for key, record in sorted(manifest.features.items())
87
+ },
88
+ }
89
+ path.write_text(tomli_w.dumps(data), encoding="utf-8")
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ from new_feature.errors import NewFeatureError
6
+
7
+
8
+ def slugify(name: str) -> str:
9
+ raw = name.strip().lower()
10
+ if not raw:
11
+ raise NewFeatureError("feature name cannot be empty")
12
+ slug = re.sub(r"[^a-z0-9]+", "-", raw).strip("-")
13
+ slug = re.sub(r"-{2,}", "-", slug)
14
+ if not slug:
15
+ raise NewFeatureError("feature name must contain letters or numbers")
16
+ return slug
17
+
18
+
19
+ def feature_key(slug: str) -> str:
20
+ return slug.replace("-", "_")