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/detector.py ADDED
@@ -0,0 +1,342 @@
1
+ """
2
+ Language, Framework, and Stack Detection for Autopush.
3
+ Detects project stacks, component types, and supplies tailored .gitignore templates.
4
+ """
5
+
6
+ import json
7
+ from pathlib import Path
8
+ from typing import NamedTuple
9
+
10
+
11
+ class ProjectStack(NamedTuple):
12
+ language: str
13
+ framework: str | None
14
+ ecosystem: str
15
+ build_tool: str | None
16
+
17
+
18
+ class StackDetector:
19
+ """Detects programming languages, frameworks, and architecture patterns."""
20
+
21
+ @classmethod
22
+ def detect(cls, root: Path) -> ProjectStack:
23
+ # Check Node.js / TypeScript / JavaScript
24
+ pkg_json = root / "package.json"
25
+ if pkg_json.is_file():
26
+ try:
27
+ with open(pkg_json, "r", encoding="utf-8", errors="ignore") as f:
28
+ data = json.load(f)
29
+ deps = {
30
+ **data.get("dependencies", {}),
31
+ **data.get("devDependencies", {}),
32
+ }
33
+
34
+ framework = None
35
+ if "next" in deps:
36
+ framework = "Next.js"
37
+ elif "react" in deps and "react-native" in deps:
38
+ framework = "React Native"
39
+ elif "react" in deps:
40
+ framework = "React"
41
+ elif "vue" in deps or "nuxt" in deps:
42
+ framework = "Nuxt.js" if "nuxt" in deps else "Vue.js"
43
+ elif "@angular/core" in deps:
44
+ framework = "Angular"
45
+ elif "svelte" in deps or "@sveltejs/kit" in deps:
46
+ framework = "Svelte"
47
+ elif "@nestjs/core" in deps:
48
+ framework = "NestJS"
49
+ elif "express" in deps:
50
+ framework = "Express.js"
51
+ elif "vite" in deps:
52
+ framework = "Vite App"
53
+
54
+ is_ts = any(root.glob("tsconfig*.json")) or "typescript" in deps
55
+ lang = "TypeScript" if is_ts else "JavaScript"
56
+ return ProjectStack(
57
+ language=lang,
58
+ framework=framework or "Node.js",
59
+ ecosystem="npm/node",
60
+ build_tool="npm" if not (root / "yarn.lock").exists() else "yarn",
61
+ )
62
+ except Exception:
63
+ pass
64
+
65
+ # Check Python
66
+ py_files = list(root.glob("*.py"))
67
+ req_txt = root / "requirements.txt"
68
+ pyproject = root / "pyproject.toml"
69
+ pipfile = root / "Pipfile"
70
+
71
+ if py_files or req_txt.exists() or pyproject.exists() or pipfile.exists():
72
+ framework = None
73
+ # Check for Django
74
+ if (root / "manage.py").exists() or any(root.glob("*/settings.py")):
75
+ framework = "Django"
76
+ else:
77
+ # Check requirements or imports
78
+ req_content = ""
79
+ if req_txt.is_file():
80
+ req_content += req_txt.read_text(encoding="utf-8", errors="ignore")
81
+ if pyproject.is_file():
82
+ req_content += pyproject.read_text(encoding="utf-8", errors="ignore")
83
+
84
+ req_lower = req_content.lower()
85
+ if "fastapi" in req_lower:
86
+ framework = "FastAPI"
87
+ elif "flask" in req_lower:
88
+ framework = "Flask"
89
+ elif "torch" in req_lower or "tensorflow" in req_lower:
90
+ framework = "AI/Machine Learning"
91
+
92
+ return ProjectStack(
93
+ language="Python",
94
+ framework=framework or "Standard Python",
95
+ ecosystem="pip/python",
96
+ build_tool="poetry" if (root / "poetry.lock").exists() else "pip",
97
+ )
98
+
99
+ # Check Rust
100
+ if (root / "Cargo.toml").exists():
101
+ return ProjectStack(
102
+ language="Rust",
103
+ framework="Cargo App",
104
+ ecosystem="cargo",
105
+ build_tool="cargo",
106
+ )
107
+
108
+ # Check Go
109
+ if (root / "go.mod").exists() or list(root.glob("*.go")):
110
+ return ProjectStack(
111
+ language="Go",
112
+ framework="Go Module",
113
+ ecosystem="go",
114
+ build_tool="go",
115
+ )
116
+
117
+ # Check Flutter / Dart
118
+ if (root / "pubspec.yaml").exists():
119
+ content = (root / "pubspec.yaml").read_text(encoding="utf-8", errors="ignore")
120
+ is_flutter = "flutter:" in content
121
+ return ProjectStack(
122
+ language="Dart",
123
+ framework="Flutter" if is_flutter else "Dart Console",
124
+ ecosystem="pub",
125
+ build_tool="flutter" if is_flutter else "dart",
126
+ )
127
+
128
+ # Check Java / Kotlin
129
+ if (root / "pom.xml").exists():
130
+ return ProjectStack(
131
+ language="Java",
132
+ framework="Maven Project",
133
+ ecosystem="maven",
134
+ build_tool="mvn",
135
+ )
136
+ if (root / "build.gradle").exists() or (root / "build.gradle.kts").exists():
137
+ is_kotlin = (root / "build.gradle.kts").exists()
138
+ return ProjectStack(
139
+ language="Kotlin" if is_kotlin else "Java",
140
+ framework="Gradle Project",
141
+ ecosystem="gradle",
142
+ build_tool="gradle",
143
+ )
144
+
145
+ # Check C# / .NET
146
+ if list(root.glob("*.csproj")) or list(root.glob("*.sln")):
147
+ return ProjectStack(
148
+ language="C#",
149
+ framework=".NET Core / C#",
150
+ ecosystem="dotnet",
151
+ build_tool="dotnet",
152
+ )
153
+
154
+ # Check PHP
155
+ if (root / "composer.json").exists() or list(root.glob("*.php")):
156
+ framework = "Laravel" if (root / "artisan").exists() else "PHP App"
157
+ return ProjectStack(
158
+ language="PHP",
159
+ framework=framework,
160
+ ecosystem="composer",
161
+ build_tool="composer",
162
+ )
163
+
164
+ # Check Ruby
165
+ if (root / "Gemfile").exists():
166
+ framework = "Ruby on Rails" if (root / "bin" / "rails").exists() else "Ruby App"
167
+ return ProjectStack(
168
+ language="Ruby",
169
+ framework=framework,
170
+ ecosystem="rubygems",
171
+ build_tool="bundle",
172
+ )
173
+
174
+ # Default fallback
175
+ return ProjectStack(
176
+ language="Universal",
177
+ framework="Generic Project",
178
+ ecosystem="git",
179
+ build_tool=None,
180
+ )
181
+
182
+ @classmethod
183
+ def get_recommended_gitignore(cls, stack: ProjectStack) -> str:
184
+ """Returns recommended .gitignore rules tailored to the detected language and framework."""
185
+ common = """# OS & Editor temp files
186
+ .DS_Store
187
+ Thumbs.db
188
+ *.swp
189
+ *.bak
190
+ .idea/
191
+ .vscode/
192
+ *.log
193
+ .env
194
+ .env.local
195
+ .env.*.local
196
+ """
197
+ lang = stack.language.lower()
198
+ if "python" in lang:
199
+ return common + """
200
+ # Python
201
+ __pycache__/
202
+ *.py[cod]
203
+ *$py.class
204
+ *.so
205
+ .venv/
206
+ env/
207
+ venv/
208
+ ENV/
209
+ build/
210
+ dist/
211
+ *.egg-info/
212
+ .pytest_cache/
213
+ .coverage
214
+ htmlcov/
215
+ """
216
+ elif "javascript" in lang or "typescript" in lang or "node" in lang:
217
+ return common + """
218
+ # Node & Web
219
+ node_modules/
220
+ dist/
221
+ build/
222
+ .next/
223
+ out/
224
+ .nuxt/
225
+ .svelte-kit/
226
+ .turbo/
227
+ coverage/
228
+ *.tsbuildinfo
229
+ """
230
+ elif "rust" in lang:
231
+ return common + """
232
+ # Rust
233
+ target/
234
+ **/*.rs.bk
235
+ Cargo.lock
236
+ """
237
+ elif "go" in lang:
238
+ return common + """
239
+ # Go
240
+ bin/
241
+ pkg/
242
+ *.exe
243
+ *.test
244
+ vendor/
245
+ """
246
+ elif "dart" in lang:
247
+ return common + """
248
+ # Flutter & Dart
249
+ .dart_tool/
250
+ .packages
251
+ build/
252
+ .flutter-plugins
253
+ .flutter-plugins-dependencies
254
+ """
255
+ elif "java" in lang or "kotlin" in lang:
256
+ return common + """
257
+ # Java & Gradle / Maven
258
+ target/
259
+ build/
260
+ .gradle/
261
+ *.class
262
+ *.jar
263
+ *.war
264
+ """
265
+ elif "c#" in lang:
266
+ return common + """
267
+ # .NET
268
+ bin/
269
+ obj/
270
+ *.user
271
+ *.suo
272
+ """
273
+ elif "php" in lang:
274
+ return common + """
275
+ # PHP & Composer
276
+ vendor/
277
+ composer.phar
278
+ .phpunit.result.cache
279
+ """
280
+ return common
281
+
282
+
283
+ def classify_file_role(filepath: str) -> tuple[str, str]:
284
+ """Classifies a changed file into (category, component_name).
285
+ Returns category (e.g. 'ui', 'api', 'model', 'auth', 'test', 'docs', 'config')
286
+ and a clean component label.
287
+ """
288
+ path_obj = Path(filepath)
289
+ filename = path_obj.name.lower()
290
+ parts = [p.lower() for p in path_obj.parts]
291
+
292
+ # Documentation
293
+ if filename.endswith((".md", ".txt", ".rst")) or "doc" in parts or "docs" in parts:
294
+ return "docs", "documentation"
295
+
296
+ # Tests
297
+ if (
298
+ "test" in filename
299
+ or "spec" in filename
300
+ or "tests" in parts
301
+ or "test" in parts
302
+ or "__tests__" in parts
303
+ ):
304
+ stem = path_obj.stem.replace(".test", "").replace(".spec", "").replace("test_", "")
305
+ return "test", stem
306
+
307
+ # Configuration / Dependencies / Pipelines
308
+ if filename in (
309
+ "package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
310
+ "requirements.txt", "pyproject.toml", "pipfile", "pipfile.lock",
311
+ "cargo.toml", "cargo.lock", "go.mod", "go.sum", "pom.xml",
312
+ "build.gradle", "build.gradle.kts", "composer.json", "gemfile",
313
+ "dockerfile", "docker-compose.yml", "docker-compose.yaml",
314
+ ".gitignore", "tsconfig.json", "vite.config.ts", "vite.config.js"
315
+ ) or ".github" in parts:
316
+ return "config", path_obj.stem
317
+
318
+ # Authentication
319
+ if any(k in filename or k in parts for k in ("auth", "login", "register", "token", "session", "user", "jwt")):
320
+ return "auth", path_obj.stem
321
+
322
+ # API / Controllers / Routes
323
+ if any(k in filename or k in parts for k in ("api", "route", "controller", "endpoint", "handler", "viewset", "rpc")):
324
+ return "api", path_obj.stem
325
+
326
+ # Database / Models / Schemas
327
+ if any(k in filename or k in parts for k in ("model", "models", "schema", "entity", "migration", "migrations", "db")):
328
+ return "model", path_obj.stem
329
+
330
+ # Frontend Components & Views
331
+ if any(k in filename or k in parts for k in ("component", "components", "pages", "app", "views", "screens", "widgets")):
332
+ return "ui", path_obj.stem
333
+
334
+ # Styling
335
+ if filename.endswith((".css", ".scss", ".sass", ".less", ".tailwind")):
336
+ return "style", path_obj.stem
337
+
338
+ # Services / Utils / Helpers
339
+ if any(k in filename or k in parts for k in ("service", "services", "util", "utils", "helper", "helpers", "lib")):
340
+ return "service", path_obj.stem
341
+
342
+ return "core", path_obj.stem
autopush/git_ops.py ADDED
@@ -0,0 +1,256 @@
1
+ """
2
+ Git Operations Layer for Autopush.
3
+ Handles repository status, remote configuration, branch management,
4
+ staging, committing, and upstream pushing.
5
+ """
6
+
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+ from autopush.detector import ProjectStack, StackDetector
12
+
13
+
14
+ class GitError(Exception):
15
+ pass
16
+
17
+
18
+ def run_git(cmd: list[str], cwd: Path) -> tuple[int, str, str]:
19
+ """Execute git command and return (code, stdout, stderr)."""
20
+ try:
21
+ proc = subprocess.run(
22
+ ["git"] + cmd,
23
+ cwd=str(cwd),
24
+ stdout=subprocess.PIPE,
25
+ stderr=subprocess.PIPE,
26
+ text=True,
27
+ encoding="utf-8",
28
+ errors="replace",
29
+ )
30
+ return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
31
+ except FileNotFoundError:
32
+ raise GitError("Git is not installed or not in PATH.")
33
+ except Exception as e:
34
+ raise GitError(str(e))
35
+
36
+
37
+ def is_git_repo(root: Path) -> bool:
38
+ code, stdout, _ = run_git(["rev-parse", "--is-inside-work-tree"], cwd=root)
39
+ return code == 0 and stdout == "true"
40
+
41
+
42
+ def get_current_branch(root: Path) -> str:
43
+ code, stdout, _ = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=root)
44
+ if code == 0 and stdout and stdout != "HEAD":
45
+ return stdout
46
+ code, stdout, _ = run_git(["symbolic-ref", "--short", "HEAD"], cwd=root)
47
+ if code == 0 and stdout:
48
+ return stdout
49
+ return "main"
50
+
51
+
52
+ def get_remotes(root: Path) -> dict[str, str]:
53
+ """Returns mapping of remote name -> fetch URL."""
54
+ code, stdout, _ = run_git(["remote", "-v"], cwd=root)
55
+ if code != 0 or not stdout:
56
+ return {}
57
+ remotes = {}
58
+ for line in stdout.splitlines():
59
+ parts = line.split()
60
+ if len(parts) >= 2 and "(fetch)" in line:
61
+ remotes[parts[0]] = parts[1]
62
+ return remotes
63
+
64
+
65
+ def ensure_gitignore(root: Path, stack: ProjectStack, interactive: bool = True) -> bool:
66
+ """Checks if .gitignore exists. If missing, creates a framework-tailored .gitignore."""
67
+ gitignore_path = root / ".gitignore"
68
+ if gitignore_path.exists():
69
+ return True
70
+
71
+ print(f"\n[NOTICE] No .gitignore found in this {stack.language} ({stack.framework or 'project'}) repository.")
72
+ content = StackDetector.get_recommended_gitignore(stack)
73
+ try:
74
+ gitignore_path.write_text(content, encoding="utf-8")
75
+ print(f"[OK] Generated framework-specific .gitignore for {stack.language} ({stack.framework or 'General'}).")
76
+ return True
77
+ except Exception as e:
78
+ print(f"[WARNING] Could not create .gitignore: {e}")
79
+ return False
80
+
81
+
82
+ def setup_remote_interactive(root: Path) -> str | None:
83
+ """Prompts user to link a GitHub repository if none is configured."""
84
+ print("\n" + "=" * 60)
85
+ print(" GITHUB REPOSITORY SETUP")
86
+ print("=" * 60)
87
+ print("No GitHub remote repository is currently linked to this project.")
88
+ print("To auto-push, enter your GitHub repository URL.")
89
+ print("Examples:")
90
+ print(" - HTTPS: https://github.com/username/my-repo.git")
91
+ print(" - SSH: git@github.com:username/my-repo.git\n")
92
+
93
+ try:
94
+ repo_url = input("Enter GitHub repository URL (or press Enter to skip): ").strip()
95
+ except (EOFError, KeyboardInterrupt):
96
+ return None
97
+
98
+ if not repo_url:
99
+ print("[INFO] Skipped remote setup.")
100
+ return None
101
+
102
+ # Clean URL
103
+ repo_url = repo_url.strip("'\"")
104
+
105
+ # Add remote
106
+ code, _, stderr = run_git(["remote", "add", "origin", repo_url], cwd=root)
107
+ if code == 0:
108
+ print(f"[OK] Linked remote 'origin': {repo_url}")
109
+ # Standardize default branch to main if in initial state
110
+ run_git(["branch", "-M", "main"], cwd=root)
111
+ return repo_url
112
+ else:
113
+ # Check if origin already existed and update it
114
+ run_git(["remote", "set-url", "origin", repo_url], cwd=root)
115
+ print(f"[OK] Updated remote 'origin': {repo_url}")
116
+ return repo_url
117
+
118
+
119
+ def ensure_repo_ready(root: Path, repo_url: str | None = None, interactive: bool = True) -> tuple[bool, bool]:
120
+ """Ensures git is initialized and ready.
121
+ Returns (is_ready, remote_was_just_linked).
122
+ """
123
+ if not is_git_repo(root):
124
+ print(f"[INFO] Initializing new Git repository in: {root}")
125
+ code, stdout, stderr = run_git(["init"], cwd=root)
126
+ if code != 0:
127
+ raise GitError(f"Failed to initialize git repository: {stderr}")
128
+ run_git(["branch", "-M", "main"], cwd=root)
129
+ print("[OK] Git repository initialized with default branch 'main'.")
130
+
131
+ remotes = get_remotes(root)
132
+ remote_was_just_linked = False
133
+
134
+ if repo_url and repo_url.strip():
135
+ clean_url = repo_url.strip().strip("'\"")
136
+ if "origin" in remotes:
137
+ run_git(["remote", "set-url", "origin", clean_url], cwd=root)
138
+ print(f"[OK] Updated remote 'origin': {clean_url}")
139
+ else:
140
+ code, _, stderr = run_git(["remote", "add", "origin", clean_url], cwd=root)
141
+ if code != 0:
142
+ run_git(["remote", "set-url", "origin", clean_url], cwd=root)
143
+ print(f"[OK] Linked remote 'origin': {clean_url}")
144
+ run_git(["branch", "-M", "main"], cwd=root)
145
+ remote_was_just_linked = True
146
+ elif "origin" not in remotes and interactive:
147
+ linked_url = setup_remote_interactive(root)
148
+ if linked_url:
149
+ remote_was_just_linked = True
150
+
151
+ return True, remote_was_just_linked
152
+
153
+
154
+ def get_git_status_changes(root: Path) -> list[tuple[str, str]]:
155
+ """Returns list of (status, filename) from git status --porcelain."""
156
+ code, stdout, stderr = run_git(["status", "--porcelain"], cwd=root)
157
+ if code != 0 or not stdout:
158
+ return []
159
+
160
+ changes = []
161
+ for line in stdout.splitlines():
162
+ if len(line) >= 3:
163
+ status = line[:2].strip()
164
+ filename = line[2:].strip().strip('"')
165
+ if filename:
166
+ changes.append((status, filename))
167
+ return changes
168
+
169
+
170
+ def get_git_diff_summary(root: Path, max_chars: int = 3500) -> str:
171
+ """Returns a concise diff summary of staged, unstaged, and untracked changes."""
172
+ _, diff_unstaged, _ = run_git(["diff", "--no-color"], cwd=root)
173
+ _, diff_staged, _ = run_git(["diff", "--cached", "--no-color"], cwd=root)
174
+ _, status_out, _ = run_git(["status", "--short"], cwd=root)
175
+
176
+ combined = []
177
+ if status_out:
178
+ combined.append(f"Changed files:\n{status_out.strip()}\n")
179
+ if diff_staged:
180
+ combined.append(f"Staged changes:\n{diff_staged.strip()}\n")
181
+ if diff_unstaged:
182
+ combined.append(f"Working tree changes:\n{diff_unstaged.strip()}\n")
183
+
184
+ full_text = "\n".join(combined)
185
+ if len(full_text) > max_chars:
186
+ return full_text[:max_chars] + "\n... [diff truncated]"
187
+ return full_text
188
+
189
+
190
+ def has_unpushed_commits(root: Path, remote: str = "origin", branch: str = "main") -> bool:
191
+ """Check if local commits exist that are not on remote."""
192
+ # 1. Does local branch have any commits?
193
+ code, head_commit, _ = run_git(["rev-parse", "HEAD"], cwd=root)
194
+ if code != 0 or not head_commit:
195
+ return False # No commits in repository
196
+
197
+ # 2. Check if remote tracking branch exists locally
198
+ code, remote_commit, _ = run_git(["rev-parse", "--verify", f"{remote}/{branch}"], cwd=root)
199
+ if code != 0:
200
+ # Remote tracking branch does not exist locally (branch never pushed/fetched)
201
+ return True
202
+
203
+ # 3. If remote tracking branch exists, check if local is ahead
204
+ code, stdout, _ = run_git(["log", f"{remote}/{branch}..{branch}"], cwd=root)
205
+ return code == 0 and bool(stdout.strip())
206
+
207
+
208
+ def stage_all(root: Path) -> None:
209
+ code, _, stderr = run_git(["add", "-A"], cwd=root)
210
+ if code != 0:
211
+ raise GitError(f"Failed to stage changes: {stderr}")
212
+
213
+
214
+ def commit(root: Path, message: str) -> None:
215
+ code, stdout, stderr = run_git(["commit", "-m", message], cwd=root)
216
+ if code != 0:
217
+ raise GitError(f"Failed to commit: {stderr or stdout}")
218
+
219
+
220
+ def pull_rebase(root: Path, remote: str, branch: str) -> bool:
221
+ # Attempt 1: Standard pull rebase with autostash
222
+ code, stdout, stderr = run_git(["pull", "--rebase", "--autostash", remote, branch], cwd=root)
223
+ if code == 0:
224
+ return True
225
+
226
+ # Clean up any paused rebase attempt
227
+ run_git(["rebase", "--abort"], cwd=root)
228
+
229
+ # Attempt 2: Pull rebase preferring local project files on conflict (-X ours)
230
+ # This automatically resolves conflicts when GitHub initialized a default README or .gitignore
231
+ code, stdout, stderr = run_git(["pull", "--rebase", "-X", "ours", remote, branch], cwd=root)
232
+ if code == 0:
233
+ return True
234
+
235
+ # If still stuck, abort cleanly so user's work tree is preserved
236
+ run_git(["rebase", "--abort"], cwd=root)
237
+ return False
238
+
239
+
240
+ def push(root: Path, remote: str = "origin", branch: str = "main") -> bool:
241
+ """Push local branch to remote with upstream tracking."""
242
+ # First attempt: git push -u remote branch
243
+ code, stdout, stderr = run_git(["push", "-u", remote, branch], cwd=root)
244
+ if code == 0:
245
+ return True
246
+
247
+ err = (stderr or stdout).lower()
248
+ # If branch rejected because remote has commits not present locally:
249
+ if "fetch first" in err or "non-fast-forward" in err or "rejected" in err:
250
+ print("[INFO] Remote contains newer commits (e.g. GitHub initial commit). Syncing with local project...")
251
+ if pull_rebase(root, remote, branch):
252
+ code, stdout, stderr = run_git(["push", "-u", remote, branch], cwd=root)
253
+ if code == 0:
254
+ return True
255
+
256
+ raise GitError(f"Git push failed:\n{stderr or stdout}")