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/__init__.py +6 -0
- autopush/ai_commit.py +483 -0
- autopush/cli.py +316 -0
- autopush/commit_gen.py +144 -0
- autopush/detector.py +342 -0
- autopush/git_ops.py +256 -0
- autopush/watcher.py +159 -0
- pushpilot-1.1.0.dist-info/METADATA +155 -0
- pushpilot-1.1.0.dist-info/RECORD +13 -0
- pushpilot-1.1.0.dist-info/WHEEL +5 -0
- pushpilot-1.1.0.dist-info/entry_points.txt +6 -0
- pushpilot-1.1.0.dist-info/licenses/LICENSE +23 -0
- pushpilot-1.1.0.dist-info/top_level.txt +1 -0
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}")
|