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.
- new_feature-0.1.0/.gitignore +17 -0
- new_feature-0.1.0/PKG-INFO +88 -0
- new_feature-0.1.0/README.md +66 -0
- new_feature-0.1.0/pyproject.toml +242 -0
- new_feature-0.1.0/src/new_feature/__init__.py +16 -0
- new_feature-0.1.0/src/new_feature/__main__.py +3 -0
- new_feature-0.1.0/src/new_feature/agent.py +18 -0
- new_feature-0.1.0/src/new_feature/allocator.py +100 -0
- new_feature-0.1.0/src/new_feature/app.py +9 -0
- new_feature-0.1.0/src/new_feature/cli.py +179 -0
- new_feature-0.1.0/src/new_feature/commands.py +19 -0
- new_feature-0.1.0/src/new_feature/config.py +79 -0
- new_feature-0.1.0/src/new_feature/errors.py +2 -0
- new_feature-0.1.0/src/new_feature/git.py +84 -0
- new_feature-0.1.0/src/new_feature/gitignore.py +17 -0
- new_feature-0.1.0/src/new_feature/manifest.py +89 -0
- new_feature-0.1.0/src/new_feature/slug.py +20 -0
|
@@ -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,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,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,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("-", "_")
|