33from __future__ import annotations
44
55import json
6+ import subprocess
67from datetime import datetime
78from pathlib import Path
89from typing import Optional
1213from rich .table import Table
1314
1415from 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
17138def 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