Skip to content

Commit f68a9d9

Browse files
Merge pull request #166 from Priivacy-ai/codex/2x-upgrade-auto-commit-project-dirs
feat(upgrade): auto-commit project-directory upgrade changes
2 parents 4b05e01 + 87391a6 commit f68a9d9

File tree

2 files changed

+742
-1
lines changed

2 files changed

+742
-1
lines changed

src/specify_cli/cli/commands/upgrade.py

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import json
6+
import subprocess
67
from datetime import datetime
78
from pathlib import Path
89
from typing import Optional
@@ -12,6 +13,126 @@
1213
from rich.table import Table
1314

1415
from specify_cli.cli.helpers import console, show_banner
16+
from specify_cli.git.commit_helpers import safe_commit
17+
18+
19+
def _git_status_paths(repo_path: Path) -> set[str] | None:
20+
"""Return git status paths for *repo_path* using porcelain -z output.
21+
22+
Returns ``None`` when ``git status`` fails (e.g. not a git repo) so
23+
callers can distinguish "no dirty files" from "unable to determine".
24+
"""
25+
result = subprocess.run(
26+
["git", "status", "--porcelain", "-z"],
27+
cwd=repo_path,
28+
capture_output=True,
29+
check=False,
30+
)
31+
if result.returncode != 0:
32+
return None
33+
34+
entries = result.stdout.decode("utf-8", errors="replace").split("\0")
35+
paths: set[str] = set()
36+
37+
i = 0
38+
while i < len(entries):
39+
entry = entries[i]
40+
i += 1
41+
if not entry or len(entry) < 4:
42+
continue
43+
44+
status = entry[:2]
45+
path = entry[3:]
46+
47+
# With -z format, renames/copies include a second NUL-separated
48+
# path. We take the *destination* (new name); the source (old name)
49+
# is intentionally discarded because we care about "what exists now".
50+
if "R" in status or "C" in status:
51+
if i < len(entries) and entries[i]:
52+
path = entries[i]
53+
i += 1
54+
55+
normalized = path.strip().replace("\\", "/")
56+
if normalized.startswith("./"):
57+
normalized = normalized[2:]
58+
59+
if normalized:
60+
paths.add(normalized)
61+
62+
return paths
63+
64+
65+
def _is_upgrade_commit_eligible(path: str, project_path: Path) -> bool:
66+
"""Return True when a changed file should be included in upgrade auto-commit."""
67+
normalized = path.strip().replace("\\", "/")
68+
if not normalized:
69+
return False
70+
71+
# Ignore paths that are outside the repo and root-level files.
72+
if normalized.startswith("../") or "/" not in normalized:
73+
return False
74+
75+
# Never auto-commit ~/.kittify when users run inside their home directory.
76+
if project_path.resolve() == Path.home().resolve() and normalized.startswith(".kittify/"):
77+
return False
78+
79+
return True
80+
81+
82+
def _prepare_upgrade_commit_files(
83+
project_path: Path,
84+
baseline_paths: set[str] | None,
85+
) -> list[Path]:
86+
"""Collect newly changed project-directory files after an upgrade run.
87+
88+
Returns an empty list when *baseline_paths* is ``None`` (git status
89+
failed at baseline time) to avoid accidentally committing unrelated work.
90+
"""
91+
if baseline_paths is None:
92+
return []
93+
94+
current_paths = _git_status_paths(project_path)
95+
if current_paths is None:
96+
return []
97+
98+
new_paths = sorted(
99+
path
100+
for path in current_paths
101+
if path not in baseline_paths and _is_upgrade_commit_eligible(path, project_path)
102+
)
103+
return [Path(path) for path in new_paths]
104+
105+
106+
def _auto_commit_upgrade_changes(
107+
project_path: Path,
108+
from_version: str,
109+
to_version: str,
110+
baseline_paths: set[str] | None,
111+
) -> tuple[bool, list[str], str | None]:
112+
"""Auto-commit newly introduced project-directory upgrade changes."""
113+
files_to_commit = _prepare_upgrade_commit_files(project_path, baseline_paths)
114+
if not files_to_commit:
115+
return False, [], None
116+
117+
commit_message = (
118+
f"chore: apply spec-kitty upgrade changes ({from_version} -> {to_version})"
119+
)
120+
commit_success = safe_commit(
121+
repo_path=project_path,
122+
files_to_commit=files_to_commit,
123+
commit_message=commit_message,
124+
allow_empty=False,
125+
)
126+
committed_paths = [str(path).replace("\\", "/") for path in files_to_commit]
127+
128+
if commit_success:
129+
return True, committed_paths, None
130+
131+
return (
132+
False,
133+
committed_paths,
134+
"Could not auto-commit upgrade changes; please review and commit manually.",
135+
)
15136

