feat(cc): Add ConflictCatcher v1(Python)
Python tool to check for any merge or rebase conflicts between two branches All operations done in temporary git worktrees leaving all dev repo's untouched
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
# Conflict Catcher
|
||||||
|
|
||||||
|
Conflict Catcher is a tiny local web app for checking whether two Git branches are likely to conflict before you run a real merge or rebase.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python conflict_catcher.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open the printed local URL. By default it is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:8765
|
||||||
|
```
|
||||||
|
|
||||||
|
## Branch Spec
|
||||||
|
|
||||||
|
Use this format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
main<develop
|
||||||
|
```
|
||||||
|
|
||||||
|
The left side is the target/base branch. The right side is the incoming/source branch.
|
||||||
|
|
||||||
|
- Merge check: tests whether `develop` can merge into `main`.
|
||||||
|
- Rebase check: tests whether `develop` can rebase onto `main`.
|
||||||
|
|
||||||
|
The app creates temporary detached worktrees for its dry runs and removes them after each check. It does not merge, rebase, commit, or change your active working tree.
|
||||||
|
|
||||||
@@ -0,0 +1,702 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import webbrowser
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
APP_TITLE = "Conflict Catcher"
|
||||||
|
DEFAULT_HOST = "127.0.0.1"
|
||||||
|
DEFAULT_PORT = 8765
|
||||||
|
|
||||||
|
|
||||||
|
class CheckError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GitResult:
|
||||||
|
code: int
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def combined(self) -> str:
|
||||||
|
return "\n".join(part for part in (self.stdout, self.stderr) if part).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def run_git(repo: Path, args: list[str], *, cwd: Path | None = None, timeout: int = 120) -> GitResult:
|
||||||
|
command = ["git", *args]
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
command,
|
||||||
|
cwd=str(cwd or repo),
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=timeout,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise CheckError("Git was not found on PATH. Install Git and try again.") from exc
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
raise CheckError(f"Git command timed out: {' '.join(command)}") from exc
|
||||||
|
|
||||||
|
return GitResult(completed.returncode, completed.stdout.strip(), completed.stderr.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def repo_root(path: str) -> Path:
|
||||||
|
if not path or not path.strip():
|
||||||
|
raise CheckError("Choose a Git repository path.")
|
||||||
|
|
||||||
|
candidate = Path(path).expanduser().resolve()
|
||||||
|
if not candidate.exists():
|
||||||
|
raise CheckError(f"Path does not exist: {candidate}")
|
||||||
|
|
||||||
|
result = run_git(candidate, ["rev-parse", "--show-toplevel"])
|
||||||
|
if result.code != 0:
|
||||||
|
raise CheckError(f"Not a Git repository: {candidate}")
|
||||||
|
|
||||||
|
return Path(result.stdout).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_branch_spec(spec: str, target: str, source: str) -> tuple[str, str]:
|
||||||
|
spec = (spec or "").strip()
|
||||||
|
target = (target or "").strip()
|
||||||
|
source = (source or "").strip()
|
||||||
|
|
||||||
|
if spec:
|
||||||
|
if "<" not in spec:
|
||||||
|
raise CheckError("Branch spec must look like main<develop.")
|
||||||
|
left, right = [part.strip() for part in spec.split("<", 1)]
|
||||||
|
if not left or not right:
|
||||||
|
raise CheckError("Branch spec must include both sides, for example main<develop.")
|
||||||
|
return left, right
|
||||||
|
|
||||||
|
if not target or not source:
|
||||||
|
raise CheckError("Enter a branch spec like main<develop or fill both branch fields.")
|
||||||
|
|
||||||
|
return target, source
|
||||||
|
|
||||||
|
|
||||||
|
def verify_commit(repo: Path, ref: str, label: str) -> str:
|
||||||
|
result = run_git(repo, ["rev-parse", "--verify", f"{ref}^{{commit}}"])
|
||||||
|
if result.code != 0:
|
||||||
|
raise CheckError(f"Could not find {label} branch/ref: {ref}")
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def list_conflicts(worktree: Path) -> list[dict[str, str]]:
|
||||||
|
result = run_git(worktree, ["diff", "--name-status", "--diff-filter=U"], cwd=worktree)
|
||||||
|
conflicts: list[dict[str, str]] = []
|
||||||
|
if result.code != 0 or not result.stdout:
|
||||||
|
return conflicts
|
||||||
|
|
||||||
|
status_names = {
|
||||||
|
"AA": "both added",
|
||||||
|
"AU": "added by us",
|
||||||
|
"UA": "added by them",
|
||||||
|
"DD": "both deleted",
|
||||||
|
"DU": "deleted by us",
|
||||||
|
"UD": "deleted by them",
|
||||||
|
"UU": "both modified",
|
||||||
|
"U": "unmerged",
|
||||||
|
}
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
parts = line.split("\t", 1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
conflicts.append(
|
||||||
|
{
|
||||||
|
"status": parts[0],
|
||||||
|
"meaning": status_names.get(parts[0], "unmerged"),
|
||||||
|
"path": parts[1],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return conflicts
|
||||||
|
|
||||||
|
|
||||||
|
def make_worktree(repo: Path, ref: str) -> Path:
|
||||||
|
temp_parent = Path(tempfile.mkdtemp(prefix="conflict-catcher-"))
|
||||||
|
worktree = temp_parent / "worktree"
|
||||||
|
result = run_git(repo, ["worktree", "add", "--detach", str(worktree), ref], timeout=180)
|
||||||
|
if result.code != 0:
|
||||||
|
shutil.rmtree(temp_parent, ignore_errors=True)
|
||||||
|
raise CheckError(f"Could not create a temporary worktree for {ref}.\n{result.combined}")
|
||||||
|
return worktree
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_worktree(repo: Path, worktree: Path) -> None:
|
||||||
|
run_git(repo, ["worktree", "remove", "--force", str(worktree)], timeout=180)
|
||||||
|
shutil.rmtree(worktree.parent, ignore_errors=True)
|
||||||
|
run_git(repo, ["worktree", "prune"], timeout=180)
|
||||||
|
|
||||||
|
|
||||||
|
def dry_run_merge(repo: Path, target: str, source: str) -> dict[str, Any]:
|
||||||
|
worktree = make_worktree(repo, target)
|
||||||
|
try:
|
||||||
|
result = run_git(
|
||||||
|
worktree,
|
||||||
|
["merge", "--no-commit", "--no-ff", source],
|
||||||
|
cwd=worktree,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
conflicts = list_conflicts(worktree)
|
||||||
|
ok = result.code == 0 and not conflicts
|
||||||
|
summary = (
|
||||||
|
f"{source} can be merged into {target} without conflicts."
|
||||||
|
if ok
|
||||||
|
else f"{source} would conflict when merged into {target}."
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": ok,
|
||||||
|
"operation": "merge",
|
||||||
|
"summary": summary,
|
||||||
|
"conflicts": conflicts,
|
||||||
|
"gitOutput": result.combined,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
run_git(worktree, ["merge", "--abort"], cwd=worktree)
|
||||||
|
cleanup_worktree(repo, worktree)
|
||||||
|
|
||||||
|
|
||||||
|
def dry_run_rebase(repo: Path, target: str, source: str) -> dict[str, Any]:
|
||||||
|
worktree = make_worktree(repo, source)
|
||||||
|
try:
|
||||||
|
result = run_git(
|
||||||
|
worktree,
|
||||||
|
["rebase", "--no-verify", target],
|
||||||
|
cwd=worktree,
|
||||||
|
timeout=300,
|
||||||
|
)
|
||||||
|
conflicts = list_conflicts(worktree)
|
||||||
|
ok = result.code == 0 and not conflicts
|
||||||
|
summary = (
|
||||||
|
f"{source} can be rebased onto {target} without conflicts."
|
||||||
|
if ok
|
||||||
|
else f"{source} would conflict when rebased onto {target}."
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": ok,
|
||||||
|
"operation": "rebase",
|
||||||
|
"summary": summary,
|
||||||
|
"conflicts": conflicts,
|
||||||
|
"gitOutput": result.combined,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
run_git(worktree, ["rebase", "--abort"], cwd=worktree)
|
||||||
|
cleanup_worktree(repo, worktree)
|
||||||
|
|
||||||
|
|
||||||
|
def check_conflicts(payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
repo = repo_root(str(payload.get("repoPath", "")))
|
||||||
|
target, source = parse_branch_spec(
|
||||||
|
str(payload.get("branchSpec", "")),
|
||||||
|
str(payload.get("targetBranch", "")),
|
||||||
|
str(payload.get("sourceBranch", "")),
|
||||||
|
)
|
||||||
|
operation = str(payload.get("operation", "both")).lower()
|
||||||
|
if operation not in {"merge", "rebase", "both"}:
|
||||||
|
raise CheckError("Operation must be merge, rebase, or both.")
|
||||||
|
|
||||||
|
target_sha = verify_commit(repo, target, "target")
|
||||||
|
source_sha = verify_commit(repo, source, "source")
|
||||||
|
|
||||||
|
checks: list[dict[str, Any]] = []
|
||||||
|
if operation in {"merge", "both"}:
|
||||||
|
checks.append(dry_run_merge(repo, target, source))
|
||||||
|
if operation in {"rebase", "both"}:
|
||||||
|
checks.append(dry_run_rebase(repo, target, source))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"repo": str(repo),
|
||||||
|
"target": target,
|
||||||
|
"source": source,
|
||||||
|
"targetSha": target_sha[:12],
|
||||||
|
"sourceSha": source_sha[:12],
|
||||||
|
"checks": checks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
INDEX_HTML = r"""<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Conflict Catcher</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--ink: #17212b;
|
||||||
|
--muted: #5f6f7d;
|
||||||
|
--line: #d9e0e5;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--page: #f6f7f2;
|
||||||
|
--accent: #146c63;
|
||||||
|
--accent-ink: #ffffff;
|
||||||
|
--warn: #b54708;
|
||||||
|
--danger: #b42318;
|
||||||
|
--success: #177245;
|
||||||
|
--shadow: 0 16px 40px rgba(23, 33, 43, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(20, 108, 99, 0.08) 1px, transparent 1px),
|
||||||
|
linear-gradient(0deg, rgba(20, 108, 99, 0.08) 1px, transparent 1px),
|
||||||
|
var(--page);
|
||||||
|
background-size: 32px 32px;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
width: min(1120px, calc(100% - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: clamp(2rem, 5vw, 4.8rem);
|
||||||
|
line-height: 0.95;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, 420px) 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
form, .results {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
form { padding: 18px; }
|
||||||
|
.results { min-height: 468px; overflow: hidden; }
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
padding: 0 12px;
|
||||||
|
color: var(--ink);
|
||||||
|
background: #fbfcfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, select:focus, button:focus {
|
||||||
|
outline: 3px solid rgba(20, 108, 99, 0.18);
|
||||||
|
outline-offset: 1px;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
border: 0;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-ink);
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: -4px 0 16px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: #fbfcfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-body { padding: 18px; }
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
min-height: 380px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 28px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.ok { background: var(--success); }
|
||||||
|
.badge.bad { background: var(--danger); }
|
||||||
|
.badge.warn { background: var(--warn); }
|
||||||
|
|
||||||
|
.check {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2, h3 {
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { font-size: 1rem; }
|
||||||
|
h3 { font-size: 0.95rem; }
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 10px 8px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
th { color: var(--muted); font-size: 0.78rem; text-transform: uppercase; }
|
||||||
|
td code { overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
pre {
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
background: #17212b;
|
||||||
|
color: #e7edf0;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta strong { color: var(--ink); }
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
header, .shell, .row, .meta { grid-template-columns: 1fr; }
|
||||||
|
header { display: grid; align-items: start; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<h1>Conflict Catcher</h1>
|
||||||
|
<p>Dry-run branch merge and rebase checks before you touch the real working tree.</p>
|
||||||
|
</div>
|
||||||
|
<span class="badge">Git preflight</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="shell">
|
||||||
|
<form id="check-form">
|
||||||
|
<label>
|
||||||
|
Repository path
|
||||||
|
<input id="repo-path" name="repoPath" placeholder="C:\Users\you\code\repo" autocomplete="off" required>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Branch spec
|
||||||
|
<input id="branch-spec" name="branchSpec" placeholder="main<develop" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<p class="hint">Left side is the base/target. Right side is the incoming/source.</p>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label>
|
||||||
|
Target
|
||||||
|
<input name="targetBranch" placeholder="main" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Source
|
||||||
|
<input name="sourceBranch" placeholder="develop" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Check
|
||||||
|
<select name="operation">
|
||||||
|
<option value="both">Merge and rebase</option>
|
||||||
|
<option value="merge">Merge only</option>
|
||||||
|
<option value="rebase">Rebase only</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button id="submit-button" type="submit">
|
||||||
|
<span aria-hidden="true">✓</span>
|
||||||
|
Check conflicts
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section class="results" aria-live="polite">
|
||||||
|
<div class="result-head">
|
||||||
|
<div>
|
||||||
|
<h2>Results</h2>
|
||||||
|
<p id="result-subtitle">Waiting for a repository and branch pair.</p>
|
||||||
|
</div>
|
||||||
|
<span id="overall-badge" class="badge">Idle</span>
|
||||||
|
</div>
|
||||||
|
<div id="result-body" class="result-body empty">
|
||||||
|
<p>Enter a repo path and a branch pair such as <strong>main<develop</strong>.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const form = document.querySelector("#check-form");
|
||||||
|
const button = document.querySelector("#submit-button");
|
||||||
|
const subtitle = document.querySelector("#result-subtitle");
|
||||||
|
const badge = document.querySelector("#overall-badge");
|
||||||
|
const body = document.querySelector("#result-body");
|
||||||
|
|
||||||
|
const escapeHtml = (value) => String(value ?? "")
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
|
||||||
|
const setBadge = (text, className) => {
|
||||||
|
badge.textContent = text;
|
||||||
|
badge.className = `badge ${className || ""}`.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCheck = (check) => {
|
||||||
|
const rows = check.conflicts.map((item) => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${escapeHtml(item.status)}</strong></td>
|
||||||
|
<td>${escapeHtml(item.meaning)}</td>
|
||||||
|
<td><code>${escapeHtml(item.path)}</code></td>
|
||||||
|
</tr>
|
||||||
|
`).join("");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<article class="check">
|
||||||
|
<div class="check-title">
|
||||||
|
<h3>${escapeHtml(check.operation.toUpperCase())}</h3>
|
||||||
|
<span class="badge ${check.ok ? "ok" : "bad"}">${check.ok ? "Clean" : "Conflicts"}</span>
|
||||||
|
</div>
|
||||||
|
<p>${escapeHtml(check.summary)}</p>
|
||||||
|
${check.conflicts.length ? `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Status</th><th>Meaning</th><th>Path</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
` : ""}
|
||||||
|
${check.gitOutput ? `<pre>${escapeHtml(check.gitOutput)}</pre>` : ""}
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderResult = (data) => {
|
||||||
|
const hasConflict = data.checks.some((check) => !check.ok);
|
||||||
|
setBadge(hasConflict ? "Attention" : "Clean", hasConflict ? "bad" : "ok");
|
||||||
|
subtitle.textContent = `${data.source} -> ${data.target}`;
|
||||||
|
body.className = "result-body";
|
||||||
|
body.innerHTML = `
|
||||||
|
<div class="meta">
|
||||||
|
<div><strong>Repo:</strong> ${escapeHtml(data.repo)}</div>
|
||||||
|
<div><strong>Refs:</strong> ${escapeHtml(data.targetSha)} ← ${escapeHtml(data.sourceSha)}</div>
|
||||||
|
<div><strong>Target/base:</strong> ${escapeHtml(data.target)}</div>
|
||||||
|
<div><strong>Source/incoming:</strong> ${escapeHtml(data.source)}</div>
|
||||||
|
</div>
|
||||||
|
${data.checks.map(renderCheck).join("")}
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = "Checking...";
|
||||||
|
setBadge("Running", "warn");
|
||||||
|
subtitle.textContent = "Creating temporary worktrees and asking Git.";
|
||||||
|
body.className = "result-body empty";
|
||||||
|
body.innerHTML = "<p>Checking merge and rebase preflight...</p>";
|
||||||
|
|
||||||
|
const payload = Object.fromEntries(new FormData(form).entries());
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/check", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.error || "Conflict check failed.");
|
||||||
|
renderResult(data);
|
||||||
|
} catch (error) {
|
||||||
|
setBadge("Error", "bad");
|
||||||
|
subtitle.textContent = "The check could not finish.";
|
||||||
|
body.className = "result-body";
|
||||||
|
body.innerHTML = `<article class="check"><p>${escapeHtml(error.message)}</p></article>`;
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
button.innerHTML = '<span aria-hidden="true">✓</span> Check conflicts';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictCatcherHandler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "ConflictCatcher/1.0"
|
||||||
|
|
||||||
|
def log_message(self, format: str, *args: Any) -> None:
|
||||||
|
sys.stderr.write("%s - %s\n" % (self.address_string(), format % args))
|
||||||
|
|
||||||
|
def send_json(self, status: int, payload: dict[str, Any]) -> None:
|
||||||
|
encoded = json.dumps(payload).encode("utf-8")
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(encoded)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(encoded)
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
if self.path not in {"/", "/index.html"}:
|
||||||
|
self.send_error(404)
|
||||||
|
return
|
||||||
|
|
||||||
|
encoded = INDEX_HTML.encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(encoded)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(encoded)
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
if self.path != "/api/check":
|
||||||
|
self.send_error(404)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
payload = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||||
|
self.send_json(200, check_conflicts(payload))
|
||||||
|
except CheckError as exc:
|
||||||
|
self.send_json(400, {"error": str(exc)})
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
self.send_json(400, {"error": "Request body must be valid JSON."})
|
||||||
|
except Exception as exc:
|
||||||
|
self.send_json(500, {"error": f"Unexpected error: {exc}"})
|
||||||
|
|
||||||
|
|
||||||
|
def run_server(host: str, port: int, open_browser: bool) -> None:
|
||||||
|
server = ThreadingHTTPServer((host, port), ConflictCatcherHandler)
|
||||||
|
url = f"http://{host}:{server.server_port}"
|
||||||
|
if open_browser:
|
||||||
|
threading.Timer(0.4, lambda: webbrowser.open(url)).start()
|
||||||
|
print(f"{APP_TITLE} running at {url}")
|
||||||
|
print("Press Ctrl+C to stop.")
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description="Check Git branch conflicts before merging or rebasing.")
|
||||||
|
parser.add_argument("--host", default=os.environ.get("CONFLICT_CATCHER_HOST", DEFAULT_HOST))
|
||||||
|
parser.add_argument("--port", type=int, default=int(os.environ.get("CONFLICT_CATCHER_PORT", DEFAULT_PORT)))
|
||||||
|
parser.add_argument("--no-browser", action="store_true", help="Start the server without opening a browser.")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
try:
|
||||||
|
run_server(args.host, args.port, not args.no_browser)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nStopped.")
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from conflict_catcher import CheckError, check_conflicts, parse_branch_spec, run_git
|
||||||
|
|
||||||
|
|
||||||
|
def git(repo: Path, *args: str) -> None:
|
||||||
|
result = run_git(repo, list(args))
|
||||||
|
if result.code != 0:
|
||||||
|
raise AssertionError(result.combined)
|
||||||
|
|
||||||
|
|
||||||
|
def write(path: Path, text: str) -> None:
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictCatcherTests(unittest.TestCase):
|
||||||
|
def test_parse_branch_spec(self) -> None:
|
||||||
|
self.assertEqual(parse_branch_spec("main<develop", "", ""), ("main", "develop"))
|
||||||
|
self.assertEqual(parse_branch_spec("", "main", "develop"), ("main", "develop"))
|
||||||
|
with self.assertRaises(CheckError):
|
||||||
|
parse_branch_spec("main", "", "")
|
||||||
|
|
||||||
|
def test_detects_merge_and_rebase_conflicts(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as temp:
|
||||||
|
repo = Path(temp)
|
||||||
|
git(repo, "init", "-b", "main")
|
||||||
|
git(repo, "config", "user.email", "test@example.com")
|
||||||
|
git(repo, "config", "user.name", "Test User")
|
||||||
|
write(repo / "note.txt", "base\n")
|
||||||
|
git(repo, "add", "note.txt")
|
||||||
|
git(repo, "commit", "-m", "base")
|
||||||
|
|
||||||
|
git(repo, "checkout", "-b", "develop")
|
||||||
|
write(repo / "note.txt", "develop\n")
|
||||||
|
git(repo, "commit", "-am", "develop edit")
|
||||||
|
|
||||||
|
git(repo, "checkout", "main")
|
||||||
|
write(repo / "note.txt", "main\n")
|
||||||
|
git(repo, "commit", "-am", "main edit")
|
||||||
|
|
||||||
|
result = check_conflicts(
|
||||||
|
{
|
||||||
|
"repoPath": str(repo),
|
||||||
|
"branchSpec": "main<develop",
|
||||||
|
"operation": "both",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["target"], "main")
|
||||||
|
self.assertEqual(result["source"], "develop")
|
||||||
|
self.assertEqual(len(result["checks"]), 2)
|
||||||
|
self.assertTrue(all(not check["ok"] for check in result["checks"]))
|
||||||
|
self.assertTrue(
|
||||||
|
all(
|
||||||
|
any(conflict["path"] == "note.txt" for conflict in check["conflicts"])
|
||||||
|
for check in result["checks"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user