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.
Files changed (78) hide show
  1. app/__init__.py +3 -0
  2. app/admin/__init__.py +0 -0
  3. app/admin/advanced_settings.py +88 -0
  4. app/admin/database_browser.py +301 -0
  5. app/admin/router.py +528 -0
  6. app/admin/static/i18n.js +401 -0
  7. app/admin/static/icons/advanced.svg +8 -0
  8. app/admin/static/icons/database.svg +5 -0
  9. app/admin/static/icons/download.svg +3 -0
  10. app/admin/static/icons/home.svg +4 -0
  11. app/admin/static/icons/logs.svg +3 -0
  12. app/admin/static/icons/projects.svg +5 -0
  13. app/admin/static/summary.js +73 -0
  14. app/admin/templates/admin.html +511 -0
  15. app/admin/templates/advanced.html +635 -0
  16. app/admin/templates/database.html +880 -0
  17. app/admin/templates/logs.html +686 -0
  18. app/admin/templates/projects.html +878 -0
  19. app/ai/__init__.py +0 -0
  20. app/ai/base.py +129 -0
  21. app/ai/claude.py +20 -0
  22. app/ai/codex.py +34 -0
  23. app/ai/factory.py +27 -0
  24. app/ai/gemini.py +20 -0
  25. app/ai/model_catalog.py +47 -0
  26. app/ai/usage.py +134 -0
  27. app/cli.py +238 -0
  28. app/config.py +130 -0
  29. app/git/__init__.py +0 -0
  30. app/git/ai_commit.py +88 -0
  31. app/git/branch_naming.py +21 -0
  32. app/git/commit_message.py +279 -0
  33. app/git/service.py +669 -0
  34. app/jobs/__init__.py +0 -0
  35. app/jobs/manager.py +770 -0
  36. app/jobs/schemas.py +116 -0
  37. app/jobs/store.py +334 -0
  38. app/main.py +265 -0
  39. app/models.py +20 -0
  40. app/monitoring/__init__.py +10 -0
  41. app/monitoring/code.py +161 -0
  42. app/monitoring/events.py +33 -0
  43. app/monitoring/git.py +103 -0
  44. app/monitoring/log_buffer.py +245 -0
  45. app/monitoring/memory.py +19 -0
  46. app/monitoring/model.py +598 -0
  47. app/projects/__init__.py +19 -0
  48. app/projects/registry.py +384 -0
  49. app/security/__init__.py +0 -0
  50. app/security/auth.py +19 -0
  51. app/system_startup.py +34 -0
  52. app/telegram/__init__.py +0 -0
  53. app/telegram/bot_instances.py +67 -0
  54. app/telegram/commands/__init__.py +64 -0
  55. app/telegram/commands/base.py +222 -0
  56. app/telegram/commands/branch.py +366 -0
  57. app/telegram/commands/clear_stop.py +221 -0
  58. app/telegram/commands/fix.py +219 -0
  59. app/telegram/commands/model.py +93 -0
  60. app/telegram/commands/monitor.py +185 -0
  61. app/telegram/commands/registry.py +110 -0
  62. app/telegram/commands/status.py +243 -0
  63. app/telegram/commands/system.py +201 -0
  64. app/telegram/confirmations.py +36 -0
  65. app/telegram/conversation.py +789 -0
  66. app/telegram/i18n.py +742 -0
  67. app/telegram/model_preferences.py +53 -0
  68. app/telegram/notifier.py +387 -0
  69. app/telegram/parser.py +267 -0
  70. app/telegram/webhook.py +988 -0
  71. app/telegram/webhook_registration.py +172 -0
  72. app/tunnel.py +104 -0
  73. remote_coder-0.4.1.dist-info/METADATA +520 -0
  74. remote_coder-0.4.1.dist-info/RECORD +78 -0
  75. remote_coder-0.4.1.dist-info/WHEEL +5 -0
  76. remote_coder-0.4.1.dist-info/entry_points.txt +2 -0
  77. remote_coder-0.4.1.dist-info/licenses/LICENSE +201 -0
  78. 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