16137

17138
def upgrade(
@@ -44,6 +165,7 @@ def upgrade(
44165
show_banner()
45166

46167
project_path = Path.cwd()
168+
baseline_changed_paths = _git_status_paths(project_path)
47169
kittify_dir = project_path / ".kittify"
48170
specify_dir = project_path / ".specify" # Old name
49171

@@ -89,27 +211,49 @@ def upgrade(
89211
migrations_needed = MigrationRegistry.get_applicable(version_for_migration, target_version, project_path=project_path)
90212

91213
if not migrations_needed:
214+
auto_committed = False
215+
auto_commit_paths: list[str] = []
216+
auto_commit_warning: str | None = None
217+
92218
# Still stamp the version even when no migrations are needed
93219
from specify_cli.upgrade.metadata import ProjectMetadata
94220

95221
metadata = ProjectMetadata.load(kittify_dir)
96-
if metadata and metadata.version != target_version:
222+
if metadata and metadata.version != target_version and not dry_run:
97223
metadata.version = target_version
98224
metadata.last_upgraded_at = datetime.now()
99225
metadata.save(kittify_dir)
100226

227+
if not dry_run:
228+
auto_committed, auto_commit_paths, auto_commit_warning = _auto_commit_upgrade_changes(
229+
project_path=project_path,
230+
from_version=current_version,
231+
to_version=target_version,
232+
baseline_paths=baseline_changed_paths,
233+
)
234+
101235
if json_output:
236+
warnings = [auto_commit_warning] if auto_commit_warning else []
102237
console.print(
103238
json.dumps(
104239
{
105240
"status": "up_to_date",
106241
"current_version": current_version,
107242
"target_version": target_version,
243+
"auto_committed": auto_committed,
244+
"auto_commit_paths": auto_commit_paths,
245+
"warnings": warnings,
108246
}
109247
)
110248
)
111249
else:
112250
console.print("[green]Project is already up to date![/green]")
251+
if auto_committed:
252+
console.print(
253+
f"[cyan]→ Auto-committed upgrade changes ({len(auto_commit_paths)} files)[/cyan]"
254+
)
255+
if auto_commit_warning:
256+
console.print(f"[yellow]Warning:[/yellow] {auto_commit_warning}")
113257
return
114258

115259
# Show migration plan
@@ -162,6 +306,19 @@ def upgrade(
162306
include_worktrees=not no_worktrees,
163307
)
164308

309+
auto_committed = False
310+
auto_commit_paths: list[str] = []
311+
auto_commit_warning: str | None = None
312+
if result.success and not dry_run:
313+
auto_committed, auto_commit_paths, auto_commit_warning = _auto_commit_upgrade_changes(
314+
project_path=project_path,
315+
from_version=result.from_version,
316+
to_version=result.to_version,
317+
baseline_paths=baseline_changed_paths,
318+
)
319+
if auto_commit_warning:
320+
result.warnings.append(auto_commit_warning)
321+
165322
if json_output:
166323
# Build detailed migrations array
167324
migrations_detail = []
@@ -187,6 +344,8 @@ def upgrade(
187344
"success": result.success,
188345
"errors": result.errors,
189346
"warnings": result.warnings,
347+
"auto_committed": auto_committed,
348+
"auto_commit_paths": auto_commit_paths,
190349
}
191350
console.print(json.dumps(output, indent=2))
192351
return
@@ -227,6 +386,10 @@ def upgrade(
227386
console.print(
228387
f"[bold green]Upgrade complete![/bold green] {result.from_version} -> {result.to_version}"
229388
)
389+
if auto_committed:
390+
console.print(
391+
f"[cyan]→ Auto-committed upgrade changes ({len(auto_commit_paths)} files)[/cyan]"
392+
)
230393
else:
231394
console.print("[bold red]Upgrade failed.[/bold red]")
232395
raise typer.Exit(1)

0 commit comments

Comments
 (0)