duaer-spec 0.1.0

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.
Files changed (75) hide show
  1. package/.cursor/rules/agents-workflow.mdc +50 -0
  2. package/.cursor/rules/ai-ui-copy.mdc +12 -0
  3. package/.cursor/rules/duaer-spec.mdc +33 -0
  4. package/.cursor/skills/duaer-analyze/SKILL.md +259 -0
  5. package/.cursor/skills/duaer-checklist/SKILL.md +383 -0
  6. package/.cursor/skills/duaer-clarify/SKILL.md +291 -0
  7. package/.cursor/skills/duaer-constitution/SKILL.md +177 -0
  8. package/.cursor/skills/duaer-converge/SKILL.md +277 -0
  9. package/.cursor/skills/duaer-git-commit/SKILL.md +68 -0
  10. package/.cursor/skills/duaer-git-feature/SKILL.md +94 -0
  11. package/.cursor/skills/duaer-git-initialize/SKILL.md +54 -0
  12. package/.cursor/skills/duaer-git-remote/SKILL.md +50 -0
  13. package/.cursor/skills/duaer-git-validate/SKILL.md +54 -0
  14. package/.cursor/skills/duaer-implement/SKILL.md +226 -0
  15. package/.cursor/skills/duaer-plan/SKILL.md +166 -0
  16. package/.cursor/skills/duaer-specify/SKILL.md +345 -0
  17. package/.cursor/skills/duaer-tasks/SKILL.md +214 -0
  18. package/.cursor/skills/duaer-taskstoissues/SKILL.md +109 -0
  19. package/.duaer/extensions/.registry +23 -0
  20. package/.duaer/extensions/git/README.md +119 -0
  21. package/.duaer/extensions/git/commands/duaer.git.commit.md +63 -0
  22. package/.duaer/extensions/git/commands/duaer.git.feature.md +82 -0
  23. package/.duaer/extensions/git/commands/duaer.git.initialize.md +49 -0
  24. package/.duaer/extensions/git/commands/duaer.git.remote.md +45 -0
  25. package/.duaer/extensions/git/commands/duaer.git.validate.md +49 -0
  26. package/.duaer/extensions/git/config-template.yml +79 -0
  27. package/.duaer/extensions/git/extension.yml +142 -0
  28. package/.duaer/extensions/git/git-config.yml +79 -0
  29. package/.duaer/extensions/git/scripts/bash/auto-commit.sh +211 -0
  30. package/.duaer/extensions/git/scripts/bash/create-new-feature-branch.sh +626 -0
  31. package/.duaer/extensions/git/scripts/bash/git-common.sh +56 -0
  32. package/.duaer/extensions/git/scripts/bash/initialize-repo.sh +54 -0
  33. package/.duaer/extensions/git/scripts/powershell/auto-commit.ps1 +230 -0
  34. package/.duaer/extensions/git/scripts/powershell/create-new-feature-branch.ps1 +592 -0
  35. package/.duaer/extensions/git/scripts/powershell/git-common.ps1 +52 -0
  36. package/.duaer/extensions/git/scripts/powershell/initialize-repo.ps1 +69 -0
  37. package/.duaer/extensions/git/scripts/python/auto_commit.py +195 -0
  38. package/.duaer/extensions/git/scripts/python/create_new_feature_branch.py +634 -0
  39. package/.duaer/extensions/git/scripts/python/git_common.py +81 -0
  40. package/.duaer/extensions/git/scripts/python/initialize_repo.py +89 -0
  41. package/.duaer/extensions.yml +167 -0
  42. package/.duaer/init-options.json +9 -0
  43. package/.duaer/integration.json +15 -0
  44. package/.duaer/integrations/cursor-agent.manifest.json +17 -0
  45. package/.duaer/integrations/duaer.manifest.json +19 -0
  46. package/.duaer/memory/.constitution-template.json +4 -0
  47. package/.duaer/memory/constitution.md +37 -0
  48. package/.duaer/memory/project-context.md +24 -0
  49. package/.duaer/memory/testing.md +14 -0
  50. package/.duaer/scripts/bash/check-prerequisites.sh +243 -0
  51. package/.duaer/scripts/bash/common.sh +926 -0
  52. package/.duaer/scripts/bash/create-new-feature.sh +407 -0
  53. package/.duaer/scripts/bash/resolve-template.sh +57 -0
  54. package/.duaer/scripts/bash/setup-plan.sh +85 -0
  55. package/.duaer/scripts/bash/setup-tasks.sh +94 -0
  56. package/.duaer/templates/checklist-template.md +45 -0
  57. package/.duaer/templates/constitution-template.md +50 -0
  58. package/.duaer/templates/plan-template.md +113 -0
  59. package/.duaer/templates/spec-template.md +131 -0
  60. package/.duaer/templates/tasks-template.md +252 -0
  61. package/.duaer/workflows/duaer/workflow.yml +78 -0
  62. package/.duaer/workflows/workflow-registry.json +13 -0
  63. package/ADOPT.md +75 -0
  64. package/AGENTS.md +290 -0
  65. package/CHANGELOG.md +28 -0
  66. package/DUADER.md +57 -0
  67. package/LICENSE +21 -0
  68. package/README.md +74 -0
  69. package/bin/duaer.mjs +298 -0
  70. package/docs/agent/README.md +12 -0
  71. package/docs/agent/change-checklist.md +130 -0
  72. package/docs/agent/e2e-test-plan.md +33 -0
  73. package/docs/agent/workflow.md +127 -0
  74. package/docs/baseline.md +10 -0
  75. package/package.json +49 -0
