gdmcode 0.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.
Files changed (131) hide show
  1. gdmcode-0.1.0.dist-info/METADATA +240 -0
  2. gdmcode-0.1.0.dist-info/RECORD +131 -0
  3. gdmcode-0.1.0.dist-info/WHEEL +4 -0
  4. gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
  5. src/__init__.py +1 -0
  6. src/_internal/__init__.py +0 -0
  7. src/_internal/constants.py +244 -0
  8. src/_internal/domain_skills.py +339 -0
  9. src/agent/__init__.py +0 -0
  10. src/agent/commit_classifier.py +91 -0
  11. src/agent/context_budget.py +391 -0
  12. src/agent/daemon.py +681 -0
  13. src/agent/dag_validator.py +153 -0
  14. src/agent/debug_loop.py +473 -0
  15. src/agent/impact_analyzer.py +149 -0
  16. src/agent/impact_graph.py +117 -0
  17. src/agent/loop.py +1410 -0
  18. src/agent/orchestrator.py +141 -0
  19. src/agent/regression_guard.py +251 -0
  20. src/agent/review_gate.py +648 -0
  21. src/agent/risk_scorer.py +169 -0
  22. src/agent/self_healing.py +145 -0
  23. src/agent/smart_test_selector.py +89 -0
  24. src/agent/system_prompt.py +226 -0
  25. src/agent/task_tracker.py +320 -0
  26. src/agent/test_validator.py +210 -0
  27. src/agent/tool_orchestrator.py +402 -0
  28. src/agent/transcript.py +230 -0
  29. src/agent/verification_loop.py +133 -0
  30. src/agent/work_director.py +136 -0
  31. src/agent/worktree_manager.py +53 -0
  32. src/artifacts/__init__.py +16 -0
  33. src/artifacts/artifact_store.py +456 -0
  34. src/artifacts/verification_graph.py +75 -0
  35. src/auth.py +411 -0
  36. src/cli.py +1290 -0
  37. src/commands.py +1398 -0
  38. src/config.py +762 -0
  39. src/cost_tracker.py +348 -0
  40. src/db/__init__.py +4 -0
  41. src/db/migrations.py +337 -0
  42. src/enterprise/__init__.py +3 -0
  43. src/enterprise/audit_log.py +182 -0
  44. src/enterprise/identity.py +90 -0
  45. src/enterprise/rbac.py +100 -0
  46. src/enterprise/team_config.py +125 -0
  47. src/enterprise/usage_analytics.py +261 -0
  48. src/exceptions.py +207 -0
  49. src/git_workflow.py +651 -0
  50. src/integrations/__init__.py +6 -0
  51. src/integrations/github_actions.py +106 -0
  52. src/integrations/mcp_server.py +333 -0
  53. src/integrations/sentry_integration.py +100 -0
  54. src/integrations/sentry_server.py +82 -0
  55. src/integrations/webhook_security.py +19 -0
  56. src/main.py +27 -0
  57. src/memory/__init__.py +0 -0
  58. src/memory/code_index.py +376 -0
  59. src/memory/compressor.py +378 -0
  60. src/memory/context_memory.py +135 -0
  61. src/memory/continuous_memory.py +234 -0
  62. src/memory/conventions.py +495 -0
  63. src/memory/db.py +1119 -0
  64. src/memory/document_index.py +205 -0
  65. src/memory/file_cache.py +128 -0
  66. src/memory/project_scanner.py +178 -0
  67. src/memory/session_store.py +201 -0
  68. src/models/__init__.py +0 -0
  69. src/models/client.py +715 -0
  70. src/models/definitions.py +459 -0
  71. src/models/router.py +418 -0
  72. src/models/schemas.py +389 -0
  73. src/permissions.py +294 -0
  74. src/remote/__init__.py +5 -0
  75. src/remote/command_filter.py +33 -0
  76. src/remote/models.py +31 -0
  77. src/remote/permission_handler.py +79 -0
  78. src/remote/phone_ui.py +48 -0
  79. src/remote/protocol.py +59 -0
  80. src/remote/qr.py +65 -0
  81. src/remote/server.py +586 -0
  82. src/remote/token_manager.py +61 -0
  83. src/remote/tunnel.py +212 -0
  84. src/repl.py +475 -0
  85. src/runtime/__init__.py +1 -0
  86. src/runtime/branch_farm.py +372 -0
  87. src/runtime/replay.py +351 -0
  88. src/sandbox/__init__.py +2 -0
  89. src/sandbox/hermetic.py +214 -0
  90. src/sandbox/policy.py +44 -0
  91. src/sdk/__init__.py +3 -0
  92. src/sdk/plugin_base.py +39 -0
  93. src/sdk/plugin_host.py +100 -0
  94. src/sdk/plugin_loader.py +101 -0
  95. src/security.py +409 -0
  96. src/server/__init__.py +7 -0
  97. src/server/bridge.py +427 -0
  98. src/server/bridge_cli.py +103 -0
  99. src/server/bridge_client.py +170 -0
  100. src/server/protocol_version.py +103 -0
  101. src/session/__init__.py +10 -0
  102. src/session/event_fanout.py +46 -0
  103. src/session/input_broker.py +38 -0
  104. src/session/permission_bridge.py +100 -0
  105. src/tools/__init__.py +160 -0
  106. src/tools/_atomic.py +72 -0
  107. src/tools/agent_tools.py +423 -0
  108. src/tools/ask_user_tool.py +83 -0
  109. src/tools/bash_tool.py +384 -0
  110. src/tools/browser_tool.py +352 -0
  111. src/tools/browser_tools.py +179 -0
  112. src/tools/dep_tools.py +210 -0
  113. src/tools/document_reader.py +167 -0
  114. src/tools/document_tool.py +240 -0
  115. src/tools/document_writer.py +171 -0
  116. src/tools/impact_tools.py +240 -0
  117. src/tools/playwright_tool.py +172 -0
  118. src/tools/quality_tools.py +366 -0
  119. src/tools/read_tools.py +318 -0
  120. src/tools/result_cache.py +157 -0
  121. src/tools/search_tools.py +310 -0
  122. src/tools/shell_tools.py +311 -0
  123. src/tools/write_tools.py +337 -0
  124. src/voice/__init__.py +25 -0
  125. src/voice/audio_capture.py +92 -0
  126. src/voice/audio_playback.py +68 -0
  127. src/voice/errors.py +14 -0
  128. src/voice/models.py +35 -0
  129. src/voice/providers.py +143 -0
  130. src/voice/vad.py +55 -0
  131. src/voice/voice_loop.py +156 -0
