docspan 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 (65) hide show
  1. docspan/__init__.py +3 -0
  2. docspan/__main__.py +0 -0
  3. docspan/backends/__init__.py +19 -0
  4. docspan/backends/base.py +85 -0
  5. docspan/backends/confluence/__init__.py +0 -0
  6. docspan/backends/confluence/adf/__init__.py +14 -0
  7. docspan/backends/confluence/adf/comparator.py +427 -0
  8. docspan/backends/confluence/adf/converter.py +119 -0
  9. docspan/backends/confluence/adf/converters.py +1449 -0
  10. docspan/backends/confluence/adf/interfaces.py +191 -0
  11. docspan/backends/confluence/adf/nodes.py +2085 -0
  12. docspan/backends/confluence/adf/parser.py +400 -0
  13. docspan/backends/confluence/adf/validators.py +161 -0
  14. docspan/backends/confluence/adf/visitors.py +495 -0
  15. docspan/backends/confluence/backend.py +227 -0
  16. docspan/backends/confluence/client.py +44 -0
  17. docspan/backends/confluence/config/__init__.py +21 -0
  18. docspan/backends/confluence/config/loader.py +107 -0
  19. docspan/backends/confluence/config/models.py +167 -0
  20. docspan/backends/confluence/config/validation.py +297 -0
  21. docspan/backends/confluence/markdown/__init__.py +22 -0
  22. docspan/backends/confluence/markdown/ast.py +819 -0
  23. docspan/backends/confluence/markdown/extensions/__init__.py +5 -0
  24. docspan/backends/confluence/markdown/extensions/frontmatter.py +80 -0
  25. docspan/backends/confluence/markdown/extensions/mermaid.py +64 -0
  26. docspan/backends/confluence/markdown/extensions/wikilinks.py +179 -0
  27. docspan/backends/confluence/markdown/inline_parser.py +495 -0
  28. docspan/backends/confluence/markdown/parser.py +1006 -0
  29. docspan/backends/confluence/models/__init__.py +18 -0
  30. docspan/backends/confluence/models/markdown_file.py +402 -0
  31. docspan/backends/confluence/models/page.py +212 -0
  32. docspan/backends/confluence/models/path_utils.py +34 -0
  33. docspan/backends/confluence/models/results.py +28 -0
  34. docspan/backends/confluence/models/sync_status.py +382 -0
  35. docspan/backends/confluence/services/__init__.py +0 -0
  36. docspan/backends/confluence/services/confluence/__init__.py +40 -0
  37. docspan/backends/confluence/services/confluence/attachment_client.py +147 -0
  38. docspan/backends/confluence/services/confluence/base_client.py +420 -0
  39. docspan/backends/confluence/services/confluence/client.py +376 -0
  40. docspan/backends/confluence/services/confluence/comment_client.py +682 -0
  41. docspan/backends/confluence/services/confluence/crawler.py +587 -0
  42. docspan/backends/confluence/services/confluence/label_client.py +130 -0
  43. docspan/backends/confluence/services/confluence/page_client.py +1288 -0
  44. docspan/backends/confluence/services/confluence/space_client.py +179 -0
  45. docspan/backends/confluence/services/confluence/url_parser.py +106 -0
  46. docspan/backends/google_docs/__init__.py +0 -0
  47. docspan/backends/google_docs/auth.py +143 -0
  48. docspan/backends/google_docs/backend.py +140 -0
  49. docspan/backends/google_docs/client.py +665 -0
  50. docspan/backends/google_docs/converter.py +471 -0
  51. docspan/backends/google_docs/docs_request_builder.py +232 -0
  52. docspan/backends/google_docs/docs_structure_parser.py +120 -0
  53. docspan/backends/google_docs/markdown_to_paragraph_parser.py +145 -0
  54. docspan/cli/__init__.py +0 -0
  55. docspan/cli/main.py +408 -0
  56. docspan/config.py +62 -0
  57. docspan/core/__init__.py +49 -0
  58. docspan/core/merge.py +30 -0
  59. docspan/core/orchestrator.py +332 -0
  60. docspan/core/paths.py +8 -0
  61. docspan/core/state.py +53 -0
  62. docspan-0.1.0.dist-info/METADATA +273 -0
  63. docspan-0.1.0.dist-info/RECORD +65 -0
  64. docspan-0.1.0.dist-info/WHEEL +4 -0
  65. docspan-0.1.0.dist-info/entry_points.txt +2 -0
