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.
- gdmcode-0.1.0.dist-info/METADATA +240 -0
- gdmcode-0.1.0.dist-info/RECORD +131 -0
- gdmcode-0.1.0.dist-info/WHEEL +4 -0
- gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
- src/__init__.py +1 -0
- src/_internal/__init__.py +0 -0
- src/_internal/constants.py +244 -0
- src/_internal/domain_skills.py +339 -0
- src/agent/__init__.py +0 -0
- src/agent/commit_classifier.py +91 -0
- src/agent/context_budget.py +391 -0
- src/agent/daemon.py +681 -0
- src/agent/dag_validator.py +153 -0
- src/agent/debug_loop.py +473 -0
- src/agent/impact_analyzer.py +149 -0
- src/agent/impact_graph.py +117 -0
- src/agent/loop.py +1410 -0
- src/agent/orchestrator.py +141 -0
- src/agent/regression_guard.py +251 -0
- src/agent/review_gate.py +648 -0
- src/agent/risk_scorer.py +169 -0
- src/agent/self_healing.py +145 -0
- src/agent/smart_test_selector.py +89 -0
- src/agent/system_prompt.py +226 -0
- src/agent/task_tracker.py +320 -0
- src/agent/test_validator.py +210 -0
- src/agent/tool_orchestrator.py +402 -0
- src/agent/transcript.py +230 -0
- src/agent/verification_loop.py +133 -0
- src/agent/work_director.py +136 -0
- src/agent/worktree_manager.py +53 -0
- src/artifacts/__init__.py +16 -0
- src/artifacts/artifact_store.py +456 -0
- src/artifacts/verification_graph.py +75 -0
- src/auth.py +411 -0
- src/cli.py +1290 -0
- src/commands.py +1398 -0
- src/config.py +762 -0
- src/cost_tracker.py +348 -0
- src/db/__init__.py +4 -0
- src/db/migrations.py +337 -0
- src/enterprise/__init__.py +3 -0
- src/enterprise/audit_log.py +182 -0
- src/enterprise/identity.py +90 -0
- src/enterprise/rbac.py +100 -0
- src/enterprise/team_config.py +125 -0
- src/enterprise/usage_analytics.py +261 -0
- src/exceptions.py +207 -0
- src/git_workflow.py +651 -0
- src/integrations/__init__.py +6 -0
- src/integrations/github_actions.py +106 -0
- src/integrations/mcp_server.py +333 -0
- src/integrations/sentry_integration.py +100 -0
- src/integrations/sentry_server.py +82 -0
- src/integrations/webhook_security.py +19 -0
- src/main.py +27 -0
- src/memory/__init__.py +0 -0
- src/memory/code_index.py +376 -0
- src/memory/compressor.py +378 -0
- src/memory/context_memory.py +135 -0
- src/memory/continuous_memory.py +234 -0
- src/memory/conventions.py +495 -0
- src/memory/db.py +1119 -0
- src/memory/document_index.py +205 -0
- src/memory/file_cache.py +128 -0
- src/memory/project_scanner.py +178 -0
- src/memory/session_store.py +201 -0
- src/models/__init__.py +0 -0
- src/models/client.py +715 -0
- src/models/definitions.py +459 -0
- src/models/router.py +418 -0
- src/models/schemas.py +389 -0
- src/permissions.py +294 -0
- src/remote/__init__.py +5 -0
- src/remote/command_filter.py +33 -0
- src/remote/models.py +31 -0
- src/remote/permission_handler.py +79 -0
- src/remote/phone_ui.py +48 -0
- src/remote/protocol.py +59 -0
- src/remote/qr.py +65 -0
- src/remote/server.py +586 -0
- src/remote/token_manager.py +61 -0
- src/remote/tunnel.py +212 -0
- src/repl.py +475 -0
- src/runtime/__init__.py +1 -0
- src/runtime/branch_farm.py +372 -0
- src/runtime/replay.py +351 -0
- src/sandbox/__init__.py +2 -0
- src/sandbox/hermetic.py +214 -0
- src/sandbox/policy.py +44 -0
- src/sdk/__init__.py +3 -0
- src/sdk/plugin_base.py +39 -0
- src/sdk/plugin_host.py +100 -0
- src/sdk/plugin_loader.py +101 -0
- src/security.py +409 -0
- src/server/__init__.py +7 -0
- src/server/bridge.py +427 -0
- src/server/bridge_cli.py +103 -0
- src/server/bridge_client.py +170 -0
- src/server/protocol_version.py +103 -0
- src/session/__init__.py +10 -0
- src/session/event_fanout.py +46 -0
- src/session/input_broker.py +38 -0
- src/session/permission_bridge.py +100 -0
- src/tools/__init__.py +160 -0
- src/tools/_atomic.py +72 -0
- src/tools/agent_tools.py +423 -0
- src/tools/ask_user_tool.py +83 -0
- src/tools/bash_tool.py +384 -0
- src/tools/browser_tool.py +352 -0
- src/tools/browser_tools.py +179 -0
- src/tools/dep_tools.py +210 -0
- src/tools/document_reader.py +167 -0
- src/tools/document_tool.py +240 -0
- src/tools/document_writer.py +171 -0
- src/tools/impact_tools.py +240 -0
- src/tools/playwright_tool.py +172 -0
- src/tools/quality_tools.py +366 -0
- src/tools/read_tools.py +318 -0
- src/tools/result_cache.py +157 -0
- src/tools/search_tools.py +310 -0
- src/tools/shell_tools.py +311 -0
- src/tools/write_tools.py +337 -0
- src/voice/__init__.py +25 -0
- src/voice/audio_capture.py +92 -0
- src/voice/audio_playback.py +68 -0
- src/voice/errors.py +14 -0
- src/voice/models.py +35 -0
- src/voice/providers.py +143 -0
- src/voice/vad.py +55 -0
- 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
|
+
|