@@ -0,0 +1,634 @@
1
+ #!/usr/bin/env python3
2
+ """Git extension: create_new_feature_branch.py
3
+
4
+ Creates a git feature branch only. The feature directory and spec file are
5
+ created by the core create-new-feature script. Python port of
6
+ ``create-new-feature-branch.sh`` / ``create-new-feature-branch.ps1``.
7
+
8
+ Loads the core Python helpers from the project's installed scripts when
9
+ available, falling back to the minimal git helpers next to this script.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib.util
15
+ import json
16
+ import os
17
+ import re
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ from dataclasses import dataclass, field
22
+ from datetime import datetime
23
+ from pathlib import Path
24
+
25
+ SCRIPT_DIR = Path(__file__).resolve().parent
26
+ MAX_BRANCH_LENGTH = 244 # GitHub enforces a 244-byte limit on branch names
27
+
28
+ USAGE = (
29
+ "Usage: create_new_feature_branch.py [--json] [--dry-run] "
30
+ "[--allow-existing-branch] [--short-name <name>] [--number N] "
31
+ "[--timestamp] <feature_description>"
32
+ )
33
+
34
+ HELP_TEXT = f"""{USAGE}
35
+
36
+ Options:
37
+ --json Output in JSON format
38
+ --dry-run Compute branch name without creating the branch
39
+ --allow-existing-branch Switch to branch if it already exists instead of failing
40
+ --short-name <name> Provide a custom short name (2-4 words) for the branch
41
+ --number N Specify branch number manually (overrides auto-detection)
42
+ --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering
43
+ --help, -h Show this help message
44
+
45
+ Environment variables:
46
+ GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation
47
+
48
+ Configuration:
49
+ branch_template Optional git-config.yml template with {{author}}, {{app}}, {{number}}, {{slug}}
50
+ branch_prefix Optional shorthand namespace expanded before {{number}}-{{slug}}
51
+
52
+ Examples:
53
+ create_new_feature_branch.py 'Add user authentication system' --short-name 'user-auth'
54
+ create_new_feature_branch.py 'Implement OAuth2 integration for API' --number 5
55
+ create_new_feature_branch.py --timestamp --short-name 'user-auth' 'Add user authentication'
56
+ GIT_BRANCH_NAME=my-branch create_new_feature_branch.py 'feature description'
57
+ """
58
+
59
+ STOP_WORDS = frozenset(
60
+ "i a an the to for of in on at by with from is are was were be been being "
61
+ "have has had do does did will would should could can may might must shall "
62
+ "this that these those my your our their want need add get set".split()
63
+ )
64
+
65
+
66
+ def _err(message: str) -> None:
67
+ print(message, file=sys.stderr)
68
+
69
+
70
+ def _persist_hint(var_name: str, value: str) -> str:
71
+ """Shell-appropriate guidance for persisting an env var in the caller's shell."""
72
+ if os.name == "nt":
73
+ escaped_value = value.replace("'", "''")
74
+ return f"$env:{var_name} = '{escaped_value}'"
75
+ escaped_value = re.sub(r"([^\w@%+=:,./-])", r"\\\1", value)
76
+ return f"export {var_name}={escaped_value}"
77
+
78
+
79
+ @dataclass
80
+ class Args:
81
+ json_mode: bool = False
82
+ dry_run: bool = False
83
+ allow_existing: bool = False
84
+ short_name: str = ""
85
+ branch_number: str = ""
86
+ use_timestamp: bool = False
87
+ description_parts: list[str] = field(default_factory=list)
88
+
89
+
90
+ def parse_args(argv: list[str]) -> Args:
91
+ args = Args()
92
+ i = 0
93
+ while i < len(argv):
94
+ arg = argv[i]
95
+ if arg == "--json":
96
+ args.json_mode = True
97
+ elif arg == "--dry-run":
98
+ args.dry_run = True
99
+ elif arg == "--allow-existing-branch":
100
+ args.allow_existing = True
101
+ elif arg == "--short-name":
102
+ if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
103
+ _err("Error: --short-name requires a value")
104
+ raise SystemExit(1)
105
+ i += 1
106
+ args.short_name = argv[i]
107
+ elif arg == "--number":
108
+ if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
109
+ _err("Error: --number requires a value")
110
+ raise SystemExit(1)
111
+ i += 1
112
+ args.branch_number = argv[i]
113
+ if not re.fullmatch(r"[0-9]+", args.branch_number):
114
+ _err("Error: --number must be a non-negative integer")
115
+ raise SystemExit(1)
116
+ elif arg == "--timestamp":
117
+ args.use_timestamp = True
118
+ elif arg in ("--help", "-h"):
119
+ print(HELP_TEXT)
120
+ raise SystemExit(0)
121
+ else:
122
+ args.description_parts.append(arg)
123
+ i += 1
124
+ return args
125
+
126
+
127
+ # ── Core helpers loading ─────────────────────────────────────────────────────
128
+
129
+
130
+ def _find_project_root(start: Path) -> Path | None:
131
+ current = start
132
+ while True:
133
+ if (current / ".duaer").is_dir() or (current / ".git").exists():
134
+ return current
135
+ if current.parent == current:
136
+ return None
137
+ current = current.parent
138
+
139
+
140
+ def _load_core_common(project_root: Path | None):
141
+ """Load the core common.py from the project's installed scripts.
142
+
143
+ Search locations in priority order, mirroring the bash script:
144
+ 1. .duaer/scripts/python/common.py (installed project)
145
+ 2. scripts/python/common.py (source checkout fallback)
146
+ Returns the loaded module or None.
147
+ """
148
+ if project_root is None:
149
+ return None
150
+ for relative in (".duaer/scripts/python/common.py", "scripts/python/common.py"):
151
+ candidate = project_root / relative
152
+ if candidate.is_file():
153
+ spec = importlib.util.spec_from_file_location("speckit_core_common", candidate)
154
+ if spec is None or spec.loader is None:
155
+ continue
156
+ module = importlib.util.module_from_spec(spec)
157
+ sys.modules[spec.name] = module
158
+ spec.loader.exec_module(module)
159
+ return module
160
+ return None
161
+
162
+
163
+ def _local_has_git(repo_root: Path) -> bool:
164
+ git_marker = repo_root / ".git"
165
+ if not (git_marker.is_dir() or git_marker.is_file()):
166
+ return False
167
+ if shutil.which("git") is None:
168
+ return False
169
+ return (
170
+ subprocess.run(
171
+ ["git", "-C", str(repo_root), "rev-parse", "--is-inside-work-tree"],
172
+ capture_output=True,
173
+ text=True,
174
+ ).returncode
175
+ == 0
176
+ )
177
+
178
+
179
+ # ── Numbering ────────────────────────────────────────────────────────────────
180
+
181
+
182
+ def get_highest_from_specs(specs_dir: Path) -> int:
183
+ highest = 0
184
+ if specs_dir.is_dir():
185
+ for entry in specs_dir.iterdir():
186
+ if not entry.is_dir():
187
+ continue
188
+ name = entry.name
189
+ # Match sequential prefixes (>=3 digits), but skip timestamp dirs.
190
+ if re.match(r"^[0-9]{3,}-", name) and not re.match(
191
+ r"^[0-9]{8}-[0-9]{6}-", name
192
+ ):
193
+ number = int(re.match(r"^[0-9]+", name).group(0))
194
+ highest = max(highest, number)
195
+ return highest
196
+
197
+
198
+ def _extract_highest_number(names: list[str], scope_prefix: str) -> int:
199
+ """Extract the highest sequential feature number from a list of ref names."""
200
+ highest = 0
201
+ for name in names:
202
+ if not name:
203
+ continue
204
+ if scope_prefix:
205
+ if not name.startswith(scope_prefix):
206
+ continue
207
+ name = name[len(scope_prefix) :]
208
+ name = name.rsplit("/", 1)[-1]
209
+ if (
210
+ re.match(r"^[0-9]{3,}-", name)
211
+ and not re.match(r"^[0-9]{8}-[0-9]{6}-", name)
212
+ and not re.match(r"^[0-9]{7}-[0-9]{6}-", name)
213
+ and not re.fullmatch(r"[0-9]{7,8}-[0-9]{6}", name)
214
+ ):
215
+ match = re.match(r"^([0-9]{3,})-", name)
216
+ number = int(match.group(1)) if match else 0
217
+ highest = max(highest, number)
218
+ return highest
219
+
220
+
221
+ def _git_lines(repo_root: Path, *args: str, env_extra: dict | None = None) -> list[str]:
222
+ if shutil.which("git") is None:
223
+ return []
224
+ env = {**os.environ, **(env_extra or {})}
225
+ result = subprocess.run(
226
+ ["git", *args], cwd=repo_root, capture_output=True, text=True, env=env
227
+ )
228
+ if result.returncode != 0:
229
+ return []
230
+ return result.stdout.splitlines()
231
+
232
+
233
+ def get_highest_from_branches(repo_root: Path, scope_prefix: str) -> int:
234
+ names = []
235
+ for line in _git_lines(repo_root, "branch", "-a"):
236
+ line = re.sub(r"^[+*]\s+", "", line)
237
+ line = line.lstrip()
238
+ line = re.sub(r"^remotes/[^/]*/", "", line)
239
+ names.append(line)
240
+ return _extract_highest_number(names, scope_prefix)
241
+
242
+
243
+ def get_highest_from_remote_refs(repo_root: Path, scope_prefix: str) -> int:
244
+ """Highest number from remote branches without fetching (side-effect-free)."""
245
+ highest = 0
246
+ for remote in _git_lines(repo_root, "remote"):
247
+ refs = _git_lines(
248
+ repo_root,
249
+ "ls-remote",
250
+ "--heads",
251
+ remote,
252
+ env_extra={"GIT_TERMINAL_PROMPT": "0"},
253
+ )
254
+ names = [re.sub(r".*refs/heads/", "", ref) for ref in refs]
255
+ highest = max(highest, _extract_highest_number(names, scope_prefix))
256
+ return highest
257
+
258
+
259
+ def check_existing_branches(
260
+ repo_root: Path, specs_dir: Path, skip_fetch: bool, scope_prefix: str
261
+ ) -> int:
262
+ """Check existing branches and return the next available number."""
263
+ if skip_fetch:
264
+ highest_branch = max(
265
+ get_highest_from_remote_refs(repo_root, scope_prefix),
266
+ get_highest_from_branches(repo_root, scope_prefix),
267
+ )
268
+ else:
269
+ subprocess.run(
270
+ ["git", "fetch", "--all", "--prune"],
271
+ cwd=repo_root,
272
+ capture_output=True,
273
+ text=True,
274
+ )
275
+ highest_branch = get_highest_from_branches(repo_root, scope_prefix)
276
+
277
+ return max(highest_branch, get_highest_from_specs(specs_dir)) + 1
278
+
279
+
280
+ # ── Branch naming ────────────────────────────────────────────────────────────
281
+
282
+
283
+ def clean_branch_name(name: str) -> str:
284
+ name = re.sub(r"[^a-z0-9]", "-", name.lower())
285
+ name = re.sub(r"-+", "-", name)
286
+ return name.strip("-")
287
+
288
+
289
+ def generate_branch_name(description: str) -> str:
290
+ """Generate a branch suffix from the description with stop word filtering."""
291
+ clean_name = re.sub(r"[^a-z0-9]", " ", description.lower())
292
+
293
+ meaningful_words = []
294
+ for word in clean_name.split():
295
+ if word in STOP_WORDS:
296
+ continue
297
+ if len(word) >= 3:
298
+ meaningful_words.append(word)
299
+ # Keep short words only when they appear uppercased in the original
300
+ # description (acronyms like "API" or "DB").
301
+ elif re.search(rf"\b{re.escape(word.upper())}\b", description):
302
+ meaningful_words.append(word)
303
+
304
+ if meaningful_words:
305
+ max_words = 4 if len(meaningful_words) == 4 else 3
306
+ return "-".join(meaningful_words[:max_words])
307
+
308
+ cleaned = clean_branch_name(description)
309
+ return "-".join([part for part in cleaned.split("-") if part][:3])
310
+
311
+
312
+ def branch_token(value: str, fallback: str) -> str:
313
+ cleaned = clean_branch_name(value)
314
+ return cleaned if cleaned else fallback
315
+
316
+
317
+ def get_author_token(repo_root: Path) -> str:
318
+ author = ""
319
+ if shutil.which("git") is not None:
320
+ lines = _git_lines(repo_root, "config", "user.name")
321
+ author = lines[0] if lines else ""
322
+ if not author:
323
+ lines = _git_lines(repo_root, "config", "user.email")
324
+ email = lines[0] if lines else ""
325
+ author = email.split("@")[0]
326
+ if not author:
327
+ author = os.environ.get("USER") or os.environ.get("USERNAME") or "unknown"
328
+ return branch_token(author, "unknown")
329
+
330
+
331
+ def get_app_token(repo_root: Path) -> str:
332
+ return branch_token(repo_root.name, "app")
333
+
334
+
335
+ def read_git_config_value(config_file: Path, key: str) -> str:
336
+ if not config_file.is_file():
337
+ return ""
338
+ try:
339
+ lines = config_file.read_text(encoding="utf-8").splitlines()
340
+ except (OSError, UnicodeDecodeError):
341
+ return ""
342
+ for line in lines:
343
+ if re.match(rf"^\s*{re.escape(key)}:", line):
344
+ value = re.sub(rf"^\s*{re.escape(key)}:\s*", "", line)
345
+ value = re.sub(r"\s+#.*$", "", value)
346
+ value = value.strip()
347
+ value = re.sub(r'^"|"$', "", value)
348
+ value = re.sub(r"^'|'$", "", value)
349
+ return value
350
+ return ""
351
+
352
+
353
+ def resolve_branch_template(config_file: Path) -> str:
354
+ template = read_git_config_value(config_file, "branch_template")
355
+ if template:
356
+ return template
357
+
358
+ prefix = read_git_config_value(config_file, "branch_prefix")
359
+ if not prefix:
360
+ return ""
361
+ if prefix.endswith("/"):
362
+ return f"{prefix}{{number}}-{{slug}}"
363
+ return f"{prefix}/{{number}}-{{slug}}"
364
+
365
+
366
+ def validate_branch_template(template: str) -> None:
367
+ if not template:
368
+ return
369
+ if "{number}" not in template:
370
+ _err(
371
+ "Error: branch_template must include the {number} token so generated "
372
+ "branches remain valid feature branches."
373
+ )
374
+ raise SystemExit(1)
375
+ slug_index = template.find("{slug}")
376
+ if slug_index != -1 and "{number}" in template[slug_index:]:
377
+ _err(
378
+ "Error: branch_template must not place {slug} before {number}; "
379
+ "use {slug} only in the final feature segment."
380
+ )
381
+ raise SystemExit(1)
382
+ feature_segment = template.rsplit("/", 1)[-1]
383
+ if not feature_segment.startswith("{number}-"):
384
+ _err(
385
+ "Error: branch_template must put {number}- at the start of the final "
386
+ "path segment so generated branches remain valid feature branches."
387
+ )
388
+ raise SystemExit(1)
389
+
390
+
391
+ def render_branch_template(
392
+ template: str, feature_num: str, branch_suffix: str, author_token: str, app_token: str
393
+ ) -> str:
394
+ rendered = template
395
+ rendered = rendered.replace("{author}", author_token)
396
+ rendered = rendered.replace("{app}", app_token)
397
+ rendered = rendered.replace("{number}", feature_num)
398
+ rendered = rendered.replace("{slug}", branch_suffix)
399
+ return rendered
400
+
401
+
402
+ def extract_feature_num_from_branch(branch_name: str) -> str:
403
+ feature_segment = branch_name.rsplit("/", 1)[-1]
404
+ match = re.match(r"^[0-9]{8}-[0-9]{6}-", feature_segment)
405
+ if match:
406
+ return match.group(0).rstrip("-")
407
+ match = re.match(r"^[0-9]+-", feature_segment)
408
+ if match:
409
+ return match.group(0).rstrip("-")
410
+ return branch_name
411
+
412
+
413
+ def _byte_length(value: str) -> int:
414
+ return len(value.encode("utf-8"))
415
+
416
+
417
+ # ── Main ─────────────────────────────────────────────────────────────────────
418
+
419
+
420
+ def main(argv: list[str]) -> int:
421
+ args = parse_args(argv)
422
+
423
+ feature_description = " ".join(args.description_parts)
424
+ if not feature_description:
425
+ _err(USAGE)
426
+ return 1
427
+ feature_description = feature_description.strip()
428
+ if not feature_description:
429
+ _err("Error: Feature description cannot be empty or contain only whitespace")
430
+ return 1
431
+
432
+ project_root = _find_project_root(SCRIPT_DIR)
433
+ core = _load_core_common(project_root)
434
+
435
+ # SPECIFY_INIT_DIR is resolved (and validated) by the core resolver. If the
436
+ # core helpers were not found, refuse rather than silently falling back to
437
+ # the wrong root.
438
+ if os.environ.get("SPECIFY_INIT_DIR") and (
439
+ core is None or not hasattr(core, "resolve_specify_init_dir")
440
+ ):
441
+ _err(
442
+ "Error: SPECIFY_INIT_DIR requires updated Duaer core scripts "
443
+ "(common.py with resolve_specify_init_dir), which were not found."
444
+ )
445
+ return 1
446
+
447
+ if core is not None and hasattr(core, "get_repo_root"):
448
+ # Pass script path so cwd-outside-repo callers land on the same
449
+ # fallback the bash twin does. Older cores don't accept the kwarg —
450
+ # fall back to the no-arg call for compatibility.
451
+ try:
452
+ repo_root = core.get_repo_root(script_file=Path(__file__))
453
+ except TypeError:
454
+ repo_root = core.get_repo_root()
455
+ else:
456
+ toplevel = _git_lines(Path.cwd(), "rev-parse", "--show-toplevel")
457
+ if toplevel:
458
+ repo_root = Path(toplevel[0])
459
+ elif project_root is not None:
460
+ repo_root = project_root
461
+ else:
462
+ _err("Error: Could not determine repository root.")
463
+ return 1
464
+ repo_root = Path(repo_root)
465
+
466
+ has_git_repo = _local_has_git(repo_root)
467
+
468
+ specs_dir = repo_root / "specs"
469
+ config_file = repo_root / ".duaer" / "extensions" / "git" / "git-config.yml"
470
+
471
+ author_token = get_author_token(repo_root)
472
+ app_token = get_app_token(repo_root)
473
+ branch_template = resolve_branch_template(config_file)
474
+ validate_branch_template(branch_template)
475
+
476
+ def build_branch_name(feature_num: str, branch_suffix: str) -> str:
477
+ if branch_template:
478
+ return render_branch_template(
479
+ branch_template, feature_num, branch_suffix, author_token, app_token
480
+ )
481
+ return f"{feature_num}-{branch_suffix}"
482
+
483
+ branch_number = args.branch_number
484
+
485
+ # Check for GIT_BRANCH_NAME env var override (exact name, no prefix/suffix)
486
+ env_branch_name = os.environ.get("GIT_BRANCH_NAME", "")
487
+ if env_branch_name:
488
+ branch_name = env_branch_name
489
+ feature_num = extract_feature_num_from_branch(branch_name)
490
+ branch_suffix = branch_name
491
+ else:
492
+ if args.short_name:
493
+ branch_suffix = clean_branch_name(args.short_name)
494
+ else:
495
+ branch_suffix = generate_branch_name(feature_description)
496
+
497
+ if args.use_timestamp and branch_number:
498
+ _err("[specify] Warning: --number is ignored when --timestamp is used")
499
+ branch_number = ""
500
+
501
+ if args.use_timestamp:
502
+ feature_num = datetime.now().strftime("%Y%m%d-%H%M%S")
503
+ branch_name = build_branch_name(feature_num, branch_suffix)
504
+ else:
505
+ scope_prefix = ""
506
+ if branch_template:
507
+ prefix_template = branch_template.split("{number}")[0]
508
+ scope_prefix = render_branch_template(
509
+ prefix_template, "", branch_suffix, author_token, app_token
510
+ )
511
+ if not branch_number:
512
+ if args.dry_run and has_git_repo:
513
+ branch_number = check_existing_branches(
514
+ repo_root, specs_dir, True, scope_prefix
515
+ )
516
+ elif args.dry_run:
517
+ branch_number = get_highest_from_specs(specs_dir) + 1
518
+ elif has_git_repo:
519
+ branch_number = check_existing_branches(
520
+ repo_root, specs_dir, False, scope_prefix
521
+ )
522
+ else:
523
+ branch_number = get_highest_from_specs(specs_dir) + 1
524
+
525
+ feature_num = f"{int(branch_number):03d}"
526
+ branch_name = build_branch_name(feature_num, branch_suffix)
527
+
528
+ branch_byte_len = _byte_length(branch_name)
529
+ if env_branch_name and branch_byte_len > MAX_BRANCH_LENGTH:
530
+ _err(
531
+ "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. "
532
+ f"Provided value is {branch_byte_len} bytes."
533
+ )
534
+ return 1
535
+ if branch_byte_len > MAX_BRANCH_LENGTH:
536
+ original_branch_name = branch_name
537
+ truncated_suffix = branch_suffix
538
+ while _byte_length(branch_name) > MAX_BRANCH_LENGTH and truncated_suffix:
539
+ truncated_suffix = truncated_suffix[:-1]
540
+ truncated_suffix = truncated_suffix.rstrip("-")
541
+ branch_name = build_branch_name(feature_num, truncated_suffix)
542
+ if _byte_length(branch_name) > MAX_BRANCH_LENGTH:
543
+ _err("Error: Branch template prefix exceeds GitHub's 244-byte branch name limit.")
544
+ return 1
545
+
546
+ _err("[specify] Warning: Branch name exceeded GitHub's 244-byte limit")
547
+ _err(
548
+ f"[specify] Original: {original_branch_name} "
549
+ f"({_byte_length(original_branch_name)} bytes)"
550
+ )
551
+ _err(f"[specify] Truncated to: {branch_name} ({_byte_length(branch_name)} bytes)")
552
+
553
+ if not args.dry_run:
554
+ if has_git_repo:
555
+ create = subprocess.run(
556
+ ["git", "checkout", "-q", "-b", branch_name],
557
+ cwd=repo_root,
558
+ capture_output=True,
559
+ text=True,
560
+ )
561
+ if create.returncode != 0:
562
+ current_branch_lines = _git_lines(
563
+ repo_root, "rev-parse", "--abbrev-ref", "HEAD"
564
+ )
565
+ current_branch = current_branch_lines[0] if current_branch_lines else ""
566
+ branch_exists = bool(
567
+ _git_lines(repo_root, "branch", "--list", branch_name)
568
+ )
569
+ if branch_exists:
570
+ if args.allow_existing:
571
+ if current_branch != branch_name:
572
+ switch = subprocess.run(
573
+ ["git", "checkout", "-q", branch_name],
574
+ cwd=repo_root,
575
+ capture_output=True,
576
+ text=True,
577
+ )
578
+ if switch.returncode != 0:
579
+ _err(
580
+ f"Error: Failed to switch to existing branch '{branch_name}'. "
581
+ "Please resolve any local changes or conflicts and try again."
582
+ )
583
+ if switch.stderr.strip():
584
+ _err(switch.stderr.strip())
585
+ return 1
586
+ elif args.use_timestamp:
587
+ _err(
588
+ f"Error: Branch '{branch_name}' already exists. Rerun to get "
589
+ "a new timestamp or use a different --short-name."
590
+ )
591
+ return 1
592
+ else:
593
+ _err(
594
+ f"Error: Branch '{branch_name}' already exists. Please use a "
595
+ "different feature name or specify a different number with --number."
596
+ )
597
+ return 1
598
+ else:
599
+ _err(f"Error: Failed to create git branch '{branch_name}'.")
600
+ if create.stderr.strip():
601
+ _err(create.stderr.strip())
602
+ else:
603
+ _err("Please check your git configuration and try again.")
604
+ return 1
605
+ else:
606
+ _err(
607
+ "[specify] Warning: Git repository not detected; skipped branch "
608
+ f"creation for {branch_name}"
609
+ )
610
+
611
+ _err(f"# To persist: {_persist_hint('SPECIFY_FEATURE', branch_name)}")
612
+
613
+ if args.json_mode:
614
+ payload: dict[str, object] = {
615
+ "BRANCH_NAME": branch_name,
616
+ "FEATURE_NUM": feature_num,
617
+ }
618
+ if args.dry_run:
619
+ payload["DRY_RUN"] = True
620
+ print(json.dumps(payload, ensure_ascii=False, separators=(",", ":")))
621
+ else:
622
+ print(f"BRANCH_NAME: {branch_name}")
623
+ print(f"FEATURE_NUM: {feature_num}")
624
+ if not args.dry_run:
625
+ print(
626
+ "# To persist in your shell: "
627
+ f"{_persist_hint('SPECIFY_FEATURE', branch_name)}"
628
+ )
629
+
630
+ return 0
631
+
632
+
633
+ if __name__ == "__main__":
634
+ raise SystemExit(main(sys.argv[1:]))