docspan/cli/main.py ADDED
@@ -0,0 +1,408 @@
1
+ """docspan CLI — push, pull, auth, status, conflicts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import shutil
8
+ from typing import Optional
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ import typer
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+
16
+ from docspan.backends import BACKENDS
17
+ from docspan.config import MarkgateConfig, load_config
18
+ from docspan.core import (
19
+ MappingState,
20
+ SyncState,
21
+ get_base_content,
22
+ get_state_dir,
23
+ get_state_path,
24
+ orchestrate_pull,
25
+ orchestrate_push,
26
+ record_state,
27
+ )
28
+ from docspan.core.paths import ORIG_SUFFIX
29
+
30
+ app = typer.Typer(
31
+ name="docspan",
32
+ help="Push and pull markdown to Google Docs and Confluence.",
33
+ add_completion=False,
34
+ rich_markup_mode="rich",
35
+ )
36
+ auth_app = typer.Typer(help="Manage authentication for backends.")
37
+ conflicts_app = typer.Typer(help="Manage merge conflicts.")
38
+ app.add_typer(auth_app, name="auth")
39
+ app.add_typer(conflicts_app, name="conflicts")
40
+
41
+ console = Console()
42
+ err_console = Console(stderr=True, style="bold red")
43
+
44
+
45
+ # ─────────────────────────────────────────────────────────────────────────────
46
+ # Backend factory
47
+ # ─────────────────────────────────────────────────────────────────────────────
48
+
49
+ def _get_backend(backend_name: str, config: MarkgateConfig):
50
+ cls = BACKENDS.get(backend_name)
51
+ if not cls:
52
+ err_console.print(
53
+ f"Unknown backend '{backend_name}'. Available: {list(BACKENDS.keys())}"
54
+ )
55
+ raise typer.Exit(1)
56
+ backend = cls.from_config(config)
57
+ try:
58
+ backend.validate_config()
59
+ except ValueError as exc:
60
+ err_console.print(f"Configuration error: {exc}")
61
+ raise typer.Exit(1)
62
+ return backend
63
+
64
+
65
+ def _load_state(state_path: str) -> SyncState:
66
+ try:
67
+ return SyncState.load(state_path)
68
+ except FileNotFoundError:
69
+ return SyncState()
70
+
71
+
72
+ # ─────────────────────────────────────────────────────────────────────────────
73
+ # push command
74
+ # ─────────────────────────────────────────────────────────────────────────────
75
+
76
+ @app.command()
77
+ def push(
78
+ files: Optional[list[str]] = typer.Argument(
79
+ None, help="Local markdown files to push (default: all mappings)"
80
+ ),
81
+ config_path: Optional[str] = typer.Option(None, "--config", "-c", help="Path to markgate.yaml"),
82
+ dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without writing"),
83
+ ) -> None:
84
+ """Push local markdown to remote docs."""
85
+ config = load_config(config_path)
86
+ mappings = config.mappings
87
+
88
+ if files:
89
+ mappings = [m for m in mappings if m.local in files]
90
+ if not mappings:
91
+ err_console.print(f"No mappings found for: {files}")
92
+ raise typer.Exit(1)
93
+
94
+ if not mappings:
95
+ err_console.print("No mappings configured. Add entries to markgate.yaml.")
96
+ raise typer.Exit(1)
97
+
98
+ state_path = get_state_path(config_path)
99
+ state_dir = get_state_dir(config_path)
100
+ state = _load_state(state_path)
101
+
102
+ had_error = False
103
+ for mapping in mappings:
104
+ if mapping.direction == "pull":
105
+ console.print(f"[dim]Skipping {mapping.local} (pull-only)[/dim]")
106
+ continue
107
+ if dry_run:
108
+ console.print(
109
+ f"[yellow]dry-run[/yellow] {mapping.local} → [{mapping.backend}] {mapping.remote_id}"
110
+ )
111
+ continue
112
+
113
+ backend = _get_backend(mapping.backend, config)
114
+ outcome = orchestrate_push(mapping, backend, state, state_dir, state_path)
115
+ result = outcome.result
116
+
117
+ icon = "✓" if result.status in ("ok", "skipped") else "✗"
118
+ style = "green" if result.status in ("ok", "skipped") else "red"
119
+ console.print(f"[{style}]{icon}[/{style}] {mapping.local} → {result.url or mapping.remote_id}")
120
+ if result.message:
121
+ console.print(f" [dim]{result.message}[/dim]")
122
+ if result.status == "ok" and not outcome.state_saved:
123
+ console.print(" [yellow]Warning: could not save sync state[/yellow]")
124
+ if result.status == "error":
125
+ had_error = True
126
+
127
+ if had_error:
128
+ raise typer.Exit(1)
129
+
130
+
131
+ # ─────────────────────────────────────────────────────────────────────────────
132
+ # pull command
133
+ # ─────────────────────────────────────────────────────────────────────────────
134
+
135
+ @app.command()
136
+ def pull(
137
+ files: Optional[list[str]] = typer.Argument(
138
+ None, help="Local paths to pull into (default: all mappings)"
139
+ ),
140
+ config_path: Optional[str] = typer.Option(None, "--config", "-c"),
141
+ dry_run: bool = typer.Option(False, "--dry-run"),
142
+ ) -> None:
143
+ """Pull remote docs into local markdown files."""
144
+ config = load_config(config_path)
145
+ mappings = config.mappings
146
+
147
+ if files:
148
+ mappings = [m for m in mappings if m.local in files]
149
+
150
+ if not mappings:
151
+ err_console.print("No mappings configured.")
152
+ raise typer.Exit(1)
153
+
154
+ state_path = get_state_path(config_path)
155
+ state_dir = get_state_dir(config_path)
156
+ state = _load_state(state_path)
157
+
158
+ had_error = False
159
+ for mapping in mappings:
160
+ if mapping.direction == "push":
161
+ console.print(f"[dim]Skipping {mapping.local} (push-only)[/dim]")
162
+ continue
163
+ if dry_run:
164
+ console.print(
165
+ f"[yellow]dry-run[/yellow] [{mapping.backend}] {mapping.remote_id} → {mapping.local}"
166
+ )
167
+ continue
168
+
169
+ backend = _get_backend(mapping.backend, config)
170
+ outcome = orchestrate_pull(mapping, backend, state, state_dir, state_path)
171
+
172
+ if outcome.action == "up-to-date":
173
+ console.print(f"[dim]up to date[/dim] {mapping.local}")
174
+ elif outcome.action == "local-only":
175
+ console.print(
176
+ f"[yellow]warning[/yellow] {mapping.local} has local changes not yet pushed. "
177
+ "Pull skipped. Push first or use 'docspan conflicts resolve'."
178
+ )
179
+ elif outcome.action == "merged":
180
+ console.print(f"[yellow]merging[/yellow] {mapping.local}")
181
+ if outcome.has_conflicts:
182
+ console.print(
183
+ f" [yellow]Merge conflicts ({outcome.conflict_count}) written to "
184
+ f"{mapping.local}. Resolve with: docspan conflicts resolve {mapping.local}[/yellow]"
185
+ )
186
+ else:
187
+ console.print(" [green]Merged cleanly.[/green]")
188
+ elif outcome.action == "error":
189
+ had_error = True
190
+ result = outcome.result
191
+ err_console.print(
192
+ f"✗ {mapping.remote_id} → {mapping.local}: "
193
+ f"{result.message if result else 'unknown error'}"
194
+ )
195
+ else:
196
+ # first-sync or fast-forward
197
+ result = outcome.result
198
+ if result:
199
+ icon = "✓" if result.status == "ok" else "✗"
200
+ style = "green" if result.status == "ok" else "red"
201
+ console.print(f"[{style}]{icon}[/{style}] {mapping.remote_id} → {mapping.local}")
202
+ if result.message:
203
+ console.print(f" [dim]{result.message}[/dim]")
204
+
205
+ if had_error:
206
+ raise typer.Exit(1)
207
+
208
+
209
+ # ─────────────────────────────────────────────────────────────────────────────
210
+ # status command
211
+ # ─────────────────────────────────────────────────────────────────────────────
212
+
213
+ @app.command()
214
+ def status(
215
+ config_path: Optional[str] = typer.Option(None, "--config", "-c"),
216
+ ) -> None:
217
+ """Show current mapping status."""
218
+ config = load_config(config_path)
219
+
220
+ if not config.mappings:
221
+ console.print("[yellow]No mappings configured.[/yellow] Add entries to markgate.yaml.")
222
+ return
223
+
224
+ table = Table(title="docspan mappings")
225
+ table.add_column("Local file", style="cyan")
226
+ table.add_column("Backend", style="magenta")
227
+ table.add_column("Remote ID")
228
+ table.add_column("Direction")
229
+
230
+ for m in config.mappings:
231
+ table.add_row(m.local, m.backend, m.remote_id, m.direction)
232
+
233
+ console.print(table)
234
+
235
+
236
+ # ─────────────────────────────────────────────────────────────────────────────
237
+ # auth subcommand
238
+ # ─────────────────────────────────────────────────────────────────────────────
239
+
240
+ @auth_app.command("setup")
241
+ def auth_setup(
242
+ backend: str = typer.Argument(..., help="Backend to authenticate: google_docs | confluence"),
243
+ config_path: Optional[str] = typer.Option(None, "--config", "-c"),
244
+ ) -> None:
245
+ """Interactive authentication setup for a backend."""
246
+ config = load_config(config_path)
247
+ cls = BACKENDS.get(backend)
248
+ if not cls:
249
+ err_console.print(f"Unknown backend '{backend}'. Available: {list(BACKENDS.keys())}")
250
+ raise typer.Exit(1)
251
+ b = cls.from_config(config)
252
+ b.auth_setup()
253
+
254
+
255
+ # ─────────────────────────────────────────────────────────────────────────────
256
+ # conflicts subcommand
257
+ # ─────────────────────────────────────────────────────────────────────────────
258
+
259
+ @conflicts_app.command("list")
260
+ def conflicts_list(
261
+ config_path: Optional[str] = typer.Option(None, "--config", "-c"),
262
+ ) -> None:
263
+ """List files with unresolved merge conflicts."""
264
+ state_path = get_state_path(config_path)
265
+ state = _load_state(state_path)
266
+
267
+ conflicted = []
268
+ for local_path in state.mappings:
269
+ if not os.path.exists(local_path):
270
+ continue
271
+ with open(local_path, encoding="utf-8") as fh:
272
+ content = fh.read()
273
+ count = sum(1 for line in content.splitlines() if line.startswith("<<<<<<< "))
274
+ if count > 0:
275
+ conflicted.append((local_path, count))
276
+
277
+ if not conflicted:
278
+ console.print("No unresolved conflicts.")
279
+ return
280
+
281
+ table = Table(title="Files with merge conflicts")
282
+ table.add_column("File", style="cyan")
283
+ table.add_column("Conflict blocks", style="red")
284
+ for local_path, count in conflicted:
285
+ table.add_row(local_path, str(count))
286
+ console.print(table)
287
+
288
+
289
+ @conflicts_app.command("resolve")
290
+ def conflicts_resolve(
291
+ file: str = typer.Argument(..., help="Local file path to resolve"),
292
+ accept: str = typer.Option(..., "--accept", help="Resolution strategy: remote | local | merged"),
293
+ config_path: Optional[str] = typer.Option(None, "--config", "-c"),
294
+ ) -> None:
295
+ """Resolve a merge conflict in a tracked file."""
296
+ if accept not in ("remote", "local", "merged"):
297
+ err_console.print("--accept must be one of: remote, local, merged")
298
+ raise typer.Exit(1)
299
+
300
+ state_path = get_state_path(config_path)
301
+ state_dir = get_state_dir(config_path)
302
+ state = _load_state(state_path)
303
+
304
+ entry = state.get(file)
305
+ if entry is None:
306
+ err_console.print(f"File '{file}' is not tracked in .markgate-state.json")
307
+ raise typer.Exit(1)
308
+
309
+ config = load_config(config_path)
310
+ backend = _get_backend(entry.backend, config)
311
+
312
+ if accept == "remote":
313
+ _resolve_remote(file, entry, backend, state, state_path, state_dir)
314
+ elif accept == "local":
315
+ _resolve_local(file, entry, state, state_path, state_dir)
316
+ elif accept == "merged":
317
+ _resolve_merged(file, entry, state, state_path, state_dir)
318
+
319
+
320
+ def _resolve_remote(
321
+ file: str,
322
+ entry: MappingState,
323
+ backend,
324
+ state: SyncState,
325
+ state_path: str,
326
+ state_dir: str,
327
+ ) -> None:
328
+ result = backend.pull(entry.doc_id, file)
329
+ if result.status != "ok":
330
+ err_console.print(f"Could not re-fetch remote: {result.message}")
331
+ raise typer.Exit(1)
332
+ orig_path = file + ORIG_SUFFIX
333
+ if os.path.exists(orig_path):
334
+ os.unlink(orig_path)
335
+ with open(file, encoding="utf-8") as fh:
336
+ new_content = fh.read()
337
+ try:
338
+ remote_version = backend.get_remote_version(entry.doc_id)
339
+ except Exception:
340
+ logger.warning(
341
+ "Could not fetch remote version for %s after resolve; retaining stale version %s",
342
+ entry.doc_id, entry.remote_version, exc_info=True,
343
+ )
344
+ remote_version = entry.remote_version
345
+ record_state(state, state_path, state_dir, file, entry.doc_id, entry.backend, new_content, remote_version)
346
+ console.print(f"[green]Resolved[/green] {file} (accepted remote)")
347
+
348
+
349
+ def _resolve_local(
350
+ file: str,
351
+ entry: MappingState,
352
+ state: SyncState,
353
+ state_path: str,
354
+ state_dir: str,
355
+ ) -> None:
356
+ orig_path = file + ORIG_SUFFIX
357
+ if os.path.exists(orig_path):
358
+ shutil.copy2(orig_path, file)
359
+ os.unlink(orig_path)
360
+ console.print(f"[green]Restored[/green] {file} from {orig_path}")
361
+ else:
362
+ base_content = get_base_content(state_dir, entry.base_hash)
363
+ if base_content:
364
+ with open(file, "w", encoding="utf-8") as fh:
365
+ fh.write(base_content)
366
+ console.print("[yellow]Warning:[/yellow] .orig not found; restored from base content")
367
+ else:
368
+ err_console.print(f"No .orig file and no base content for '{file}'. Cannot restore.")
369
+ raise typer.Exit(1)
370
+ with open(file, encoding="utf-8") as fh:
371
+ new_content = fh.read()
372
+ record_state(state, state_path, state_dir, file, entry.doc_id, entry.backend, new_content, entry.remote_version)
373
+ console.print(f"[green]Resolved[/green] {file} (accepted local)")
374
+
375
+
376
+ def _resolve_merged(
377
+ file: str,
378
+ entry: MappingState,
379
+ state: SyncState,
380
+ state_path: str,
381
+ state_dir: str,
382
+ ) -> None:
383
+ if not os.path.exists(file):
384
+ err_console.print(f"File '{file}' does not exist")
385
+ raise typer.Exit(1)
386
+ with open(file, encoding="utf-8") as fh:
387
+ content = fh.read()
388
+ if "<<<<<<< " in content:
389
+ err_console.print(
390
+ f"File '{file}' still contains conflict markers. "
391
+ "Resolve all conflicts before accepting as merged."
392
+ )
393
+ raise typer.Exit(1)
394
+ record_state(state, state_path, state_dir, file, entry.doc_id, entry.backend, content, entry.remote_version)
395
+ console.print(f"[green]Resolved[/green] {file} (accepted merged)")
396
+
397
+
398
+
399
+ # ─────────────────────────────────────────────────────────────────────────────
400
+ # Entry point
401
+ # ─────────────────────────────────────────────────────────────────────────────
402
+
403
+ def main() -> None:
404
+ app()
405
+
406
+
407
+ if __name__ == "__main__":
408
+ main()
docspan/config.py ADDED
@@ -0,0 +1,62 @@
1
+ """markgate.yaml loader and config model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import pathlib
7
+ from typing import Literal, Optional
8
+
9
+ import yaml
10
+ from pydantic import BaseModel
11
+
12
+ CONFIG_FILENAME = "markgate.yaml"
13
+
14
+
15
+ class GoogleDocsConfig(BaseModel):
16
+ credentials_path: Optional[str] = None
17
+ token_path: Optional[str] = ".markgate/google_token.json"
18
+
19
+
20
+ class ConfluenceConfig(BaseModel):
21
+ base_url: Optional[str] = None
22
+ username: Optional[str] = None
23
+ api_token: Optional[str] = None
24
+
25
+
26
+ class BackendsConfig(BaseModel):
27
+ google_docs: Optional[GoogleDocsConfig] = None
28
+ confluence: Optional[ConfluenceConfig] = None
29
+
30
+
31
+ class Mapping(BaseModel):
32
+ local: str # relative path to local markdown file
33
+ backend: str # "google_docs" or "confluence"
34
+ remote_id: str # Google Doc ID or Confluence page ID
35
+ direction: Literal["push", "pull", "both"] = "both"
36
+
37
+
38
+ class MarkgateConfig(BaseModel):
39
+ backends: BackendsConfig = BackendsConfig()
40
+ mappings: list[Mapping] = []
41
+
42
+
43
+ def load_config(path: Optional[str] = None) -> MarkgateConfig:
44
+ """Load markgate.yaml, falling back to env vars for credentials."""
45
+ config_path = pathlib.Path(path or CONFIG_FILENAME)
46
+
47
+ raw: dict = {}
48
+ if config_path.exists():
49
+ with open(config_path) as f:
50
+ raw = yaml.safe_load(f) or {}
51
+
52
+ # Env var overrides for Confluence (backwards compat with markdown-confluence)
53
+ if "backends" not in raw:
54
+ raw["backends"] = {}
55
+ if "confluence" not in raw["backends"]:
56
+ raw["backends"]["confluence"] = {}
57
+ cf = raw["backends"]["confluence"]
58
+ cf.setdefault("base_url", os.getenv("CONFLUENCE_BASE_URL"))
59
+ cf.setdefault("username", os.getenv("ATLASSIAN_USER_NAME"))
60
+ cf.setdefault("api_token", os.getenv("CONFLUENCE_API_TOKEN"))
61
+
62
+ return MarkgateConfig(**raw)
@@ -0,0 +1,49 @@
1
+ from docspan.core.merge import MergeResult, three_way_merge
2
+ from docspan.core.orchestrator import (
3
+ PullOutcome,
4
+ PushOutcome,
5
+ get_base_content,
6
+ get_state_dir,
7
+ get_state_path,
8
+ orchestrate_pull,
9
+ orchestrate_push,
10
+ record_state,
11
+ save_base_content,
12
+ )
13
+ from docspan.core.paths import (
14
+ BASE_FILE_SUFFIX,
15
+ BASE_STORE_DIR,
16
+ COMMENTS_SUFFIX,
17
+ GOOGLE_TOKEN_PATH,
18
+ ORIG_SUFFIX,
19
+ STATE_FILENAME,
20
+ )
21
+ from docspan.core.state import MappingState, SyncState, sha256_of_content, sha256_of_file
22
+
23
+ __all__ = [
24
+ # state
25
+ "SyncState",
26
+ "MappingState",
27
+ "sha256_of_file",
28
+ "sha256_of_content",
29
+ # merge
30
+ "MergeResult",
31
+ "three_way_merge",
32
+ # orchestrator
33
+ "PushOutcome",
34
+ "PullOutcome",
35
+ "get_state_path",
36
+ "get_state_dir",
37
+ "get_base_content",
38
+ "save_base_content",
39
+ "orchestrate_push",
40
+ "orchestrate_pull",
41
+ "record_state",
42
+ # paths
43
+ "STATE_FILENAME",
44
+ "BASE_STORE_DIR",
45
+ "BASE_FILE_SUFFIX",
46
+ "ORIG_SUFFIX",
47
+ "COMMENTS_SUFFIX",
48
+ "GOOGLE_TOKEN_PATH",
49
+ ]
docspan/core/merge.py ADDED
@@ -0,0 +1,30 @@
1
+ """Three-way merge using the merge3 package."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass
8
+ class MergeResult:
9
+ merged: str
10
+ has_conflicts: bool
11
+ conflict_count: int
12
+
13
+
14
+ def three_way_merge(base: str, theirs: str, ours: str) -> MergeResult:
15
+ from merge3 import Merge3
16
+
17
+ base_lines = base.splitlines(keepends=True)
18
+ ours_lines = ours.splitlines(keepends=True)
19
+ theirs_lines = theirs.splitlines(keepends=True)
20
+ m3 = Merge3(base_lines, ours_lines, theirs_lines)
21
+ merged_lines = list(m3.merge_lines(
22
+ name_a="ours",
23
+ name_b="theirs",
24
+ start_marker="<<<<<<< ours",
25
+ mid_marker="=======",
26
+ end_marker=">>>>>>> theirs",
27
+ ))
28
+ merged = "".join(merged_lines)
29
+ conflicts = sum(1 for line in merged_lines if line.startswith("<<<<<<<"))
30
+ return MergeResult(merged=merged, has_conflicts=conflicts > 0, conflict_count=conflicts)