remote-coder 0.4.1__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.
- app/__init__.py +3 -0
- app/admin/__init__.py +0 -0
- app/admin/advanced_settings.py +88 -0
- app/admin/database_browser.py +301 -0
- app/admin/router.py +528 -0
- app/admin/static/i18n.js +401 -0
- app/admin/static/icons/advanced.svg +8 -0
- app/admin/static/icons/database.svg +5 -0
- app/admin/static/icons/download.svg +3 -0
- app/admin/static/icons/home.svg +4 -0
- app/admin/static/icons/logs.svg +3 -0
- app/admin/static/icons/projects.svg +5 -0
- app/admin/static/summary.js +73 -0
- app/admin/templates/admin.html +511 -0
- app/admin/templates/advanced.html +635 -0
- app/admin/templates/database.html +880 -0
- app/admin/templates/logs.html +686 -0
- app/admin/templates/projects.html +878 -0
- app/ai/__init__.py +0 -0
- app/ai/base.py +129 -0
- app/ai/claude.py +20 -0
- app/ai/codex.py +34 -0
- app/ai/factory.py +27 -0
- app/ai/gemini.py +20 -0
- app/ai/model_catalog.py +47 -0
- app/ai/usage.py +134 -0
- app/cli.py +238 -0
- app/config.py +130 -0
- app/git/__init__.py +0 -0
- app/git/ai_commit.py +88 -0
- app/git/branch_naming.py +21 -0
- app/git/commit_message.py +279 -0
- app/git/service.py +669 -0
- app/jobs/__init__.py +0 -0
- app/jobs/manager.py +770 -0
- app/jobs/schemas.py +116 -0
- app/jobs/store.py +334 -0
- app/main.py +265 -0
- app/models.py +20 -0
- app/monitoring/__init__.py +10 -0
- app/monitoring/code.py +161 -0
- app/monitoring/events.py +33 -0
- app/monitoring/git.py +103 -0
- app/monitoring/log_buffer.py +245 -0
- app/monitoring/memory.py +19 -0
- app/monitoring/model.py +598 -0
- app/projects/__init__.py +19 -0
- app/projects/registry.py +384 -0
- app/security/__init__.py +0 -0
- app/security/auth.py +19 -0
- app/system_startup.py +34 -0
- app/telegram/__init__.py +0 -0
- app/telegram/bot_instances.py +67 -0
- app/telegram/commands/__init__.py +64 -0
- app/telegram/commands/base.py +222 -0
- app/telegram/commands/branch.py +366 -0
- app/telegram/commands/clear_stop.py +221 -0
- app/telegram/commands/fix.py +219 -0
- app/telegram/commands/model.py +93 -0
- app/telegram/commands/monitor.py +185 -0
- app/telegram/commands/registry.py +110 -0
- app/telegram/commands/status.py +243 -0
- app/telegram/commands/system.py +201 -0
- app/telegram/confirmations.py +36 -0
- app/telegram/conversation.py +789 -0
- app/telegram/i18n.py +742 -0
- app/telegram/model_preferences.py +53 -0
- app/telegram/notifier.py +387 -0
- app/telegram/parser.py +267 -0
- app/telegram/webhook.py +988 -0
- app/telegram/webhook_registration.py +172 -0
- app/tunnel.py +104 -0
- remote_coder-0.4.1.dist-info/METADATA +520 -0
- remote_coder-0.4.1.dist-info/RECORD +78 -0
- remote_coder-0.4.1.dist-info/WHEEL +5 -0
- remote_coder-0.4.1.dist-info/entry_points.txt +2 -0
- remote_coder-0.4.1.dist-info/licenses/LICENSE +201 -0
- remote_coder-0.4.1.dist-info/top_level.txt +1 -0
app/git/service.py
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import uuid
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from app.monitoring.events import EventLogger
|
|
10
|
+
|
|
11
|
+
_SAFE_BRANCH_TOKEN = re.compile(r"^[A-Za-z0-9/._-]+$")
|
|
12
|
+
|
|
13
|
+
_gitlog = EventLogger("app.git.service", "git.operation")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GitWorktreeService:
|
|
17
|
+
def __init__(self, base_dir: Path) -> None:
|
|
18
|
+
self._base_dir = base_dir
|
|
19
|
+
self._base_dir.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
|
|
21
|
+
def _run_git(self, cwd: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
|
|
22
|
+
return subprocess.run(
|
|
23
|
+
["git", *args],
|
|
24
|
+
cwd=cwd,
|
|
25
|
+
capture_output=True,
|
|
26
|
+
text=True,
|
|
27
|
+
check=False,
|
|
28
|
+
shell=False,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def _run_git_checked(
|
|
32
|
+
self, cwd: Path, args: list[str], error_prefix: str
|
|
33
|
+
) -> subprocess.CompletedProcess[str]:
|
|
34
|
+
result = self._run_git(cwd, args)
|
|
35
|
+
if result.returncode != 0:
|
|
36
|
+
raise RuntimeError(f"{error_prefix}: {result.stderr.strip()}")
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def _remote_branch_ref(remote: str, branch: str) -> str:
|
|
41
|
+
return f"{remote}/{branch}"
|
|
42
|
+
|
|
43
|
+
def resolve_integrate_branch(self, project_path: Path) -> str:
|
|
44
|
+
for candidate in ("main", "master"):
|
|
45
|
+
result = self._run_git(project_path, ["rev-parse", "--verify", candidate])
|
|
46
|
+
if result.returncode == 0:
|
|
47
|
+
return candidate
|
|
48
|
+
raise RuntimeError(
|
|
49
|
+
"No integration branch (main or master). The repository needs main or master."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def _add_worktree(
|
|
53
|
+
self,
|
|
54
|
+
project_path: Path,
|
|
55
|
+
job_id: str,
|
|
56
|
+
build_args: Callable[[Path], list[str]],
|
|
57
|
+
*,
|
|
58
|
+
log_label: str,
|
|
59
|
+
log_detail: str,
|
|
60
|
+
error_label: str,
|
|
61
|
+
worktree_base_dir: Path | None,
|
|
62
|
+
) -> Path:
|
|
63
|
+
base = worktree_base_dir if worktree_base_dir is not None else self._base_dir
|
|
64
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
worktree_path = base / job_id
|
|
66
|
+
_gitlog.info("%s start %s", log_label, log_detail, job_id=job_id)
|
|
67
|
+
result = self._run_git(project_path, build_args(worktree_path))
|
|
68
|
+
if result.returncode != 0:
|
|
69
|
+
_gitlog.warning(
|
|
70
|
+
"%s failed %s stderr_len=%d",
|
|
71
|
+
log_label,
|
|
72
|
+
log_detail,
|
|
73
|
+
len(result.stderr),
|
|
74
|
+
job_id=job_id,
|
|
75
|
+
)
|
|
76
|
+
raise RuntimeError(f"{error_label}: {result.stderr.strip()}")
|
|
77
|
+
_gitlog.info("%s ok %s", log_label, log_detail, job_id=job_id)
|
|
78
|
+
return worktree_path
|
|
79
|
+
|
|
80
|
+
def prepare_worktree(
|
|
81
|
+
self,
|
|
82
|
+
project_path: Path,
|
|
83
|
+
branch_name: str,
|
|
84
|
+
job_id: str,
|
|
85
|
+
worktree_base_dir: Path | None = None,
|
|
86
|
+
) -> Path:
|
|
87
|
+
return self._add_worktree(
|
|
88
|
+
project_path,
|
|
89
|
+
job_id,
|
|
90
|
+
lambda worktree_path: ["worktree", "add", "-b", branch_name, str(worktree_path)],
|
|
91
|
+
log_label="prepare_worktree",
|
|
92
|
+
log_detail=f"branch={branch_name}",
|
|
93
|
+
error_label="failed to create worktree",
|
|
94
|
+
worktree_base_dir=worktree_base_dir,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def prepare_detached_worktree(
|
|
98
|
+
self,
|
|
99
|
+
project_path: Path,
|
|
100
|
+
job_id: str,
|
|
101
|
+
worktree_base_dir: Path | None = None,
|
|
102
|
+
base_branch: str | None = None,
|
|
103
|
+
) -> Path:
|
|
104
|
+
ref = base_branch if base_branch is not None else "HEAD"
|
|
105
|
+
return self._add_worktree(
|
|
106
|
+
project_path,
|
|
107
|
+
job_id,
|
|
108
|
+
lambda worktree_path: ["worktree", "add", "--detach", str(worktree_path), ref],
|
|
109
|
+
log_label="prepare_detached_worktree",
|
|
110
|
+
log_detail=f"ref={ref}",
|
|
111
|
+
error_label="failed to create detached worktree",
|
|
112
|
+
worktree_base_dir=worktree_base_dir,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def prepare_branch_worktree(
|
|
116
|
+
self,
|
|
117
|
+
project_path: Path,
|
|
118
|
+
branch_name: str,
|
|
119
|
+
job_id: str,
|
|
120
|
+
worktree_base_dir: Path | None = None,
|
|
121
|
+
) -> Path:
|
|
122
|
+
return self._add_worktree(
|
|
123
|
+
project_path,
|
|
124
|
+
job_id,
|
|
125
|
+
lambda worktree_path: ["worktree", "add", str(worktree_path), branch_name],
|
|
126
|
+
log_label="prepare_branch_worktree",
|
|
127
|
+
log_detail=f"branch={branch_name}",
|
|
128
|
+
error_label="failed to create branch worktree",
|
|
129
|
+
worktree_base_dir=worktree_base_dir,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def ensure_worktree_writable(worktree_path: Path) -> None:
|
|
134
|
+
probe = worktree_path / ".remote_coder_write_probe"
|
|
135
|
+
try:
|
|
136
|
+
probe.write_text("ok", encoding="utf-8")
|
|
137
|
+
probe.unlink(missing_ok=True)
|
|
138
|
+
except OSError as exc:
|
|
139
|
+
raise RuntimeError(f"worktree is not writable: {worktree_path} ({exc})") from exc
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def validate_branch_token(name: str) -> str | None:
|
|
143
|
+
if not name or len(name) > 255:
|
|
144
|
+
return "Branch name is empty or too long."
|
|
145
|
+
if ".." in name or name.startswith("-"):
|
|
146
|
+
return "Branch name is not allowed."
|
|
147
|
+
if not _SAFE_BRANCH_TOKEN.match(name):
|
|
148
|
+
return "Branch names may only use letters, numbers, /, ., _, and -."
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
def get_current_branch(self, project_path: Path) -> str:
|
|
152
|
+
"""Return the checked-out local branch name, or a detached HEAD label."""
|
|
153
|
+
result = self._run_git(project_path, ["branch", "--show-current"])
|
|
154
|
+
if result.returncode != 0:
|
|
155
|
+
raise RuntimeError(f"failed to read current branch: {result.stderr.strip()}")
|
|
156
|
+
name = result.stdout.strip()
|
|
157
|
+
if name:
|
|
158
|
+
return name
|
|
159
|
+
return "(detached HEAD - no branch name)"
|
|
160
|
+
|
|
161
|
+
def local_branch_exists(self, project_path: Path, branch: str) -> bool:
|
|
162
|
+
result = self._run_git(project_path, ["show-ref", "--verify", f"refs/heads/{branch}"])
|
|
163
|
+
_gitlog.info("local_branch_exists branch=%s exists=%s", branch, result.returncode == 0)
|
|
164
|
+
return result.returncode == 0
|
|
165
|
+
|
|
166
|
+
def switch_branch(self, project_path: Path, branch: str) -> None:
|
|
167
|
+
if not self.local_branch_exists(project_path, branch):
|
|
168
|
+
raise RuntimeError(f"No local branch: {branch}")
|
|
169
|
+
result = self._run_git(project_path, ["switch", branch])
|
|
170
|
+
if result.returncode != 0:
|
|
171
|
+
raise RuntimeError(f"git switch failed: {result.stderr.strip()}")
|
|
172
|
+
|
|
173
|
+
def create_branch_in_worktree(self, worktree_path: Path, branch_name: str) -> None:
|
|
174
|
+
_gitlog.info("create_branch_in_worktree start branch=%s", branch_name)
|
|
175
|
+
result = self._run_git(worktree_path, ["switch", "-c", branch_name])
|
|
176
|
+
if result.returncode != 0:
|
|
177
|
+
_gitlog.warning("create_branch_in_worktree failed branch=%s stderr_len=%d", branch_name, len(result.stderr))
|
|
178
|
+
raise RuntimeError(f"failed to create branch in worktree: {result.stderr.strip()}")
|
|
179
|
+
_gitlog.info("create_branch_in_worktree ok branch=%s", branch_name)
|
|
180
|
+
|
|
181
|
+
def find_linked_worktree_for_branch(self, project_path: Path, branch_name: str) -> Path | None:
|
|
182
|
+
_gitlog.info("find_linked_worktree_for_branch start branch=%s", branch_name)
|
|
183
|
+
result = self._run_git(project_path, ["worktree", "list", "--porcelain"])
|
|
184
|
+
if result.returncode != 0:
|
|
185
|
+
_gitlog.warning(
|
|
186
|
+
"find_linked_worktree_for_branch failed branch=%s stderr_len=%d",
|
|
187
|
+
branch_name,
|
|
188
|
+
len(result.stderr),
|
|
189
|
+
)
|
|
190
|
+
raise RuntimeError(f"failed to list worktrees: {result.stderr.strip()}")
|
|
191
|
+
root = project_path.resolve()
|
|
192
|
+
for worktree_path, branch in self._parse_worktree_list_porcelain(result.stdout):
|
|
193
|
+
if branch != branch_name:
|
|
194
|
+
continue
|
|
195
|
+
if worktree_path.resolve() == root:
|
|
196
|
+
continue
|
|
197
|
+
_gitlog.info("find_linked_worktree_for_branch hit branch=%s", branch_name)
|
|
198
|
+
return worktree_path
|
|
199
|
+
_gitlog.info("find_linked_worktree_for_branch miss branch=%s", branch_name)
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
def branch_is_checked_out(self, project_path: Path, branch_name: str) -> bool:
|
|
203
|
+
_gitlog.info("branch_is_checked_out start branch=%s", branch_name)
|
|
204
|
+
result = self._run_git(project_path, ["worktree", "list", "--porcelain"])
|
|
205
|
+
if result.returncode != 0:
|
|
206
|
+
_gitlog.warning(
|
|
207
|
+
"branch_is_checked_out failed branch=%s stderr_len=%d",
|
|
208
|
+
branch_name,
|
|
209
|
+
len(result.stderr),
|
|
210
|
+
)
|
|
211
|
+
raise RuntimeError(f"failed to list worktrees: {result.stderr.strip()}")
|
|
212
|
+
checked_out = any(branch == branch_name for _, branch in self._parse_worktree_list_porcelain(result.stdout))
|
|
213
|
+
_gitlog.info("branch_is_checked_out branch=%s checked_out=%s", branch_name, checked_out)
|
|
214
|
+
return checked_out
|
|
215
|
+
|
|
216
|
+
def collect_changes(self, worktree_path: Path) -> list[str]:
|
|
217
|
+
result = self._run_git(worktree_path, ["status", "--porcelain"])
|
|
218
|
+
if result.returncode != 0:
|
|
219
|
+
_gitlog.warning("collect_changes failed stderr_len=%d", len(result.stderr))
|
|
220
|
+
raise RuntimeError(f"failed to collect changes: {result.stderr.strip()}")
|
|
221
|
+
files: list[str] = []
|
|
222
|
+
for line in result.stdout.splitlines():
|
|
223
|
+
if len(line) > 3:
|
|
224
|
+
files.append(line[3:].strip())
|
|
225
|
+
_gitlog.info("collect_changes count=%d", len(files))
|
|
226
|
+
return files
|
|
227
|
+
|
|
228
|
+
def commit_all(self, worktree_path: Path, message: str) -> str | None:
|
|
229
|
+
_gitlog.info("commit_all start message_len=%d", len(message))
|
|
230
|
+
add_result = self._run_git(worktree_path, ["add", "."])
|
|
231
|
+
if add_result.returncode != 0:
|
|
232
|
+
_gitlog.warning("commit_all stage failed stderr_len=%d", len(add_result.stderr))
|
|
233
|
+
raise RuntimeError(f"failed to stage changes: {add_result.stderr.strip()}")
|
|
234
|
+
diff_result = self._run_git(worktree_path, ["diff", "--cached", "--name-only"])
|
|
235
|
+
if diff_result.returncode != 0:
|
|
236
|
+
_gitlog.warning("commit_all inspect staged failed stderr_len=%d", len(diff_result.stderr))
|
|
237
|
+
raise RuntimeError(f"failed to inspect staged files: {diff_result.stderr.strip()}")
|
|
238
|
+
if not diff_result.stdout.strip():
|
|
239
|
+
_gitlog.info("commit_all skipped no staged files")
|
|
240
|
+
return None
|
|
241
|
+
staged_count = len([ln for ln in diff_result.stdout.splitlines() if ln.strip()])
|
|
242
|
+
_gitlog.info("commit_all staged_count=%d", staged_count)
|
|
243
|
+
commit_result = self._run_git(worktree_path, ["commit", "-m", message])
|
|
244
|
+
if commit_result.returncode != 0:
|
|
245
|
+
_gitlog.warning("commit_all commit failed stderr_len=%d", len(commit_result.stderr))
|
|
246
|
+
raise RuntimeError(f"failed to commit: {commit_result.stderr.strip()}")
|
|
247
|
+
hash_result = self._run_git(worktree_path, ["rev-parse", "--short", "HEAD"])
|
|
248
|
+
if hash_result.returncode != 0:
|
|
249
|
+
_gitlog.warning("commit_all hash failed stderr_len=%d", len(hash_result.stderr))
|
|
250
|
+
raise RuntimeError(f"failed to resolve commit hash: {hash_result.stderr.strip()}")
|
|
251
|
+
short_hash = hash_result.stdout.strip()
|
|
252
|
+
_gitlog.info("commit_all ok hash=%s", short_hash)
|
|
253
|
+
return short_hash
|
|
254
|
+
|
|
255
|
+
def push_branch(self, project_path: Path, remote: str, branch: str) -> None:
|
|
256
|
+
_gitlog.info("push_branch start remote=%s branch=%s", remote, branch)
|
|
257
|
+
result = self._run_git(project_path, ["push", "-u", remote, branch])
|
|
258
|
+
if result.returncode != 0:
|
|
259
|
+
_gitlog.warning("push_branch failed remote=%s branch=%s stderr_len=%d", remote, branch, len(result.stderr))
|
|
260
|
+
raise RuntimeError(f"git push failed: {result.stderr.strip()}")
|
|
261
|
+
_gitlog.info("push_branch ok remote=%s branch=%s", remote, branch)
|
|
262
|
+
|
|
263
|
+
def amend_commit(self, worktree_path: Path, message: str) -> str:
|
|
264
|
+
_gitlog.info("amend_commit start message_len=%d", len(message))
|
|
265
|
+
add_result = self._run_git(worktree_path, ["add", "."])
|
|
266
|
+
if add_result.returncode != 0:
|
|
267
|
+
_gitlog.warning("amend_commit stage failed stderr_len=%d", len(add_result.stderr))
|
|
268
|
+
raise RuntimeError(f"failed to stage changes: {add_result.stderr.strip()}")
|
|
269
|
+
commit_result = self._run_git(
|
|
270
|
+
worktree_path,
|
|
271
|
+
["commit", "--amend", "--allow-empty", "-m", message],
|
|
272
|
+
)
|
|
273
|
+
if commit_result.returncode != 0:
|
|
274
|
+
_gitlog.warning("amend_commit failed stderr_len=%d", len(commit_result.stderr))
|
|
275
|
+
raise RuntimeError(f"failed to amend commit: {commit_result.stderr.strip()}")
|
|
276
|
+
hash_result = self._run_git(worktree_path, ["rev-parse", "--short", "HEAD"])
|
|
277
|
+
if hash_result.returncode != 0:
|
|
278
|
+
_gitlog.warning("amend_commit hash failed stderr_len=%d", len(hash_result.stderr))
|
|
279
|
+
raise RuntimeError(f"failed to resolve commit hash: {hash_result.stderr.strip()}")
|
|
280
|
+
short_hash = hash_result.stdout.strip()
|
|
281
|
+
_gitlog.info("amend_commit ok hash=%s", short_hash)
|
|
282
|
+
return short_hash
|
|
283
|
+
|
|
284
|
+
def push_branch_force_with_lease(self, project_path: Path, remote: str, branch: str) -> None:
|
|
285
|
+
_gitlog.info("push_branch_force_with_lease start remote=%s branch=%s", remote, branch)
|
|
286
|
+
result = self._run_git(
|
|
287
|
+
project_path,
|
|
288
|
+
["push", "--force-with-lease", remote, branch],
|
|
289
|
+
)
|
|
290
|
+
if result.returncode != 0:
|
|
291
|
+
_gitlog.warning(
|
|
292
|
+
"push_branch_force_with_lease failed remote=%s branch=%s stderr_len=%d",
|
|
293
|
+
remote,
|
|
294
|
+
branch,
|
|
295
|
+
len(result.stderr),
|
|
296
|
+
)
|
|
297
|
+
raise RuntimeError(f"git push --force-with-lease failed: {result.stderr.strip()}")
|
|
298
|
+
_gitlog.info("push_branch_force_with_lease ok remote=%s branch=%s", remote, branch)
|
|
299
|
+
|
|
300
|
+
def cleanup_worktree(self, project_path: Path, worktree_path: Path) -> None:
|
|
301
|
+
_gitlog.info("cleanup_worktree start worktree=%s", worktree_path.name)
|
|
302
|
+
result = self._run_git(project_path, ["worktree", "remove", "--force", str(worktree_path)])
|
|
303
|
+
if result.returncode != 0:
|
|
304
|
+
_gitlog.warning("cleanup_worktree failed worktree=%s stderr_len=%d", worktree_path.name, len(result.stderr))
|
|
305
|
+
raise RuntimeError(f"failed to cleanup worktree: {result.stderr.strip()}")
|
|
306
|
+
_gitlog.info("cleanup_worktree ok worktree=%s", worktree_path.name)
|
|
307
|
+
|
|
308
|
+
def checkout_integrate_branch(self, project_path: Path) -> str:
|
|
309
|
+
name = self.resolve_integrate_branch(project_path)
|
|
310
|
+
result = self._run_git(project_path, ["checkout", name])
|
|
311
|
+
if result.returncode != 0:
|
|
312
|
+
raise RuntimeError(f"checkout {name} failed: {result.stderr.strip()}")
|
|
313
|
+
return name
|
|
314
|
+
|
|
315
|
+
def format_local_branches(self, project_path: Path) -> str:
|
|
316
|
+
result = self._run_git(project_path, ["branch", "--sort=refname"])
|
|
317
|
+
if result.returncode != 0:
|
|
318
|
+
raise RuntimeError(f"failed to list local branches: {result.stderr.strip()}")
|
|
319
|
+
text = result.stdout.strip()
|
|
320
|
+
return text if text else "(no local branches)"
|
|
321
|
+
|
|
322
|
+
def list_local_branches(self, project_path: Path) -> list[str]:
|
|
323
|
+
result = self._run_git(project_path, ["branch", "--sort=refname"])
|
|
324
|
+
if result.returncode != 0:
|
|
325
|
+
raise RuntimeError(f"failed to list local branches: {result.stderr.strip()}")
|
|
326
|
+
branches: list[str] = []
|
|
327
|
+
for line in result.stdout.splitlines():
|
|
328
|
+
name = self._branch_name_from_git_branch_output_line(line)
|
|
329
|
+
if name:
|
|
330
|
+
branches.append(name)
|
|
331
|
+
return sorted(set(branches))
|
|
332
|
+
|
|
333
|
+
def format_remote_branches_for_remote(self, project_path: Path, remote: str) -> str:
|
|
334
|
+
result = self._run_git(project_path, ["branch", "-r", "--sort=refname"])
|
|
335
|
+
if result.returncode != 0:
|
|
336
|
+
raise RuntimeError(f"failed to list remote branches: {result.stderr.strip()}")
|
|
337
|
+
prefix = f"{remote}/"
|
|
338
|
+
lines: list[str] = []
|
|
339
|
+
for raw in result.stdout.splitlines():
|
|
340
|
+
line = raw.strip()
|
|
341
|
+
if not line or "->" in line:
|
|
342
|
+
continue
|
|
343
|
+
if line.startswith(prefix):
|
|
344
|
+
rest = line[len(prefix) :]
|
|
345
|
+
if rest == "HEAD":
|
|
346
|
+
continue
|
|
347
|
+
lines.append(line)
|
|
348
|
+
return "\n".join(lines) if lines else f"(no remote branches on {remote})"
|
|
349
|
+
|
|
350
|
+
def count_local_branches(self, project_path: Path) -> int:
|
|
351
|
+
result = self._run_git(project_path, ["branch", "--format=%(refname:short)"])
|
|
352
|
+
if result.returncode != 0:
|
|
353
|
+
raise RuntimeError(f"failed to count local branches: {result.stderr.strip()}")
|
|
354
|
+
return len([ln for ln in result.stdout.splitlines() if ln.strip()])
|
|
355
|
+
|
|
356
|
+
def count_remote_branches_for_remote(self, project_path: Path, remote: str) -> int:
|
|
357
|
+
result = self._run_git(project_path, ["branch", "-r", "--sort=refname"])
|
|
358
|
+
if result.returncode != 0:
|
|
359
|
+
raise RuntimeError(f"failed to count remote branches: {result.stderr.strip()}")
|
|
360
|
+
prefix = f"{remote}/"
|
|
361
|
+
n = 0
|
|
362
|
+
for raw in result.stdout.splitlines():
|
|
363
|
+
line = raw.strip()
|
|
364
|
+
if not line or "->" in line:
|
|
365
|
+
continue
|
|
366
|
+
if line.startswith(prefix):
|
|
367
|
+
rest = line[len(prefix) :]
|
|
368
|
+
if rest == "HEAD":
|
|
369
|
+
continue
|
|
370
|
+
n += 1
|
|
371
|
+
return n
|
|
372
|
+
|
|
373
|
+
def list_worktree_entries(self, project_path: Path) -> list[tuple[Path, str | None]]:
|
|
374
|
+
result = self._run_git(project_path, ["worktree", "list", "--porcelain"])
|
|
375
|
+
if result.returncode != 0:
|
|
376
|
+
raise RuntimeError(f"failed to list worktrees: {result.stderr.strip()}")
|
|
377
|
+
return self._parse_worktree_list_porcelain(result.stdout)
|
|
378
|
+
|
|
379
|
+
@staticmethod
|
|
380
|
+
def _branch_name_from_git_branch_output_line(line: str) -> str:
|
|
381
|
+
name = line.strip()
|
|
382
|
+
while name and name[0] in "+*":
|
|
383
|
+
name = name[1:].lstrip()
|
|
384
|
+
return name
|
|
385
|
+
|
|
386
|
+
def list_local_branches_matching(self, project_path: Path, prefix: str) -> list[str]:
|
|
387
|
+
result = self._run_git(project_path, ["branch", "--list", f"{prefix}*"])
|
|
388
|
+
if result.returncode != 0:
|
|
389
|
+
raise RuntimeError(f"failed to list branches: {result.stderr.strip()}")
|
|
390
|
+
branches: list[str] = []
|
|
391
|
+
for line in result.stdout.splitlines():
|
|
392
|
+
name = self._branch_name_from_git_branch_output_line(line)
|
|
393
|
+
if not name:
|
|
394
|
+
continue
|
|
395
|
+
if name.startswith(prefix):
|
|
396
|
+
branches.append(name)
|
|
397
|
+
return sorted(set(branches))
|
|
398
|
+
|
|
399
|
+
@staticmethod
|
|
400
|
+
def _parse_worktree_list_porcelain(stdout: str) -> list[tuple[Path, str | None]]:
|
|
401
|
+
entries: list[tuple[Path, str | None]] = []
|
|
402
|
+
cur_path: Path | None = None
|
|
403
|
+
cur_branch: str | None = None
|
|
404
|
+
for line in stdout.splitlines():
|
|
405
|
+
if line.startswith("worktree "):
|
|
406
|
+
if cur_path is not None:
|
|
407
|
+
entries.append((cur_path, cur_branch))
|
|
408
|
+
cur_path = Path(line[len("worktree ") :].strip())
|
|
409
|
+
cur_branch = None
|
|
410
|
+
elif line.startswith("branch "):
|
|
411
|
+
ref = line[len("branch ") :].strip()
|
|
412
|
+
if ref.startswith("refs/heads/"):
|
|
413
|
+
cur_branch = ref[len("refs/heads/") :]
|
|
414
|
+
else:
|
|
415
|
+
cur_branch = None
|
|
416
|
+
if cur_path is not None:
|
|
417
|
+
entries.append((cur_path, cur_branch))
|
|
418
|
+
return entries
|
|
419
|
+
|
|
420
|
+
def remove_linked_worktrees_for_branches(self, project_path: Path, branch_names: list[str]) -> None:
|
|
421
|
+
if not branch_names:
|
|
422
|
+
return
|
|
423
|
+
want = set(branch_names)
|
|
424
|
+
root = project_path.resolve()
|
|
425
|
+
result = self._run_git(project_path, ["worktree", "list", "--porcelain"])
|
|
426
|
+
if result.returncode != 0:
|
|
427
|
+
raise RuntimeError(f"failed to list worktrees: {result.stderr.strip()}")
|
|
428
|
+
for wt_path, branch in self._parse_worktree_list_porcelain(result.stdout):
|
|
429
|
+
if branch is None or branch not in want:
|
|
430
|
+
continue
|
|
431
|
+
if wt_path.resolve() == root:
|
|
432
|
+
continue
|
|
433
|
+
self.cleanup_worktree(project_path, wt_path)
|
|
434
|
+
|
|
435
|
+
@staticmethod
|
|
436
|
+
def _is_within(path: Path, base: Path) -> bool:
|
|
437
|
+
try:
|
|
438
|
+
path.relative_to(base)
|
|
439
|
+
except ValueError:
|
|
440
|
+
return False
|
|
441
|
+
return True
|
|
442
|
+
|
|
443
|
+
def cleanup_managed_worktrees(
|
|
444
|
+
self,
|
|
445
|
+
project_path: Path,
|
|
446
|
+
worktree_base_dir: Path,
|
|
447
|
+
branch_prefix: str = "remote-",
|
|
448
|
+
) -> int:
|
|
449
|
+
root = project_path.resolve()
|
|
450
|
+
managed_base = worktree_base_dir.resolve()
|
|
451
|
+
rebase_ops_base = (worktree_base_dir / "_rebase_ops").resolve()
|
|
452
|
+
|
|
453
|
+
listed = self._run_git(project_path, ["worktree", "list", "--porcelain"])
|
|
454
|
+
if listed.returncode != 0:
|
|
455
|
+
raise RuntimeError(f"failed to list worktrees: {listed.stderr.strip()}")
|
|
456
|
+
|
|
457
|
+
cleanup_targets: list[Path] = []
|
|
458
|
+
for wt_path, branch in self._parse_worktree_list_porcelain(listed.stdout):
|
|
459
|
+
resolved = wt_path.resolve()
|
|
460
|
+
if resolved == root:
|
|
461
|
+
continue
|
|
462
|
+
branch_matches = branch is not None and branch.startswith(branch_prefix)
|
|
463
|
+
under_managed_base = self._is_within(resolved, managed_base)
|
|
464
|
+
under_rebase_ops = self._is_within(resolved, rebase_ops_base)
|
|
465
|
+
if branch_matches or under_managed_base or under_rebase_ops:
|
|
466
|
+
cleanup_targets.append(resolved)
|
|
467
|
+
|
|
468
|
+
removed = 0
|
|
469
|
+
for target in sorted(set(cleanup_targets), key=lambda p: str(p)):
|
|
470
|
+
self.cleanup_worktree(project_path, target)
|
|
471
|
+
removed += 1
|
|
472
|
+
|
|
473
|
+
pruned = self._run_git(project_path, ["worktree", "prune"])
|
|
474
|
+
if pruned.returncode != 0:
|
|
475
|
+
raise RuntimeError(f"failed to prune worktrees: {pruned.stderr.strip()}")
|
|
476
|
+
return removed
|
|
477
|
+
|
|
478
|
+
def list_remote_branches_matching(self, project_path: Path, remote: str, prefix: str) -> list[str]:
|
|
479
|
+
result = self._run_git(project_path, ["ls-remote", "--heads", remote])
|
|
480
|
+
if result.returncode != 0:
|
|
481
|
+
raise RuntimeError(f"failed to list remote branches: {result.stderr.strip()}")
|
|
482
|
+
heads_prefix = "refs/heads/"
|
|
483
|
+
branches: list[str] = []
|
|
484
|
+
for raw in result.stdout.splitlines():
|
|
485
|
+
line = raw.strip()
|
|
486
|
+
if not line:
|
|
487
|
+
continue
|
|
488
|
+
parts = line.split()
|
|
489
|
+
if len(parts) < 2:
|
|
490
|
+
continue
|
|
491
|
+
ref = parts[1]
|
|
492
|
+
if not ref.startswith(heads_prefix):
|
|
493
|
+
continue
|
|
494
|
+
short = ref[len(heads_prefix) :]
|
|
495
|
+
if short == "HEAD" or not short.startswith(prefix):
|
|
496
|
+
continue
|
|
497
|
+
branches.append(short)
|
|
498
|
+
return sorted(set(branches))
|
|
499
|
+
|
|
500
|
+
def delete_local_branches(self, project_path: Path, branches: list[str]) -> None:
|
|
501
|
+
for name in branches:
|
|
502
|
+
result = self._run_git(project_path, ["branch", "-D", name])
|
|
503
|
+
if result.returncode != 0:
|
|
504
|
+
raise RuntimeError(f"failed to delete local branch {name}: {result.stderr.strip()}")
|
|
505
|
+
|
|
506
|
+
def delete_remote_branches(self, project_path: Path, remote: str, branches: list[str]) -> None:
|
|
507
|
+
for name in branches:
|
|
508
|
+
result = self._run_git(project_path, ["push", remote, "--delete", name])
|
|
509
|
+
if result.returncode != 0:
|
|
510
|
+
raise RuntimeError(f"failed to delete remote branch {name}: {result.stderr.strip()}")
|
|
511
|
+
|
|
512
|
+
def pull_repository(self, project_path: Path, remote: str) -> str:
|
|
513
|
+
_gitlog.info("pull_repository start remote=%s", remote)
|
|
514
|
+
|
|
515
|
+
fetch_res = self._run_git(project_path, ["fetch", remote, "--prune"])
|
|
516
|
+
if fetch_res.returncode != 0:
|
|
517
|
+
raise RuntimeError(f"git fetch {remote} failed: {fetch_res.stderr.strip()}")
|
|
518
|
+
|
|
519
|
+
current = self.get_current_branch(project_path)
|
|
520
|
+
if not current.startswith("("):
|
|
521
|
+
pull_res = self._run_git(project_path, ["pull", remote, current])
|
|
522
|
+
if pull_res.returncode != 0:
|
|
523
|
+
self._run_git(project_path, ["merge", "--abort"])
|
|
524
|
+
raise RuntimeError(f"git pull {remote} {current} failed (possible conflict): {pull_res.stderr.strip()}")
|
|
525
|
+
else:
|
|
526
|
+
_gitlog.info("pull_repository: detached HEAD, skipping pull for current branch")
|
|
527
|
+
|
|
528
|
+
wt_entries = self.list_worktree_entries(project_path)
|
|
529
|
+
local_branches = self.list_local_branches(project_path)
|
|
530
|
+
|
|
531
|
+
updated_count = 0
|
|
532
|
+
for branch in local_branches:
|
|
533
|
+
if branch == current:
|
|
534
|
+
continue
|
|
535
|
+
|
|
536
|
+
if any(b == branch for _, b in wt_entries):
|
|
537
|
+
continue
|
|
538
|
+
|
|
539
|
+
# `fetch remote b:b`는 로컬이 원격의 조상일 때만 성공하므로, 비-FF 분기는 자연스럽게 건너뜁니다.
|
|
540
|
+
ff_res = self._run_git(project_path, ["fetch", remote, f"{branch}:{branch}"])
|
|
541
|
+
if ff_res.returncode == 0:
|
|
542
|
+
updated_count += 1
|
|
543
|
+
|
|
544
|
+
summary = f"Fetched updates from remote {remote}."
|
|
545
|
+
if not current.startswith("("):
|
|
546
|
+
summary += f" Updated current branch ({current})."
|
|
547
|
+
if updated_count > 0:
|
|
548
|
+
summary += f" Fast-forward updated {updated_count} additional local branch(es)."
|
|
549
|
+
|
|
550
|
+
_gitlog.info("pull_repository done: %s", summary)
|
|
551
|
+
return summary
|
|
552
|
+
|
|
553
|
+
def rebase_branch_onto_main_and_merge(
|
|
554
|
+
self,
|
|
555
|
+
project_path: Path,
|
|
556
|
+
branch: str,
|
|
557
|
+
remote: str,
|
|
558
|
+
worktree_ops_base: Path,
|
|
559
|
+
) -> str:
|
|
560
|
+
_gitlog.info("rebase_branch_onto_main_and_merge start branch=%s", branch)
|
|
561
|
+
main_branch = self.resolve_integrate_branch(project_path)
|
|
562
|
+
self._run_git_checked(
|
|
563
|
+
project_path, ["fetch", remote, main_branch], f"git fetch {remote} {main_branch} failed"
|
|
564
|
+
)
|
|
565
|
+
self._run_git_checked(
|
|
566
|
+
project_path, ["fetch", remote, branch], f"git fetch {remote} {branch} failed"
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
self.remove_linked_worktrees_for_branches(project_path, [branch])
|
|
570
|
+
|
|
571
|
+
worktree_ops_base.mkdir(parents=True, exist_ok=True)
|
|
572
|
+
op_id = f"_rebase_{uuid.uuid4().hex[:8]}"
|
|
573
|
+
op_path = worktree_ops_base / op_id
|
|
574
|
+
|
|
575
|
+
self._run_git_checked(
|
|
576
|
+
project_path,
|
|
577
|
+
[
|
|
578
|
+
"worktree",
|
|
579
|
+
"add",
|
|
580
|
+
"-f",
|
|
581
|
+
"-B",
|
|
582
|
+
branch,
|
|
583
|
+
str(op_path),
|
|
584
|
+
self._remote_branch_ref(remote, branch),
|
|
585
|
+
"--track",
|
|
586
|
+
],
|
|
587
|
+
"worktree add for rebase failed",
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
try:
|
|
591
|
+
rb = self._run_git(op_path, ["rebase", self._remote_branch_ref(remote, main_branch)])
|
|
592
|
+
if rb.returncode != 0:
|
|
593
|
+
self._run_git(op_path, ["rebase", "--abort"])
|
|
594
|
+
raise RuntimeError(f"git rebase failed: {rb.stderr.strip()}")
|
|
595
|
+
|
|
596
|
+
self._run_git_checked(
|
|
597
|
+
op_path,
|
|
598
|
+
["push", "--force-with-lease", remote, branch],
|
|
599
|
+
"git push feature after rebase failed",
|
|
600
|
+
)
|
|
601
|
+
self._run_git_checked(
|
|
602
|
+
project_path, ["checkout", main_branch], f"checkout {main_branch} failed"
|
|
603
|
+
)
|
|
604
|
+
self._run_git_checked(
|
|
605
|
+
project_path,
|
|
606
|
+
["pull", "--ff-only", remote, main_branch],
|
|
607
|
+
f"git pull --ff-only {remote} {main_branch} failed",
|
|
608
|
+
)
|
|
609
|
+
self._run_git_checked(
|
|
610
|
+
project_path,
|
|
611
|
+
["merge", "--ff-only", branch],
|
|
612
|
+
f"fast-forward merge into {main_branch} failed (non-ff?)",
|
|
613
|
+
)
|
|
614
|
+
self._run_git_checked(
|
|
615
|
+
project_path, ["push", remote, main_branch], f"git push {remote} {main_branch} failed"
|
|
616
|
+
)
|
|
617
|
+
finally:
|
|
618
|
+
self._run_git_checked(
|
|
619
|
+
project_path,
|
|
620
|
+
["worktree", "remove", "--force", str(op_path)],
|
|
621
|
+
"failed to remove rebase worktree",
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
summary = (
|
|
625
|
+
f"Rebase complete: rebased `{branch}` onto `{remote}/{main_branch}`, "
|
|
626
|
+
f"fast-forward merged into `{main_branch}`, pushed to `{remote}`."
|
|
627
|
+
)
|
|
628
|
+
_gitlog.info("rebase_branch_onto_main_and_merge done branch=%s", branch)
|
|
629
|
+
return summary
|
|
630
|
+
|
|
631
|
+
def create_github_pr(
|
|
632
|
+
self,
|
|
633
|
+
project_path: Path,
|
|
634
|
+
branch: str,
|
|
635
|
+
base_branch: str,
|
|
636
|
+
title: str,
|
|
637
|
+
body: str,
|
|
638
|
+
) -> str:
|
|
639
|
+
_gitlog.info("create_github_pr branch=%s base=%s", branch, base_branch)
|
|
640
|
+
result = subprocess.run(
|
|
641
|
+
["gh", "pr", "create", "--base", base_branch, "--head", branch,
|
|
642
|
+
"--title", title, "--body", body],
|
|
643
|
+
cwd=project_path,
|
|
644
|
+
capture_output=True,
|
|
645
|
+
text=True,
|
|
646
|
+
check=False,
|
|
647
|
+
shell=False,
|
|
648
|
+
)
|
|
649
|
+
if result.returncode == 0:
|
|
650
|
+
url = result.stdout.strip()
|
|
651
|
+
_gitlog.info("create_github_pr created url=%s", url)
|
|
652
|
+
return url
|
|
653
|
+
stderr = result.stderr.strip()
|
|
654
|
+
stdout = result.stdout.strip()
|
|
655
|
+
combined = (stderr + stdout).lower()
|
|
656
|
+
if "already exists" in combined:
|
|
657
|
+
view = subprocess.run(
|
|
658
|
+
["gh", "pr", "view", branch, "--json", "url", "--jq", ".url"],
|
|
659
|
+
cwd=project_path,
|
|
660
|
+
capture_output=True,
|
|
661
|
+
text=True,
|
|
662
|
+
check=False,
|
|
663
|
+
shell=False,
|
|
664
|
+
)
|
|
665
|
+
if view.returncode == 0 and view.stdout.strip():
|
|
666
|
+
existing_url = view.stdout.strip()
|
|
667
|
+
_gitlog.info("create_github_pr already exists url=%s", existing_url)
|
|
668
|
+
return existing_url
|
|
669
|
+
raise RuntimeError(f"gh pr create failed: {stderr or stdout}")
|
app/jobs/__init__.py
ADDED
|
File without changes
|