src/git_workflow.py ADDED
@@ -0,0 +1,651 @@
1
+ """Git workflow — safe branch management, checkpoints, and rollback.
2
+
3
+ Every agentic task gets its own branch. Every significant change gets a
4
+ checkpoint commit. The user can roll back to any checkpoint or the pre-task
5
+ state with a single command.
6
+
7
+ Safety invariants:
8
+ - NEVER force-push to main/master/develop
9
+ - NEVER rewrite history (no amend, no rebase on remote branches)
10
+ - Checkpoint commits are marked [gdm-checkpoint] for easy filtering
11
+ - All rollbacks require explicit user confirmation (except --force flag)
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import shutil
17
+ import subprocess
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import TYPE_CHECKING
21
+
22
+ from src.exceptions import GitError
23
+
24
+ if TYPE_CHECKING:
25
+ from src.agent.commit_classifier import ConventionalCommitClassifier
26
+
27
+ __all__ = [
28
+ "GitWorkflow",
29
+ "Checkpoint",
30
+ "WorktreeInfo",
31
+ "ConflictResult",
32
+ "ConflictResolver",
33
+ "cleanup_merged_branches",
34
+ ]
35
+
36
+ log = logging.getLogger(__name__)
37
+
38
+ _PROTECTED_BRANCHES: frozenset[str] = frozenset({"main", "master", "develop", "production"})
39
+ _CHECKPOINT_PREFIX = "[gdm-checkpoint]"
40
+
41
+
42
+ def _run(args: list[str], cwd: Path, check: bool = True) -> subprocess.CompletedProcess: # type: ignore[type-arg]
43
+ """Run a git command, returning CompletedProcess.
44
+
45
+ Raises:
46
+ GitError: if the command fails and check=True.
47
+ """
48
+ try:
49
+ result = subprocess.run(
50
+ ["git"] + args,
51
+ cwd=str(cwd),
52
+ capture_output=True,
53
+ text=True,
54
+ check=False,
55
+ )
56
+ except FileNotFoundError as exc:
57
+ raise GitError(command=" ".join(args), message="git not found in PATH") from exc
58
+
59
+ if check and result.returncode != 0:
60
+ raise GitError(
61
+ command=" ".join(args),
62
+ message=result.stderr.strip() or result.stdout.strip(),
63
+ )
64
+ return result
65
+
66
+
67
+ @dataclass
68
+ class WorktreeInfo:
69
+ """Metadata for a single git worktree."""
70
+ branch: str
71
+ path: Path
72
+ db_path: Path # each worktree gets its own .context-memory/gdm.db copy
73
+
74
+
75
+ @dataclass
76
+ class Checkpoint:
77
+ """A recorded git commit checkpoint."""
78
+
79
+ sha: str
80
+ message: str
81
+ branch: str
82
+
83
+
84
+ class GitWorkflow:
85
+ """Manages git workflow for a single agentic task.
86
+
87
+ Usage::
88
+
89
+ wf = GitWorkflow(project_root=Path.cwd())
90
+ branch = wf.create_task_branch("fix-auth-bug")
91
+ wf.checkpoint("after fixing JWT validation")
92
+ wf.checkpoint("after updating tests")
93
+ wf.rollback_to(branch.sha) # user confirmed rollback
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ project_root: Path,
99
+ *,
100
+ classifier: "ConventionalCommitClassifier | None" = None,
101
+ ) -> None:
102
+ self._root = project_root
103
+ self._task_branch: str | None = None
104
+ self._pre_task_sha: str | None = None
105
+ self._classifier = classifier
106
+
107
+ # ------------------------------------------------------------------
108
+ # Branch management
109
+ # ------------------------------------------------------------------
110
+
111
+ def current_branch(self) -> str:
112
+ result = _run(["branch", "--show-current"], cwd=self._root)
113
+ return result.stdout.strip()
114
+
115
+ def current_sha(self) -> str:
116
+ result = _run(["rev-parse", "HEAD"], cwd=self._root)
117
+ return result.stdout.strip()
118
+
119
+ def create_task_branch(self, task_slug: str) -> str:
120
+ """Create and checkout a new task branch. Returns branch name.
121
+
122
+ Args:
123
+ task_slug: short description (will be sanitized into a branch name)
124
+
125
+ Raises:
126
+ GitError: if we're already on a protected branch and can't branch from it,
127
+ or if the repo has no commits.
128
+ """
129
+ base = self.current_branch()
130
+ self._pre_task_sha = self.current_sha()
131
+
132
+ # Sanitize task_slug into a valid branch name
133
+ safe = "".join(
134
+ c if c.isalnum() or c in "-_" else "-" for c in task_slug.lower()
135
+ ).strip("-")[:50]
136
+ branch_name = f"gdm/{safe}"
137
+
138
+ _run(["checkout", "-b", branch_name], cwd=self._root)
139
+ self._task_branch = branch_name
140
+ log.info("Created task branch '%s' from '%s' @ %s", branch_name, base, self._pre_task_sha)
141
+ return branch_name
142
+
143
+ # ------------------------------------------------------------------
144
+ # Checkpointing
145
+ # ------------------------------------------------------------------
146
+
147
+ def checkpoint(
148
+ self,
149
+ message: str,
150
+ *,
151
+ turn_id: str = "",
152
+ files: "list[Path] | None" = None,
153
+ ) -> Checkpoint:
154
+ """Stage all changes and create a checkpoint commit.
155
+
156
+ Args:
157
+ message: Human-readable description of what changed
158
+ turn_id: Session turn ID for per-turn rollback
159
+ files: Files changed in this turn (for rollback journal)
160
+
161
+ Returns:
162
+ Checkpoint with the commit SHA
163
+ """
164
+ if not self._task_branch:
165
+ log.warning("checkpoint() called outside a task branch — proceeding anyway")
166
+
167
+ _run(["add", "-A"], cwd=self._root)
168
+ commit_msg = f"{_CHECKPOINT_PREFIX} {message}"
169
+ if self._classifier is not None:
170
+ try:
171
+ cached_diff = _run(["diff", "--cached"], cwd=self._root, check=False)
172
+ classified = self._classifier.classify(cached_diff.stdout)
173
+ commit_msg = f"{_CHECKPOINT_PREFIX} {classified}"
174
+ except Exception: # noqa: BLE001
175
+ pass # fall back to raw message already set above
176
+ if turn_id or files:
177
+ files_csv = ",".join(f.name for f in (files or []))
178
+ commit_msg += f"\n\n[gdm] turn:{turn_id} files:{files_csv}"
179
+ result = _run(["commit", "--allow-empty", "-m", commit_msg], cwd=self._root)
180
+ sha = self.current_sha()
181
+ branch = self._task_branch or self.current_branch()
182
+ log.info("Checkpoint: %s (%s)", sha[:8], message)
183
+ return Checkpoint(sha=sha, message=commit_msg, branch=branch)
184
+
185
+ def list_checkpoints(self) -> list[Checkpoint]:
186
+ """Return all checkpoint commits on the current branch."""
187
+ result = _run(
188
+ ["log", "--oneline", f"--grep={_CHECKPOINT_PREFIX}"],
189
+ cwd=self._root,
190
+ check=False,
191
+ )
192
+ checkpoints: list[Checkpoint] = []
193
+ branch = self.current_branch()
194
+ for line in result.stdout.splitlines():
195
+ parts = line.split(maxsplit=1)
196
+ if len(parts) == 2:
197
+ checkpoints.append(Checkpoint(sha=parts[0], message=parts[1], branch=branch))
198
+ return checkpoints
199
+
200
+ # ------------------------------------------------------------------
201
+ # Rollback
202
+ # ------------------------------------------------------------------
203
+
204
+ def rollback_to_checkpoint(self, sha: str) -> None:
205
+ """Soft rollback — reset HEAD to *sha*, keep working tree.
206
+
207
+ This preserves changes as unstaged diffs — user can review before
208
+ discarding. For hard rollback use rollback_hard().
209
+ """
210
+ _run(["reset", sha], cwd=self._root)
211
+ log.info("Soft rollback to %s (working tree preserved)", sha[:8])
212
+
213
+ def rollback_hard(self, sha: str) -> None:
214
+ """Hard rollback — git reset --hard *sha*.
215
+
216
+ DESTRUCTIVE. Discards all changes since *sha*.
217
+ Caller MUST confirm with user before calling this.
218
+ """
219
+ _run(["reset", "--hard", sha], cwd=self._root)
220
+ log.warning("Hard rollback to %s — all subsequent changes discarded", sha[:8])
221
+
222
+ def rollback_to_pre_task(self, hard: bool = False) -> None:
223
+ """Roll back to the state before create_task_branch().
224
+
225
+ Raises:
226
+ GitError: if pre-task SHA is unknown (task branch was never created).
227
+ """
228
+ if not self._pre_task_sha:
229
+ raise GitError(
230
+ command="rollback_to_pre_task",
231
+ message="No pre-task SHA recorded — was create_task_branch() called?",
232
+ )
233
+ if hard:
234
+ self.rollback_hard(self._pre_task_sha)
235
+ else:
236
+ self.rollback_to_checkpoint(self._pre_task_sha)
237
+
238
+ # ------------------------------------------------------------------
239
+ # Utility
240
+ # ------------------------------------------------------------------
241
+
242
+ def is_clean(self) -> bool:
243
+ """Return True if the working tree has no uncommitted changes."""
244
+ result = _run(["status", "--porcelain"], cwd=self._root)
245
+ return result.stdout.strip() == ""
246
+
247
+ def is_git_repo(self) -> bool:
248
+ """Return True if *self._root* is inside a git repository."""
249
+ result = _run(["rev-parse", "--is-inside-work-tree"], cwd=self._root, check=False)
250
+ return result.returncode == 0
251
+
252
+ def is_protected(self, branch: str | None = None) -> bool:
253
+ """Return True if *branch* (or current branch) is a protected branch."""
254
+ target = branch or self.current_branch()
255
+ return target in _PROTECTED_BRANCHES
256
+
257
+ # ------------------------------------------------------------------
258
+ # Worktree management (used by BranchFarm)
259
+ # ------------------------------------------------------------------
260
+
261
+ def create_worktree(self, branch: str, path: Path) -> WorktreeInfo:
262
+ """Create a new worktree on a fresh branch at *path*.
263
+
264
+ Copies the current DB snapshot into the worktree so history is
265
+ inherited but all DB writes stay isolated to that branch.
266
+ """
267
+ path.mkdir(parents=True, exist_ok=True)
268
+ _run(["worktree", "add", "-b", branch, str(path)], cwd=self._root)
269
+ src_db = self._root / ".context-memory" / "gdm.db"
270
+ dst_db = path / ".context-memory" / "gdm.db"
271
+ dst_db.parent.mkdir(parents=True, exist_ok=True)
272
+ if src_db.exists():
273
+ shutil.copy2(src_db, dst_db)
274
+ return WorktreeInfo(branch=branch, path=path, db_path=dst_db)
275
+
276
+ def remove_worktree(self, path: Path) -> None:
277
+ """Remove a worktree (force — works even with untracked changes)."""
278
+ _run(["worktree", "remove", "--force", str(path)], cwd=self._root, check=False)
279
+
280
+ def list_worktrees(self) -> list[WorktreeInfo]:
281
+ """Return all worktrees as ``WorktreeInfo`` objects (main worktree included)."""
282
+ result = _run(["worktree", "list", "--porcelain"], cwd=self._root)
283
+ infos: list[WorktreeInfo] = []
284
+ current: dict[str, str] = {}
285
+ for line in result.stdout.splitlines():
286
+ if line.startswith("worktree "):
287
+ current = {"path": line[len("worktree "):].strip()}
288
+ elif line.startswith("branch "):
289
+ current["branch"] = line[len("branch "):].strip().replace("refs/heads/", "")
290
+ elif line == "" and current:
291
+ if "branch" in current:
292
+ p = Path(current["path"])
293
+ infos.append(WorktreeInfo(
294
+ branch=current["branch"],
295
+ path=p,
296
+ db_path=p / ".context-memory" / "gdm.db",
297
+ ))
298
+ current = {}
299
+ return infos
300
+
301
+ def delete_branch(self, branch: str) -> None:
302
+ """Delete a local branch (must not be checked out)."""
303
+ _run(["branch", "-D", branch], cwd=self._root, check=False)
304
+
305
+ def cherry_pick(self, sha: str) -> None:
306
+ """Cherry-pick a single commit onto the current branch."""
307
+ _run(["cherry-pick", sha], cwd=self._root)
308
+
309
+ def squash_merge(self, branch: str, message: str) -> None:
310
+ """Squash-merge *branch* into the current branch with *message*."""
311
+ _run(["merge", "--squash", branch], cwd=self._root)
312
+ _run(["commit", "-m", message], cwd=self._root)
313
+
314
+ def merge_tree_dry_run(self, branch: str) -> str:
315
+ """Return the output of ``git merge-tree HEAD <branch>`` (dry-run diff).
316
+
317
+ If the output contains conflict markers (``<<<<<<<``) a merge would
318
+ produce conflicts.
319
+ """
320
+ result = _run(["merge-tree", "HEAD", branch], cwd=self._root, check=False)
321
+ return result.stdout
322
+
323
+
324
+ # ---------------------------------------------------------------------------
325
+ # Conflict resolution
326
+ # ---------------------------------------------------------------------------
327
+
328
+ _CONFLICT_START = "<<<<<<"
329
+ _CONFLICT_SEP = "======="
330
+ _CONFLICT_END = ">>>>>>>"
331
+
332
+
333
+ @dataclass
334
+ class ConflictResult:
335
+ """Result of a single-file conflict resolution attempt."""
336
+
337
+ resolved: bool
338
+ reason: str
339
+ file: Path
340
+
341
+
342
+ class ConflictResolver:
343
+ """Resolves merge conflicts where hunks are non-overlapping.
344
+
345
+ Algorithm:
346
+ 1. Read the conflicted file.
347
+ 2. Parse conflict markers: ``<<<<<<< HEAD ... ======= ... >>>>>>> branch``
348
+ 3. For each conflict block: check if HEAD hunk and branch hunk modify
349
+ different lines (non-overlapping). If so, keep both (HEAD first).
350
+ 4. If any conflict block has overlapping edits (both sides share identical
351
+ non-empty lines) → ABORT, leave markers in place, return
352
+ ``ConflictResult(resolved=False, reason="overlapping hunks")``.
353
+ 5. Hard-fail if conflict markers remain in output (safety gate).
354
+
355
+ Safety invariant: NEVER produce a file with conflict markers.
356
+ If resolution is uncertain, return ``resolved=False`` and surface to user.
357
+ """
358
+
359
+ def resolve_file(self, path: Path) -> ConflictResult:
360
+ """Attempt to resolve merge conflicts in *path*.
361
+
362
+ Returns:
363
+ ConflictResult with ``resolved=True`` if all conflict blocks were
364
+ resolved and the output file is free of conflict markers;
365
+ ``resolved=False`` (and the file left unchanged) otherwise.
366
+ """
367
+ try:
368
+ original = path.read_text(encoding="utf-8")
369
+ except OSError as exc:
370
+ return ConflictResult(resolved=False, reason=str(exc), file=path)
371
+
372
+ if _CONFLICT_START not in original:
373
+ return ConflictResult(resolved=True, reason="no conflicts", file=path)
374
+
375
+ resolved_text, ok, reason = _resolve_conflicts(original)
376
+ if not ok:
377
+ # Leave the file untouched
378
+ return ConflictResult(resolved=False, reason=reason, file=path)
379
+
380
+ # Safety gate: NEVER write a file that still contains conflict markers
381
+ if _CONFLICT_START in resolved_text:
382
+ log.error(
383
+ "ConflictResolver safety gate: resolved text still has markers in %s", path
384
+ )
385
+ return ConflictResult(
386
+ resolved=False, reason="safety gate: markers remain after resolution", file=path
387
+ )
388
+
389
+ try:
390
+ path.write_text(resolved_text, encoding="utf-8")
391
+ except OSError as exc:
392
+ return ConflictResult(resolved=False, reason=str(exc), file=path)
393
+
394
+ return ConflictResult(resolved=True, reason="all blocks non-overlapping", file=path)
395
+
396
+ def resolve_repo(self) -> list[ConflictResult]:
397
+ """Find all conflicted files in the repo and attempt to resolve each.
398
+
399
+ Uses ``git diff --name-only --diff-filter=U`` to find conflicted files.
400
+
401
+ Returns:
402
+ List of ``ConflictResult`` objects, one per conflicted file.
403
+ """
404
+ try:
405
+ result = subprocess.run(
406
+ ["git", "diff", "--name-only", "--diff-filter=U"],
407
+ capture_output=True,
408
+ text=True,
409
+ check=False,
410
+ )
411
+ conflicted = [
412
+ p.strip() for p in result.stdout.splitlines() if p.strip()
413
+ ]
414
+ except Exception as exc: # noqa: BLE001
415
+ log.warning("Could not list conflicted files: %s", exc)
416
+ return []
417
+
418
+ results: list[ConflictResult] = []
419
+ for rel_path in conflicted:
420
+ abs_path = Path(rel_path).resolve()
421
+ if not abs_path.exists():
422
+ abs_path = Path.cwd() / rel_path
423
+ results.append(self.resolve_file(abs_path))
424
+ return results
425
+
426
+
427
+ def _parse_conflict_blocks(text: str) -> list[tuple[str, str, str, str, str]]:
428
+ """Parse *text* into segments alternating between plain text and conflict blocks.
429
+
430
+ Yields tuples of (before, head_hunk, branch_hunk, after_marker, remainder).
431
+ Returns list of (prefix, head_lines, branch_lines) for each conflict block
432
+ plus the surrounding plain segments.
433
+
434
+ More precisely, returns a list of dicts; simpler to return a structured list.
435
+
436
+ Internal helper — returns (ok, segments) where segments is a list of
437
+ ``("plain", text)`` or ``("conflict", head_str, branch_str)`` tuples.
438
+ """
439
+ segments: list[tuple] = []
440
+ remaining = text
441
+ while _CONFLICT_START in remaining:
442
+ start_idx = remaining.index(_CONFLICT_START)
443
+ segments.append(("plain", remaining[:start_idx]))
444
+ remaining = remaining[start_idx:]
445
+
446
+ # Find end of start marker line
447
+ nl = remaining.index("\n")
448
+ remaining = remaining[nl + 1:]
449
+
450
+ # Find separator
451
+ if _CONFLICT_SEP not in remaining:
452
+ # Malformed conflict — abort
453
+ return [("malformed",)]
454
+
455
+ sep_idx = remaining.index(_CONFLICT_SEP)
456
+ head_hunk = remaining[:sep_idx]
457
+ remaining = remaining[sep_idx + len(_CONFLICT_SEP):]
458
+
459
+ # Skip past separator newline
460
+ if remaining.startswith("\n"):
461
+ remaining = remaining[1:]
462
+
463
+ # Find end marker
464
+ if _CONFLICT_END not in remaining:
465
+ return [("malformed",)]
466
+
467
+ end_idx = remaining.index(_CONFLICT_END)
468
+ branch_hunk = remaining[:end_idx]
469
+ remaining = remaining[end_idx:]
470
+
471
+ # Skip end marker line
472
+ nl2 = remaining.index("\n") if "\n" in remaining else len(remaining)
473
+ remaining = remaining[nl2 + 1:]
474
+
475
+ segments.append(("conflict", head_hunk, branch_hunk))
476
+
477
+ segments.append(("plain", remaining))
478
+ return segments
479
+
480
+
481
+ def _resolve_conflicts(text: str) -> tuple[str, bool, str]:
482
+ """Attempt to resolve all conflict blocks in *text*.
483
+
484
+ Returns:
485
+ (resolved_text, ok, reason) — if ok is False, resolved_text is
486
+ the original text unchanged and reason explains why.
487
+ """
488
+ segments = _parse_conflict_blocks(text)
489
+
490
+ if any(s[0] == "malformed" for s in segments):
491
+ return text, False, "malformed conflict markers"
492
+
493
+ output_parts: list[str] = []
494
+ for seg in segments:
495
+ if seg[0] == "plain":
496
+ output_parts.append(seg[1])
497
+ else:
498
+ _, head_hunk, branch_hunk = seg
499
+ head_lines = {
500
+ ln for ln in head_hunk.splitlines() if ln.strip()
501
+ }
502
+ branch_lines = {
503
+ ln for ln in branch_hunk.splitlines() if ln.strip()
504
+ }
505
+ overlap = head_lines & branch_lines
506
+ if overlap:
507
+ return text, False, "overlapping hunks"
508
+ # Non-overlapping: keep both, HEAD first (strip trailing newline
509
+ # from head_hunk so we don't get a blank line between them)
510
+ merged = head_hunk.rstrip("\n")
511
+ if branch_hunk:
512
+ merged = merged + "\n" + branch_hunk if merged else branch_hunk
513
+ output_parts.append(merged)
514
+
515
+ return "".join(output_parts), True, "ok"
516
+
517
+
518
+ # ---------------------------------------------------------------------------
519
+ # Branch cleanup (requires GitHub API)
520
+ # ---------------------------------------------------------------------------
521
+
522
+ def cleanup_merged_branches(cfg: "object") -> list[str]:
523
+ """Delete local and remote ``gdm/*`` branches whose PRs are merged.
524
+
525
+ Requires GitHub API credentials (``cfg.github_token``).
526
+ Uses GitHub REST API:
527
+ ``GET /repos/{owner}/{repo}/pulls?state=closed&head={branch}``
528
+
529
+ Only deletes branches where ``pull_request.merged_at`` is not null.
530
+
531
+ Args:
532
+ cfg: a ``GdmConfig``-like object with ``github_token``,
533
+ ``project_root``, and optionally ``github_repo`` attributes.
534
+
535
+ Returns:
536
+ List of deleted branch names (both local and remote deletions recorded).
537
+ """
538
+ import json
539
+ import urllib.request
540
+
541
+ github_token: str | None = getattr(cfg, "github_token", None)
542
+ if not github_token:
543
+ log.info("cleanup_merged_branches: no github_token, skipping")
544
+ return []
545
+
546
+ project_root: Path = getattr(cfg, "project_root", Path.cwd())
547
+
548
+ # Discover repo owner/name from git remote
549
+ try:
550
+ result = subprocess.run(
551
+ ["git", "remote", "get-url", "origin"],
552
+ cwd=str(project_root),
553
+ capture_output=True,
554
+ text=True,
555
+ check=False,
556
+ )
557
+ remote_url = result.stdout.strip()
558
+ except Exception as exc: # noqa: BLE001
559
+ log.warning("cleanup_merged_branches: could not get remote URL: %s", exc)
560
+ return []
561
+
562
+ owner_repo = _parse_github_repo(remote_url)
563
+ if not owner_repo:
564
+ log.warning("cleanup_merged_branches: could not parse GitHub owner/repo from %r", remote_url)
565
+ return []
566
+ owner, repo = owner_repo
567
+
568
+ # List local gdm/* branches
569
+ try:
570
+ result = subprocess.run(
571
+ ["git", "branch", "--list", "gdm/*"],
572
+ cwd=str(project_root),
573
+ capture_output=True,
574
+ text=True,
575
+ check=False,
576
+ )
577
+ local_branches = [
578
+ b.strip().lstrip("* ") for b in result.stdout.splitlines() if b.strip()
579
+ ]
580
+ except Exception as exc: # noqa: BLE001
581
+ log.warning("cleanup_merged_branches: could not list branches: %s", exc)
582
+ return []
583
+
584
+ deleted: list[str] = []
585
+ for branch in local_branches:
586
+ if not _is_pr_merged(github_token, owner, repo, branch):
587
+ continue
588
+
589
+ # Delete remote branch
590
+ try:
591
+ subprocess.run(
592
+ ["git", "push", "origin", "--delete", branch],
593
+ cwd=str(project_root),
594
+ capture_output=True,
595
+ check=False,
596
+ )
597
+ except Exception as exc: # noqa: BLE001
598
+ log.warning("cleanup_merged_branches: remote delete of %r failed: %s", branch, exc)
599
+
600
+ # Delete local branch
601
+ try:
602
+ subprocess.run(
603
+ ["git", "branch", "-D", branch],
604
+ cwd=str(project_root),
605
+ capture_output=True,
606
+ check=False,
607
+ )
608
+ deleted.append(branch)
609
+ log.info("cleanup_merged_branches: deleted %r", branch)
610
+ except Exception as exc: # noqa: BLE001
611
+ log.warning("cleanup_merged_branches: local delete of %r failed: %s", branch, exc)
612
+
613
+ return deleted
614
+
615
+
616
+ def _parse_github_repo(remote_url: str) -> tuple[str, str] | None:
617
+ """Parse ``owner/repo`` from a GitHub remote URL."""
618
+ import re
619
+
620
+ # HTTPS: https://github.com/owner/repo.git
621
+ m = re.search(r"github\.com[/:]([^/]+)/([^/]+?)(?:\.git)?$", remote_url)
622
+ if m:
623
+ return m.group(1), m.group(2)
624
+ return None
625
+
626
+
627
+ def _is_pr_merged(token: str, owner: str, repo: str, branch: str) -> bool:
628
+ """Return True if the branch has at least one merged PR on GitHub."""
629
+ import json
630
+ import urllib.request
631
+
632
+ url = (
633
+ f"https://api.github.com/repos/{owner}/{repo}/pulls"
634
+ f"?state=closed&head={owner}:{branch}&per_page=5"
635
+ )
636
+ req = urllib.request.Request(
637
+ url,
638
+ headers={
639
+ "Authorization": f"Bearer {token}",
640
+ "Accept": "application/vnd.github+json",
641
+ "X-GitHub-Api-Version": "2022-11-28",
642
+ },
643
+ )
644
+ try:
645
+ with urllib.request.urlopen(req, timeout=10) as resp:
646
+ pulls = json.loads(resp.read())
647
+ return any(pr.get("merged_at") for pr in pulls)
648
+ except Exception as exc: # noqa: BLE001
649
+ log.warning("_is_pr_merged: GitHub API call failed for %r: %s", branch, exc)
650
+ return False
651
+
@@ -0,0 +1,6 @@
1
+ """gdm integrations -- MCP and other protocol adapters."""
2
+ from __future__ import annotations
3
+
4
+ from src.integrations.mcp_server import MCPResource, MCPServer, MCPTool
5
+
6
+ __all__ = ["MCPServer", "MCPTool", "MCPResource"]