pushpilot 1.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.
autopush/cli.py ADDED
@@ -0,0 +1,316 @@
1
+ """
2
+ Autopush Command-Line Interface.
3
+ Professional multi-language & multi-framework Git automation.
4
+ """
5
+
6
+ import argparse
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ # Ensure UTF-8 console output on Windows
11
+ if hasattr(sys.stdout, "reconfigure"):
12
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
13
+ if hasattr(sys.stderr, "reconfigure"):
14
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
15
+
16
+ from autopush import __version__
17
+ from autopush.commit_gen import generate_commit_message
18
+ from autopush.detector import StackDetector
19
+ from autopush.git_ops import (
20
+ GitError,
21
+ commit,
22
+ ensure_gitignore,
23
+ ensure_repo_ready,
24
+ get_current_branch,
25
+ get_git_diff_summary,
26
+ get_git_status_changes,
27
+ get_remotes,
28
+ has_unpushed_commits,
29
+ is_git_repo,
30
+ push,
31
+ setup_remote_interactive,
32
+ stage_all,
33
+ )
34
+ from autopush.watcher import FeatureWatcher
35
+
36
+
37
+ def banner():
38
+ print(
39
+ f"""
40
+ ╔═════════════════════════════════════════════════════════════╗
41
+ ║ PUSHPILOT v{__version__} ║
42
+ ║ Intelligent Multi-Framework Git & GitHub Autopilot ║
43
+ ╚═════════════════════════════════════════════════════════════╝
44
+ """
45
+ )
46
+
47
+
48
+ def run_single_sync(
49
+ root: Path,
50
+ repo_url: str | None = None,
51
+ message: str | None = None,
52
+ branch: str | None = None,
53
+ remote: str = "origin",
54
+ dry_run: bool = False,
55
+ interactive: bool = True,
56
+ allow_ai: bool = True,
57
+ ) -> bool:
58
+ """Performs a full automated check, stage, commit, and push cycle."""
59
+ # Disable interactive prompts if dry_run or stdin is not a terminal
60
+ if dry_run or not sys.stdin.isatty():
61
+ interactive = False
62
+
63
+ # 1. Detect Stack
64
+ stack = StackDetector.detect(root)
65
+ print(f"[STACK] Detected: {stack.language} | Framework: {stack.framework or 'None'} | Ecosystem: {stack.ecosystem}")
66
+
67
+ # 2. Ensure Git & Remote Setup
68
+ ready, remote_was_just_linked = ensure_repo_ready(root, repo_url=repo_url, interactive=interactive)
69
+
70
+ # 3. Ensure .gitignore
71
+ ensure_gitignore(root, stack, interactive=interactive)
72
+
73
+ # Active branch and remotes
74
+ active_branch = branch or get_current_branch(root)
75
+ remotes = get_remotes(root)
76
+
77
+ print(f"[BRANCH] Active Branch : {active_branch}")
78
+ if remote in remotes:
79
+ print(f"[REMOTE] Target Remote : {remote} ({remotes[remote]})")
80
+ else:
81
+ print(f"[REMOTE] Warning: Remote '{remote}' is not configured.")
82
+
83
+ # 4. Check Changes
84
+ changes = get_git_status_changes(root)
85
+ if not changes:
86
+ remotes = get_remotes(root)
87
+ if remote in remotes and (remote_was_just_linked or has_unpushed_commits(root, remote, active_branch)):
88
+ print("[INFO] Working tree clean, but unpushed commits exist. Pushing to GitHub...")
89
+ if not dry_run:
90
+ try:
91
+ push(root, remote, active_branch)
92
+ print(f"\n[SUCCESS] Pushed all commits to GitHub ({remote}/{active_branch}) successfully!")
93
+ except GitError as e:
94
+ print(f"\n[ERROR] Push failed: {e}")
95
+ return False
96
+ else:
97
+ print(f"[DRY-RUN] Would push to {remote}/{active_branch}")
98
+ return True
99
+ else:
100
+ print("[INFO] Working tree clean and up to date with remote. Nothing to push.")
101
+ return True
102
+
103
+ print(f"[CHANGES] Detected {len(changes)} modified/new file(s):")
104
+ for status, file_path in changes[:8]:
105
+ print(f" [{status or '?'}] {file_path}")
106
+ if len(changes) > 8:
107
+ print(f" ... and {len(changes) - 8} more file(s)")
108
+
109
+ # 5. Generate Commit Message
110
+ diff_summary = get_git_diff_summary(root)
111
+ commit_msg, ai_source = generate_commit_message(
112
+ changes,
113
+ stack,
114
+ custom_msg=message,
115
+ root=root,
116
+ diff_summary=diff_summary,
117
+ allow_ai=allow_ai,
118
+ )
119
+ if ai_source:
120
+ print(f"[AI] Detected coding with AI: {ai_source}")
121
+ print(f"[COMMIT] AI-Generated Message: \"{commit_msg}\"")
122
+ else:
123
+ print(f"[COMMIT] Generated Message: \"{commit_msg}\"")
124
+
125
+ if dry_run:
126
+ print(f"[DRY-RUN] Would stage all files ('git add -A')")
127
+ print(f"[DRY-RUN] Would commit with message: \"{commit_msg}\"")
128
+ if remote in remotes:
129
+ print(f"[DRY-RUN] Would push to {remote}/{active_branch}")
130
+ return True
131
+
132
+ # 6. Stage & Commit
133
+ print("[STAGING] Staging all files...")
134
+ stage_all(root)
135
+
136
+ print("[COMMITTING] Committing changes...")
137
+ commit(root, commit_msg)
138
+ print("[SUCCESS] Changes committed successfully.")
139
+
140
+ # 7. Push to Remote
141
+ remotes = get_remotes(root)
142
+ if remote not in remotes:
143
+ print(f"\n[NOTICE] Remote '{remote}' not linked yet.")
144
+ if interactive:
145
+ new_remote = setup_remote_interactive(root)
146
+ if not new_remote:
147
+ print("[INFO] Commit saved locally. Link a remote when you are ready to push.")
148
+ return True
149
+ else:
150
+ print(f"[WARNING] Remote '{remote}' not configured. Run 'auto-push <repo-url>' to link a GitHub repo.")
151
+ return True
152
+
153
+ print(f"[PUSHING] Pushing to GitHub ({remote}/{active_branch})...")
154
+ try:
155
+ push(root, remote, active_branch)
156
+ print(f"\n[SUCCESS] Feature pushed to GitHub ({remote}/{active_branch}) successfully!")
157
+ return True
158
+ except GitError as e:
159
+ print(f"\n[ERROR] Push failed: {e}")
160
+ return False
161
+
162
+
163
+ def main():
164
+ parser = argparse.ArgumentParser(
165
+ prog="pushpilot",
166
+ description="PushPilot - Intelligent Multi-Framework Git & GitHub Autopilot.",
167
+ )
168
+ parser.add_argument(
169
+ "repo_url",
170
+ nargs="?",
171
+ default=None,
172
+ help="Optional GitHub repository URL (e.g. https://github.com/user/repo.git). Automatically initializes git and links remote.",
173
+ )
174
+ parser.add_argument(
175
+ "-m", "--message",
176
+ type=str,
177
+ default=None,
178
+ help="Custom commit message (if omitted, conventional semantic message is generated).",
179
+ )
180
+ parser.add_argument(
181
+ "-w", "--watch",
182
+ action="store_true",
183
+ help="Enable Feature-Watcher mode: monitors files and auto-pushes when features are completed.",
184
+ )
185
+ parser.add_argument(
186
+ "--cooldown",
187
+ type=float,
188
+ default=8.0,
189
+ help="Feature completion settlement window in seconds for watch mode (default: 8.0s).",
190
+ )
191
+ parser.add_argument(
192
+ "-b", "--branch",
193
+ type=str,
194
+ default=None,
195
+ help="Target git branch (default: active branch).",
196
+ )
197
+ parser.add_argument(
198
+ "-r", "--remote",
199
+ type=str,
200
+ default="origin",
201
+ help="Git remote name (default: 'origin').",
202
+ )
203
+ parser.add_argument(
204
+ "--setup",
205
+ action="store_true",
206
+ help="Interactive GitHub repository setup / change remote URL.",
207
+ )
208
+ parser.add_argument(
209
+ "--detect",
210
+ action="store_true",
211
+ help="Inspect detected language, framework, ecosystem, and build tools for this project.",
212
+ )
213
+ parser.add_argument(
214
+ "--init-gitignore",
215
+ action="store_true",
216
+ help="Generate or reset a framework-tailored .gitignore file.",
217
+ )
218
+ parser.add_argument(
219
+ "--dry-run",
220
+ action="store_true",
221
+ help="Simulate actions without staging, committing, or pushing.",
222
+ )
223
+ parser.add_argument(
224
+ "--no-ai",
225
+ action="store_true",
226
+ help="Disable AI commit message generation and force default rule-based message.",
227
+ )
228
+ parser.add_argument(
229
+ "--ai",
230
+ action="store_true",
231
+ help="Explicitly enable AI commit message generation (auto-detected by default).",
232
+ )
233
+ parser.add_argument(
234
+ "-v", "--version",
235
+ action="version",
236
+ version=f"PushPilot v{__version__}",
237
+ )
238
+
239
+ args = parser.parse_args()
240
+ root = Path.cwd()
241
+ allow_ai = not args.no_ai
242
+
243
+ banner()
244
+
245
+ # --detect command
246
+ if args.detect:
247
+ stack = StackDetector.detect(root)
248
+ print("=" * 50)
249
+ print(" PROJECT STACK INSPECTION")
250
+ print("=" * 50)
251
+ print(f"Language : {stack.language}")
252
+ print(f"Framework : {stack.framework or 'None / Standard'}")
253
+ print(f"Ecosystem : {stack.ecosystem}")
254
+ print(f"Build Tool : {stack.build_tool or 'None detected'}")
255
+ print(f"Git Repo : {'Yes' if is_git_repo(root) else 'No (run auto-push to initialize)'}")
256
+ remotes = get_remotes(root) if is_git_repo(root) else {}
257
+ print(f"Remotes : {remotes or 'None'}")
258
+ return
259
+
260
+ # --init-gitignore command
261
+ if args.init_gitignore:
262
+ stack = StackDetector.detect(root)
263
+ ensure_gitignore(root, stack, interactive=False)
264
+ return
265
+
266
+ # --setup command
267
+ if args.setup:
268
+ ensure_repo_ready(root, repo_url=args.repo_url, interactive=False)
269
+ setup_remote_interactive(root)
270
+ return
271
+
272
+ # --watch mode
273
+ if args.watch:
274
+ stack = StackDetector.detect(root)
275
+ ensure_repo_ready(root, repo_url=args.repo_url, interactive=True)
276
+ ensure_gitignore(root, stack, interactive=True)
277
+
278
+ def on_feature_complete(changes):
279
+ run_single_sync(
280
+ root=root,
281
+ repo_url=args.repo_url,
282
+ message=args.message,
283
+ branch=args.branch,
284
+ remote=args.remote,
285
+ dry_run=args.dry_run,
286
+ interactive=False,
287
+ allow_ai=allow_ai,
288
+ )
289
+
290
+ watcher = FeatureWatcher(
291
+ root=root,
292
+ stack=stack,
293
+ on_feature_complete=on_feature_complete,
294
+ settle_window_sec=args.cooldown,
295
+ )
296
+ watcher.start()
297
+ return
298
+
299
+ # Default: One-command sync
300
+ success = run_single_sync(
301
+ root=root,
302
+ repo_url=args.repo_url,
303
+ message=args.message,
304
+ branch=args.branch,
305
+ remote=args.remote,
306
+ dry_run=args.dry_run,
307
+ interactive=True,
308
+ allow_ai=allow_ai,
309
+ )
310
+
311
+ if not success:
312
+ sys.exit(1)
313
+
314
+
315
+ if __name__ == "__main__":
316
+ main()
autopush/commit_gen.py ADDED
@@ -0,0 +1,144 @@
1
+ """
2
+ Conventional Commit Message Generator.
3
+ Generates human-readable, semantic, and professional commit messages:
4
+ 1. If the developer is coding with AI (Google Antigravity, Cursor, Aider,
5
+ Claude Code, Cline, or configured Gemini/OpenAI/Anthropic/Groq/Ollama APIs),
6
+ it leverages the internal AI context to produce an ultra-accurate commit message.
7
+ 2. If the developer is not coding with AI, it automatically falls back to the
8
+ default rule-based heuristic generator.
9
+ """
10
+
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from autopush.detector import ProjectStack, classify_file_role
15
+
16
+
17
+ def generate_default_commit_message(
18
+ changes: list[tuple[str, str]],
19
+ stack: ProjectStack,
20
+ ) -> str:
21
+ """Default rule-based conventional commit generator (used when not coding with AI)."""
22
+ if not changes:
23
+ return f"chore: sync project updates [{stack.language}]"
24
+
25
+ # Analyze change classifications
26
+ roles: dict[str, list[str]] = {}
27
+ actions = {"add": 0, "modify": 0, "delete": 0}
28
+
29
+ for status, file_path in changes:
30
+ if "?" in status or "A" in status:
31
+ actions["add"] += 1
32
+ elif "D" in status:
33
+ actions["delete"] += 1
34
+ else:
35
+ actions["modify"] += 1
36
+
37
+ role, comp = classify_file_role(file_path)
38
+ roles.setdefault(role, []).append(Path(file_path).name)
39
+
40
+ # Determine primary role
41
+ # Priority: auth > api > ai > model > ui > service > test > docs > config > core
42
+ priority_order = ["auth", "api", "ai", "model", "ui", "service", "test", "docs", "config", "core"]
43
+ primary_role = "core"
44
+ for r in priority_order:
45
+ if r in roles:
46
+ primary_role = r
47
+ break
48
+
49
+ # Determine Conventional Commit Prefix
50
+ if primary_role == "docs":
51
+ prefix = "docs"
52
+ elif primary_role == "test":
53
+ prefix = "test"
54
+ elif primary_role == "config":
55
+ prefix = "chore(deps)" if any("package" in f or "req" in f or "cargo" in f for f in roles[primary_role]) else "chore(config)"
56
+ elif actions["add"] > actions["modify"]:
57
+ prefix = f"feat({primary_role})"
58
+ elif actions["delete"] > (actions["add"] + actions["modify"]):
59
+ prefix = f"refactor({primary_role})"
60
+ else:
61
+ prefix = f"refactor({primary_role})" if actions["modify"] > 0 else f"feat({primary_role})"
62
+
63
+ # Construct descriptive subject
64
+ primary_files = roles.get(primary_role, [Path(changes[0][1]).name])
65
+ file_sample = primary_files[0]
66
+ stem_name = Path(file_sample).stem.replace("_", " ").replace("-", " ").title()
67
+
68
+ if primary_role == "docs":
69
+ subject = "update project documentation and guides"
70
+ elif primary_role == "config":
71
+ subject = "update configuration and dependencies"
72
+ elif primary_role == "test":
73
+ subject = f"add/update test suite for {stem_name}"
74
+ elif primary_role == "auth":
75
+ subject = f"implement authentication workflow ({stem_name})"
76
+ elif primary_role == "api":
77
+ subject = f"update API endpoints and handlers ({stem_name})"
78
+ elif primary_role == "model":
79
+ subject = f"update database schemas and data models ({stem_name})"
80
+ elif primary_role == "ui":
81
+ subject = f"update UI components and layouts ({stem_name})"
82
+ elif primary_role == "style":
83
+ subject = f"improve styling and visual presentation ({stem_name})"
84
+ elif primary_role == "ai":
85
+ subject = f"integrate AI-assisted features and enhancements ({stem_name})"
86
+ else:
87
+ if actions["add"] > 0 and actions["modify"] == 0:
88
+ subject = f"add new {stem_name} module"
89
+ elif actions["delete"] > 0 and actions["add"] == 0:
90
+ subject = f"clean up and remove {stem_name}"
91
+ else:
92
+ subject = f"update {stem_name} and related features"
93
+
94
+ # Context tag if framework is detected
95
+ fw_tag = f" in {stack.framework}" if stack.framework and stack.framework != "Generic Project" else ""
96
+
97
+ return f"{prefix}: {subject}{fw_tag}"
98
+
99
+
100
+ def generate_commit_message(
101
+ changes: list[tuple[str, str]],
102
+ stack: ProjectStack,
103
+ custom_msg: Optional[str] = None,
104
+ root: Optional[Path] = None,
105
+ diff_summary: Optional[str] = None,
106
+ allow_ai: bool = True,
107
+ ) -> tuple[str, Optional[str]]:
108
+ """
109
+ Generates a professional conventional commit message.
110
+
111
+ Workflow:
112
+ 1. If user provided a custom commit message (-m / --message), use it directly.
113
+ 2. If allow_ai is True, checks if the developer is coding with AI (Antigravity IDE,
114
+ Cursor, Aider, Claude Code, Cline, or configured Gemini/OpenAI/Anthropic/Groq/Ollama APIs).
115
+ If AI is detected, generates an accurate message using the internal AI context.
116
+ 3. If the developer is NOT coding with AI, seamlessly uses the default heuristic generator.
117
+
118
+ Returns:
119
+ (commit_message, ai_source_description_or_none)
120
+ """
121
+ if custom_msg and custom_msg.strip():
122
+ return custom_msg.strip(), None
123
+
124
+ if not changes:
125
+ return f"chore: sync project updates [{stack.language}]", None
126
+
127
+ # Check internal AI if enabled
128
+ if allow_ai:
129
+ try:
130
+ from autopush.ai_commit import generate_ai_commit_message
131
+ ai_msg, ai_source = generate_ai_commit_message(
132
+ changes=changes,
133
+ stack=stack,
134
+ diff_summary=diff_summary or "",
135
+ root=root or Path.cwd(),
136
+ )
137
+ if ai_msg:
138
+ return ai_msg, ai_source
139
+ except Exception:
140
+ pass
141
+
142
+ # Seamless fallback to default rule-based commit generator
143
+ default_msg = generate_default_commit_message(changes, stack)
144
+ return default_msg, None