autodevloop 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,127 @@
1
+ """Human-readable reports: CHANGELOG, FEATURES overview table, final report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from .util import markdown_list, now_text, read_text, write_text
9
+
10
+
11
+ def write_version_changelog(
12
+ changelog_path: Path,
13
+ version: int,
14
+ plan: dict[str, Any],
15
+ diff: dict[str, list[str]],
16
+ test_result: dict[str, Any],
17
+ review: dict[str, Any],
18
+ phase: str,
19
+ ) -> None:
20
+ if not changelog_path.exists():
21
+ write_text(changelog_path, "# Changelog\n\nAll AutoDevLoop-generated versions are recorded here.\n\n")
22
+
23
+ whats_new = review.get("whats_new") or []
24
+ summary = str(review.get("feature_summary") or plan.get("version_goal", "")).strip()
25
+ commands = ", ".join(test_result.get("commands", [])) or "No command"
26
+ lines = [
27
+ f"## v{version} - {now_text()} ({phase} phase)",
28
+ "",
29
+ f"_{summary}_" if summary else "",
30
+ "",
31
+ "### What's new",
32
+ markdown_list(whats_new) if whats_new else markdown_list(diff.get("added", []) + diff.get("changed", [])),
33
+ "",
34
+ "### Files",
35
+ f"- Added: {len(diff.get('added', []))}, Changed: {len(diff.get('changed', []))}, Removed: {len(diff.get('removed', []))}",
36
+ "",
37
+ "### Tests",
38
+ f"- {'PASS' if test_result.get('success') else 'FAIL'}: {commands}",
39
+ "",
40
+ "### Known issues",
41
+ markdown_list(review.get("issues", [])),
42
+ "",
43
+ ]
44
+ existing = read_text(changelog_path)
45
+ write_text(changelog_path, existing.rstrip() + "\n\n" + "\n".join(line for line in lines) + "\n")
46
+
47
+
48
+ def write_features_overview(features_path: Path, state: dict[str, Any]) -> None:
49
+ """The at-a-glance table: every version, its features, and what changed."""
50
+ versions = state.get("versions", [])
51
+ goal_version = state.get("goal_completed_version")
52
+ lines = [
53
+ "# Features Overview",
54
+ "",
55
+ f"**Project:** {state.get('project_name', '')} ",
56
+ f"**Goal:** {state.get('goal', '')}",
57
+ "",
58
+ ]
59
+ if goal_version:
60
+ lines.append(f"> Core goal first fully met at **v{goal_version}** (tag `goal-complete`). "
61
+ "Later versions extend the product beyond the original request.")
62
+ lines.append("")
63
+ lines += [
64
+ "| Version | Phase | Score | Tests | Summary | What's new |",
65
+ "|---|---|---|---|---|---|",
66
+ ]
67
+ for item in versions:
68
+ v = item.get("version")
69
+ phase = item.get("phase", "build")
70
+ score = item.get("review_score", "?")
71
+ tests = "✅" if item.get("test_result", {}).get("success") else "❌"
72
+ summary = _cell(item.get("feature_summary") or item.get("plan", {}).get("version_goal", ""))
73
+ whats_new = _cell("; ".join(item.get("whats_new", [])[:4]))
74
+ marker = " 🎯" if v == goal_version else ""
75
+ lines.append(f"| v{v}{marker} | {phase} | {score}/100 | {tests} | {summary} | {whats_new} |")
76
+ lines.append("")
77
+ write_text(features_path, "\n".join(lines))
78
+
79
+
80
+ def _cell(text: str) -> str:
81
+ flat = " ".join(str(text).split())
82
+ flat = flat.replace("|", "\\|")
83
+ return flat[:160] + ("…" if len(flat) > 160 else "")
84
+
85
+
86
+ def write_final_report(report_path: Path, state: dict[str, Any]) -> None:
87
+ versions = state.get("versions", [])
88
+ scores = [v.get("review_score", 0) for v in versions if isinstance(v.get("review_score", 0), int)]
89
+ avg = round(sum(scores) / len(scores)) if scores else 0
90
+ cost = state.get("cost", {})
91
+ lines = [
92
+ "# AutoDevLoop Final Report",
93
+ "",
94
+ f"**Project:** {state.get('project_name')}",
95
+ f"**Status:** {state.get('status')}",
96
+ f"**Stop reason:** {state.get('stop_reason', 'N/A')}",
97
+ f"**Total versions:** {state.get('current_version')}",
98
+ f"**Goal met at:** {('v' + str(state.get('goal_completed_version'))) if state.get('goal_completed_version') else 'not reached'}",
99
+ f"**Average review score:** {avg}/100",
100
+ f"**Estimated cost:** ${cost.get('cost_usd_total', 0):.4f} "
101
+ f"(in {cost.get('input_tokens', 0)} / out {cost.get('output_tokens', 0)} tokens)",
102
+ "",
103
+ "## Goal",
104
+ str(state.get("goal", "")),
105
+ "",
106
+ "## Version history",
107
+ ]
108
+ for item in versions:
109
+ v = item.get("version")
110
+ score = item.get("review_score", "?")
111
+ test = item.get("test_result", {})
112
+ phase = item.get("phase", "build")
113
+ lines.append(
114
+ f"- **v{v}** ({phase}) - score {score}/100 - tests "
115
+ f"{'PASS' if test.get('success') else 'FAIL'} - {_cell(item.get('feature_summary', ''))}"
116
+ )
117
+ lines += [
118
+ "",
119
+ "## Output layout",
120
+ "- Latest working copy: `current/`",
121
+ "- Per-version snapshots: `versions/vN/`",
122
+ "- Git history & tags inside `current/` (if git enabled)",
123
+ "- Changelog: `CHANGELOG.md`",
124
+ "- Features overview: `FEATURES.md`",
125
+ "",
126
+ ]
127
+ write_text(report_path, "\n".join(lines))
autodevloop/testing.py ADDED
@@ -0,0 +1,119 @@
1
+ """Built-in test detection and execution (no LLM required)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from .util import INTERNAL_DIRS, list_generated_files, load_json, read_text
12
+
13
+
14
+ def detect_candidates(base_dir: Path) -> list[dict[str, Any]]:
15
+ files = set(list_generated_files(base_dir))
16
+ candidates: list[dict[str, Any]] = []
17
+
18
+ if "package.json" in files:
19
+ package = load_json(base_dir / "package.json", {})
20
+ scripts = package.get("scripts", {}) if isinstance(package, dict) else {}
21
+ if "test" in scripts:
22
+ candidates.append({"name": "npm test", "command": "npm test", "kind": "node"})
23
+ if "build" in scripts:
24
+ candidates.append({"name": "npm run build", "command": "npm run build", "kind": "node"})
25
+
26
+ has_py_tests = any(f.startswith("tests/") and f.endswith(".py") for f in files) or any(
27
+ Path(f).name.startswith("test_") for f in files
28
+ )
29
+ if has_py_tests:
30
+ candidates.append({"name": "pytest", "command": "python -m pytest -q", "kind": "python"})
31
+ candidates.append({"name": "unittest", "command": "python -m unittest discover", "kind": "python"})
32
+
33
+ if "index.html" in files:
34
+ html = read_text(base_dir / "index.html")
35
+ candidates.append({"name": "static html smoke", "command": "__builtin_html_smoke__", "kind": "web"})
36
+ if 'type="module"' in html or "type='module'" in html:
37
+ candidates.append({"name": "module html server smoke", "command": "__builtin_html_server_smoke__", "kind": "web"})
38
+
39
+ if "pyproject.toml" in files or any(f.endswith(".py") for f in files):
40
+ candidates.append({"name": "python compile", "command": "__builtin_python_compile__", "kind": "python"})
41
+
42
+ candidates.append({"name": "file smoke", "command": "__builtin_file_smoke__", "kind": "generic"})
43
+ return candidates
44
+
45
+
46
+ def run_command(base_dir: Path, command: str, timeout: int, log_path: Path) -> dict[str, Any]:
47
+ builtins = {
48
+ "__builtin_file_smoke__": _file_smoke,
49
+ "__builtin_html_smoke__": lambda d, t: _html_smoke(d, server=False),
50
+ "__builtin_html_server_smoke__": lambda d, t: _html_smoke(d, server=True),
51
+ "__builtin_python_compile__": _python_compile,
52
+ }
53
+ if command in builtins:
54
+ return builtins[command](base_dir, timeout)
55
+
56
+ try:
57
+ completed = subprocess.run(
58
+ command, cwd=str(base_dir), shell=True, text=True, encoding="utf-8",
59
+ errors="replace", capture_output=True, timeout=timeout,
60
+ )
61
+ except subprocess.TimeoutExpired:
62
+ log_path.parent.mkdir(parents=True, exist_ok=True)
63
+ log_path.write_text(f"$ {command}\nTimed out after {timeout}s.\n", encoding="utf-8")
64
+ return {"success": False, "command": command, "returncode": -1, "log": log_path.name}
65
+
66
+ output = (
67
+ f"$ {command}\ncwd={base_dir}\nreturncode={completed.returncode}\n\n"
68
+ f"[stdout]\n{completed.stdout}\n\n[stderr]\n{completed.stderr}\n"
69
+ )
70
+ log_path.parent.mkdir(parents=True, exist_ok=True)
71
+ log_path.write_text(output, encoding="utf-8")
72
+ return {"success": completed.returncode == 0, "command": command, "returncode": completed.returncode, "log": log_path.name}
73
+
74
+
75
+ def _file_smoke(base_dir: Path, _timeout: int) -> dict[str, Any]:
76
+ files = list_generated_files(base_dir)
77
+ success = bool(files)
78
+ return {"success": success, "command": "__builtin_file_smoke__", "returncode": 0 if success else 1, "details": f"{len(files)} files"}
79
+
80
+
81
+ def _python_compile(base_dir: Path, timeout: int) -> dict[str, Any]:
82
+ py_files = [
83
+ str(p) for p in base_dir.rglob("*.py")
84
+ if not any(part in INTERNAL_DIRS for part in p.relative_to(base_dir).parts)
85
+ ]
86
+ if not py_files:
87
+ return {"success": True, "command": "__builtin_python_compile__", "returncode": 0, "details": "No Python files."}
88
+ completed = subprocess.run(
89
+ [sys.executable, "-m", "py_compile", *py_files],
90
+ cwd=str(base_dir), text=True, encoding="utf-8", errors="replace",
91
+ capture_output=True, timeout=timeout,
92
+ )
93
+ return {
94
+ "success": completed.returncode == 0,
95
+ "command": "__builtin_python_compile__",
96
+ "returncode": completed.returncode,
97
+ "details": (completed.stderr or "").strip()[:300],
98
+ }
99
+
100
+
101
+ def _html_smoke(base_dir: Path, server: bool) -> dict[str, Any]:
102
+ marker = "__builtin_html_server_smoke__" if server else "__builtin_html_smoke__"
103
+ index = base_dir / "index.html"
104
+ if not index.exists():
105
+ return {"success": False, "command": marker, "returncode": 1, "details": "index.html missing"}
106
+ html = read_text(index)
107
+ missing: list[str] = []
108
+ for attr in re.findall(r"""(?:src|href)=["']([^"']+)["']""", html):
109
+ if attr.startswith(("http://", "https://", "data:", "#", "//", "mailto:")):
110
+ continue
111
+ target = base_dir / attr.split("?", 1)[0].lstrip("/")
112
+ if not target.exists():
113
+ missing.append(attr)
114
+ module = 'type="module"' in html or "type='module'" in html
115
+ if missing:
116
+ return {"success": False, "command": marker, "returncode": 1, "details": f"Missing assets: {missing[:5]}"}
117
+ if module and not server:
118
+ return {"success": False, "command": "__builtin_html_smoke__", "returncode": 1, "details": "Uses ES modules; needs a local server."}
119
+ return {"success": True, "command": marker, "returncode": 0, "details": "HTML assets resolved."}
autodevloop/util.py ADDED
@@ -0,0 +1,250 @@
1
+ """Filesystem, JSON, and text helpers shared across AutoDevLoop modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import filecmp
7
+ import json
8
+ import os
9
+ import re
10
+ import shutil
11
+ import stat
12
+ import threading
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ APP_DIR = ".autodev"
17
+ CONFIG_FILE = ".autodevloop.yml"
18
+ STATE_FILE = "state.json"
19
+ PROGRESS_FILE = "progress.json"
20
+ STOP_FILE = "STOP"
21
+
22
+ TEXT_SUFFIXES = {
23
+ ".css", ".html", ".js", ".json", ".jsx", ".md", ".mjs", ".py",
24
+ ".toml", ".ts", ".tsx", ".txt", ".yml", ".yaml", ".vue", ".svelte",
25
+ ".go", ".rs", ".java", ".kt", ".rb", ".php", ".c", ".cpp", ".h",
26
+ ".sh", ".sql", ".env", ".cfg", ".ini",
27
+ }
28
+ DOC_SUFFIXES = {".md", ".txt", ".rst"}
29
+ INTERNAL_DIRS = {APP_DIR, ".git", "__pycache__", ".pytest_cache", "node_modules", ".venv", "dist", "build"}
30
+
31
+
32
+ def now_text() -> str:
33
+ return dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
34
+
35
+
36
+ def ts() -> str:
37
+ return dt.datetime.now().strftime("%H:%M:%S")
38
+
39
+
40
+ def slugify(value: str, fallback: str = "item") -> str:
41
+ cleaned = re.sub(r"[^A-Za-z0-9_-]+", "_", str(value)).strip("_")
42
+ return cleaned or fallback
43
+
44
+
45
+ def write_text(path: Path, content: str) -> None:
46
+ path.parent.mkdir(parents=True, exist_ok=True)
47
+ path.write_text(content, encoding="utf-8", newline="\n")
48
+
49
+
50
+ def read_text(path: Path, default: str = "") -> str:
51
+ try:
52
+ return path.read_text(encoding="utf-8")
53
+ except (FileNotFoundError, UnicodeDecodeError, OSError):
54
+ return default
55
+
56
+
57
+ def load_json(path: Path, default: Any) -> Any:
58
+ if not path.exists():
59
+ return default
60
+ try:
61
+ with path.open("r", encoding="utf-8") as fh:
62
+ return json.load(fh)
63
+ except (json.JSONDecodeError, OSError):
64
+ return default
65
+
66
+
67
+ def json_safe(value: Any) -> Any:
68
+ if isinstance(value, Path):
69
+ return str(value)
70
+ if isinstance(value, dict):
71
+ return {str(k): json_safe(v) for k, v in value.items()}
72
+ if isinstance(value, (list, tuple)):
73
+ return [json_safe(item) for item in value]
74
+ return value
75
+
76
+
77
+ def save_json(path: Path, data: Any, stamp: bool = True) -> None:
78
+ path.parent.mkdir(parents=True, exist_ok=True)
79
+ if stamp and isinstance(data, dict):
80
+ data["updated_at"] = now_text()
81
+ # Unique temp name so concurrent writers never collide on the same file.
82
+ tmp = path.with_name(f"{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
83
+ with tmp.open("w", encoding="utf-8", newline="\n") as fh:
84
+ json.dump(json_safe(data), fh, ensure_ascii=False, indent=2)
85
+ fh.write("\n")
86
+ tmp.replace(path)
87
+
88
+
89
+ def _chmod_retry(func, path, _exc) -> None:
90
+ """Clear the read-only bit (common on Windows .git objects) and retry."""
91
+ try:
92
+ os.chmod(path, stat.S_IWRITE)
93
+ func(path)
94
+ except OSError:
95
+ pass
96
+
97
+
98
+ def rmtree_robust(path: Path) -> None:
99
+ if not path.exists():
100
+ return
101
+ try: # Python 3.12+ uses onexc; older uses onerror.
102
+ shutil.rmtree(path, onexc=_chmod_retry)
103
+ except TypeError:
104
+ shutil.rmtree(path, onerror=_chmod_retry)
105
+
106
+
107
+ def safe_rmtree(path: Path, root: Path) -> None:
108
+ resolved = path.resolve()
109
+ root_resolved = root.resolve()
110
+ try:
111
+ resolved.relative_to(root_resolved)
112
+ except ValueError as exc:
113
+ raise RuntimeError(f"refusing to delete outside project: {resolved}") from exc
114
+ rmtree_robust(path)
115
+
116
+
117
+ def restore_working_dir(before_dir: Path, current_dir: Path) -> None:
118
+ """Restore current_dir's working files from a snapshot, keeping .git intact."""
119
+ if current_dir.exists():
120
+ for item in current_dir.iterdir():
121
+ if item.name in INTERNAL_DIRS:
122
+ continue
123
+ if item.is_dir():
124
+ rmtree_robust(item)
125
+ else:
126
+ try:
127
+ item.unlink()
128
+ except OSError:
129
+ pass
130
+ copy_tree_contents(before_dir, current_dir)
131
+
132
+
133
+ def copy_tree_contents(source: Path, dest: Path) -> None:
134
+ dest.mkdir(parents=True, exist_ok=True)
135
+ if not source.exists():
136
+ return
137
+ for item in source.iterdir():
138
+ if item.name in INTERNAL_DIRS:
139
+ continue
140
+ target = dest / item.name
141
+ if item.is_dir():
142
+ shutil.copytree(item, target, dirs_exist_ok=True)
143
+ else:
144
+ target.parent.mkdir(parents=True, exist_ok=True)
145
+ shutil.copy2(item, target)
146
+
147
+
148
+ def list_generated_files(base_dir: Path) -> list[str]:
149
+ if not base_dir.exists():
150
+ return []
151
+ files: list[str] = []
152
+ for path in sorted(base_dir.rglob("*")):
153
+ if path.is_dir():
154
+ continue
155
+ relative = path.relative_to(base_dir)
156
+ if any(part in INTERNAL_DIRS for part in relative.parts):
157
+ continue
158
+ files.append(relative.as_posix())
159
+ return files
160
+
161
+
162
+ def collect_context(base_dir: Path, max_bytes: int = 70_000) -> str:
163
+ """Render a file tree plus readable file bodies, recency-prioritised."""
164
+ if not base_dir.exists():
165
+ return "(empty)"
166
+
167
+ tree: list[str] = ["File tree:"]
168
+ files: list[Path] = []
169
+ for path in sorted(base_dir.rglob("*")):
170
+ if path.is_dir():
171
+ continue
172
+ relative = path.relative_to(base_dir)
173
+ if any(part in INTERNAL_DIRS for part in relative.parts):
174
+ continue
175
+ files.append(path)
176
+ tree.append(f"- {relative.as_posix()}")
177
+
178
+ parts = list(tree)
179
+ parts.append("\nReadable files (most recently modified first):")
180
+ used = len("\n".join(parts).encode("utf-8"))
181
+ readable = [p for p in files if p.suffix.lower() in TEXT_SUFFIXES]
182
+ readable.sort(key=lambda p: p.stat().st_mtime if p.exists() else 0, reverse=True)
183
+ for path in readable:
184
+ relative = path.relative_to(base_dir).as_posix()
185
+ block = f'\n<existing_file path="{relative}">\n{read_text(path)}\n</existing_file>\n'
186
+ size = len(block.encode("utf-8"))
187
+ if used + size > max_bytes:
188
+ parts.append("\n(context truncated; older files omitted)")
189
+ break
190
+ parts.append(block)
191
+ used += size
192
+ return "\n".join(parts)
193
+
194
+
195
+ def diff_file_lists(before_dir: Path, after_dir: Path) -> dict[str, list[str]]:
196
+ before = set(list_generated_files(before_dir))
197
+ after = set(list_generated_files(after_dir))
198
+ changed: list[str] = []
199
+ for relative in sorted(before & after):
200
+ left = before_dir / relative
201
+ right = after_dir / relative
202
+ try:
203
+ same = filecmp.cmp(left, right, shallow=False)
204
+ except OSError:
205
+ same = False
206
+ if not same:
207
+ changed.append(relative)
208
+ return {
209
+ "added": sorted(after - before),
210
+ "changed": changed,
211
+ "removed": sorted(before - after),
212
+ }
213
+
214
+
215
+ def markdown_list(items: list[Any]) -> str:
216
+ values = [str(item).strip() for item in (items or []) if str(item).strip()]
217
+ return "\n".join(f"- {item}" for item in values) if values else "- None"
218
+
219
+
220
+ def extract_json(raw: str, default: dict[str, Any]) -> dict[str, Any]:
221
+ """Best-effort JSON extraction from a model response (handles code fences)."""
222
+ text = raw.strip()
223
+ fence = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
224
+ if fence:
225
+ try:
226
+ value = json.loads(fence.group(1))
227
+ if isinstance(value, dict):
228
+ return value
229
+ except json.JSONDecodeError:
230
+ pass
231
+
232
+ try:
233
+ value = json.loads(text)
234
+ if isinstance(value, dict):
235
+ return value
236
+ except json.JSONDecodeError:
237
+ pass
238
+
239
+ decoder = json.JSONDecoder()
240
+ objects: list[tuple[int, dict[str, Any]]] = []
241
+ for idx, char in enumerate(text):
242
+ if char != "{":
243
+ continue
244
+ try:
245
+ value, end = decoder.raw_decode(text[idx:])
246
+ except json.JSONDecodeError:
247
+ continue
248
+ if isinstance(value, dict):
249
+ objects.append((len(json.dumps(value)), value))
250
+ return max(objects, key=lambda item: item[0])[1] if objects else default
autodevloop/vcs.py ADDED
@@ -0,0 +1,74 @@
1
+ """Lightweight git helpers for snapshotting each version inside current/.
2
+
3
+ Git is optional: the versions/ folder copies remain the real backup. When git
4
+ is available we additionally commit and tag every version, and place a special
5
+ tag on the version where the user's goal is first fully met.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ import subprocess
12
+ from pathlib import Path
13
+
14
+ GOAL_TAG = "goal-complete"
15
+
16
+
17
+ def git_available() -> bool:
18
+ return shutil.which("git") is not None
19
+
20
+
21
+ def _run(args: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
22
+ return subprocess.run(
23
+ ["git", *args],
24
+ cwd=str(cwd),
25
+ text=True,
26
+ encoding="utf-8",
27
+ errors="replace",
28
+ capture_output=True,
29
+ )
30
+
31
+
32
+ def ensure_repo(cwd: Path) -> bool:
33
+ """Initialise a git repo in cwd if needed. Returns True if usable."""
34
+ if not git_available():
35
+ return False
36
+ cwd.mkdir(parents=True, exist_ok=True)
37
+ if (cwd / ".git").exists():
38
+ return True
39
+ if _run(["init"], cwd).returncode != 0:
40
+ return False
41
+ # Ensure commits work even without a global identity configured.
42
+ if not _run(["config", "user.name"], cwd).stdout.strip():
43
+ _run(["config", "user.name", "AutoDevLoop"], cwd)
44
+ if not _run(["config", "user.email"], cwd).stdout.strip():
45
+ _run(["config", "user.email", "autodevloop@local"], cwd)
46
+ _run(["config", "commit.gpgsign", "false"], cwd)
47
+ gitignore = cwd / ".gitignore"
48
+ if not gitignore.exists():
49
+ gitignore.write_text("__pycache__/\nnode_modules/\n.venv/\ndist/\nbuild/\n", encoding="utf-8")
50
+ return True
51
+
52
+
53
+ def commit_all(cwd: Path, message: str) -> str | None:
54
+ if not (cwd / ".git").exists():
55
+ return None
56
+ _run(["add", "-A"], cwd)
57
+ status = _run(["status", "--porcelain"], cwd)
58
+ if not status.stdout.strip():
59
+ # Nothing changed; still allow an (empty) commit so every version has one.
60
+ result = _run(["commit", "--allow-empty", "-m", message], cwd)
61
+ else:
62
+ result = _run(["commit", "-m", message], cwd)
63
+ if result.returncode != 0:
64
+ return None
65
+ rev = _run(["rev-parse", "--short", "HEAD"], cwd)
66
+ return rev.stdout.strip() or None
67
+
68
+
69
+ def tag(cwd: Path, name: str, message: str = "") -> bool:
70
+ if not (cwd / ".git").exists():
71
+ return False
72
+ _run(["tag", "-d", name], cwd) # replace if exists
73
+ result = _run(["tag", "-a", name, "-m", message or name], cwd)
74
+ return result.returncode == 0