Files
Tools/cc/conflict_catcher.py
T

1871 lines
56 KiB
Python

from __future__ import annotations
import argparse
import json
import os
import shlex
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
from urllib.parse import urlparse
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 git_lines(repo: Path, args: list[str]) -> list[str]:
result = run_git(repo, args)
if result.code != 0:
raise CheckError(result.combined or f"Git command failed: {' '.join(args)}")
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
def list_branches(repo: Path) -> dict[str, Any]:
current = run_git(repo, ["branch", "--show-current"]).stdout.strip()
local = git_lines(repo, ["branch", "--format=%(refname:short)"])
remote = [
branch
for branch in git_lines(repo, ["branch", "--remotes", "--format=%(refname:short)"])
if "/" in branch and not branch.endswith("/HEAD")
]
return {
"repo": str(repo),
"current": current,
"local": local,
"remote": remote,
"all": sorted(dict.fromkeys([*local, *remote])),
}
def fetch_updates(repo: Path) -> dict[str, Any]:
result = run_git(repo, ["fetch", "--all", "--prune"], timeout=300)
if result.code != 0:
raise CheckError(f"Fetch failed.\n{result.combined}")
return {
"ok": True,
"summary": "Fetched all remotes and pruned stale remote-tracking branches.",
"gitOutput": result.combined,
}
def upstream_status(repo: Path, ref: str) -> dict[str, Any]:
upstream = run_git(repo, ["rev-parse", "--abbrev-ref", f"{ref}@{{upstream}}"])
if upstream.code != 0 or not upstream.stdout.strip():
return {
"ref": ref,
"upstream": "",
"ahead": None,
"behind": None,
"summary": f"{ref} has no configured upstream.",
"state": "unknown",
}
upstream_ref = upstream.stdout.strip()
counts = run_git(repo, ["rev-list", "--left-right", "--count", f"{ref}...{upstream_ref}"])
if counts.code != 0:
return {
"ref": ref,
"upstream": upstream_ref,
"ahead": None,
"behind": None,
"summary": f"Could not compare {ref} with {upstream_ref}.",
"state": "unknown",
}
left, right = (counts.stdout.split() + ["0", "0"])[:2]
ahead = int(left)
behind = int(right)
if ahead == 0 and behind == 0:
summary = f"{ref} is up to date with {upstream_ref}."
state = "ok"
elif behind:
summary = f"{ref} is {behind} commit{'s' if behind != 1 else ''} behind {upstream_ref}."
state = "behind"
else:
summary = f"{ref} is {ahead} commit{'s' if ahead != 1 else ''} ahead of {upstream_ref}."
state = "ahead"
return {
"ref": ref,
"upstream": upstream_ref,
"ahead": ahead,
"behind": behind,
"summary": summary,
"state": state,
}
def compare_freshness(repo: Path, target: str, source: str) -> list[dict[str, Any]]:
return [upstream_status(repo, target), upstream_status(repo, source)]
def resolve_repo_file(repo: Path, relative_path: str) -> Path:
if not relative_path or not str(relative_path).strip():
raise CheckError("Choose a file path.")
candidate = (repo / relative_path).resolve()
try:
candidate.relative_to(repo)
except ValueError as exc:
raise CheckError("File path must stay inside the selected repository.") from exc
return candidate
def reveal_path(payload: dict[str, Any]) -> dict[str, Any]:
repo = repo_root(str(payload.get("repoPath", "")))
target = resolve_repo_file(repo, str(payload.get("path", "")))
reveal_target = target if target.exists() else target.parent
if sys.platform.startswith("win"):
subprocess.Popen(["explorer", "/select,", str(reveal_target)])
else:
opener = "open" if sys.platform == "darwin" else "xdg-open"
subprocess.Popen([opener, str(reveal_target.parent if reveal_target.is_file() else reveal_target)])
return {"ok": True, "path": str(target)}
def open_in_editor(payload: dict[str, Any]) -> dict[str, Any]:
repo = repo_root(str(payload.get("repoPath", "")))
target = resolve_repo_file(repo, str(payload.get("path", "")))
editor_command = str(payload.get("editorCommand", "")).strip()
if editor_command:
parts = shlex.split(editor_command, posix=not sys.platform.startswith("win"))
if not parts:
raise CheckError("Editor command is empty.")
command = [part.replace("{path}", str(target)) for part in parts]
if not any("{path}" in part for part in parts):
command.append(str(target))
subprocess.Popen(command, cwd=str(repo))
elif sys.platform.startswith("win"):
os.startfile(str(target)) # type: ignore[attr-defined]
else:
opener = "open" if sys.platform == "darwin" else "xdg-open"
subprocess.Popen([opener, str(target)])
return {"ok": True, "path": str(target)}
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.")
fetch_result = None
if bool(payload.get("fetchBeforeCheck")):
fetch_result = fetch_updates(repo)
target_sha = verify_commit(repo, target, "target")
source_sha = verify_commit(repo, source, "source")
freshness = compare_freshness(repo, target, 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],
"fetch": fetch_result,
"freshness": freshness,
"checks": checks,
}
def generate_report(data: dict[str, Any]) -> str:
checks = data.get("checks", [])
has_conflict = any(not check.get("ok") for check in checks)
lines = [
"# Conflict Catcher Report",
"",
f"- Repository: `{data.get('repo', '')}`",
f"- Target/base: `{data.get('target', '')}` (`{data.get('targetSha', '')}`)",
f"- Source/incoming: `{data.get('source', '')}` (`{data.get('sourceSha', '')}`)",
f"- Overall: {'conflicts need review' if has_conflict else 'clean'}",
"",
"## Freshness",
"",
]
for item in data.get("freshness", []):
lines.append(f"- {item.get('summary', '')}")
lines.extend(["", "## Checks", ""])
for check in checks:
lines.append(f"### {str(check.get('operation', '')).title()}")
lines.append("")
lines.append(f"- Status: {'clean' if check.get('ok') else 'conflicts'}")
lines.append(f"- Summary: {check.get('summary', '')}")
conflicts = check.get("conflicts", [])
if conflicts:
lines.extend(["", "| Status | Meaning | Path |", "| --- | --- | --- |"])
for conflict in conflicts:
lines.append(
f"| {conflict.get('status', '')} | {conflict.get('meaning', '')} | `{conflict.get('path', '')}` |"
)
else:
lines.append("- Conflict files: none")
lines.append("")
return "\n".join(lines).strip() + "\n"
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: #17202a;
--ink-soft: #344352;
--muted: #627282;
--line: #d8e0e7;
--line-strong: #bac7d2;
--panel: #ffffff;
--panel-soft: #f7f9fb;
--page: #eef2f5;
--accent: #0f766e;
--accent-dark: #0b5751;
--accent-ink: #ffffff;
--warn: #a15c07;
--warn-soft: #fff4dd;
--danger: #b42318;
--danger-soft: #fff1f0;
--success: #167249;
--success-soft: #eaf8f0;
--code: #16212c;
--shadow: 0 18px 48px rgba(26, 39, 52, 0.12);
--shadow-soft: 0 8px 24px rgba(26, 39, 52, 0.08);
}
* { 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:
radial-gradient(circle at top left, rgba(15, 118, 110, 0.13), transparent 32rem),
linear-gradient(135deg, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0) 42rem),
var(--page);
color: var(--ink);
}
main {
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 40px;
}
.topbar {
display: flex;
justify-content: space-between;
gap: 20px;
align-items: center;
margin-bottom: 22px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.mark {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 8px;
color: var(--accent-ink);
background: linear-gradient(145deg, var(--accent), #135f88);
box-shadow: var(--shadow-soft);
font-weight: 900;
}
h1, h2, h3, p { margin: 0; }
h1 {
font-size: 1.32rem;
line-height: 1.1;
letter-spacing: 0;
}
.eyebrow {
color: var(--muted);
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0;
}
p {
color: var(--muted);
line-height: 1.5;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(340px, 540px);
gap: 18px;
align-items: end;
margin-bottom: 18px;
padding: 18px 20px;
border: 1px solid rgba(186, 199, 210, 0.78);
border-radius: 8px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.94), rgba(247, 249, 251, 0.88)),
linear-gradient(90deg, rgba(15, 118, 110, 0.12), rgba(19, 95, 136, 0.1));
box-shadow: var(--shadow);
}
.hero h2 {
max-width: 780px;
font-size: clamp(1.35rem, 2vw, 1.95rem);
line-height: 1.16;
letter-spacing: 0;
text-wrap: balance;
}
.hero p {
max-width: 760px;
margin-top: 8px;
font-size: 0.98rem;
}
.trust-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
min-width: 0;
width: 100%;
}
.trust-item {
min-height: 72px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255, 255, 255, 0.72);
}
.trust-item strong {
display: block;
margin-bottom: 3px;
font-size: 0.92rem;
}
.trust-item span {
display: block;
color: var(--muted);
font-size: 0.78rem;
line-height: 1.35;
}
.shell {
display: grid;
grid-template-columns: minmax(320px, 405px) 1fr;
gap: 20px;
align-items: start;
}
form, .results, .guide {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.input-stack {
display: grid;
gap: 14px;
}
form { padding: 16px; }
.results { min-height: 468px; overflow: hidden; }
.form-head {
display: flex;
align-items: start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.form-head h2 { font-size: 1rem; }
label {
display: grid;
gap: 8px;
color: var(--ink);
font-size: 0.84rem;
font-weight: 700;
}
input, button {
width: 100%;
min-height: 44px;
border-radius: 6px;
border: 1px solid var(--line);
font: inherit;
}
input {
padding: 0 12px;
color: var(--ink);
background: var(--panel-soft);
transition: border-color 160ms ease, background 160ms ease, box-shadow 160ms ease;
}
input:hover { border-color: var(--line-strong); }
input:focus, button:focus, .segment input:focus + span {
outline: 3px solid rgba(15, 118, 110, 0.18);
outline-offset: 1px;
border-color: var(--accent);
background: #fff;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.field-note {
margin-top: -4px;
color: var(--muted);
font-size: 0.8rem;
line-height: 1.4;
}
.quick-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.link-button {
width: auto;
min-height: 34px;
padding: 0 10px;
border: 1px solid var(--line);
color: var(--ink-soft);
background: #fff;
font-size: 0.82rem;
font-weight: 800;
box-shadow: none;
}
.mini-button {
width: auto;
min-height: 30px;
padding: 0 9px;
border: 1px solid var(--line);
color: var(--ink-soft);
background: #fff;
font-size: 0.76rem;
font-weight: 850;
white-space: nowrap;
}
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel-soft);
}
.toggle-row span {
display: block;
color: var(--ink);
font-size: 0.84rem;
font-weight: 800;
}
.toggle-row small {
display: block;
margin-top: 2px;
color: var(--muted);
font-size: 0.78rem;
line-height: 1.35;
}
.toggle {
position: relative;
display: inline-flex;
flex: 0 0 auto;
width: 46px;
height: 26px;
}
.toggle input {
position: absolute;
inset: 0;
opacity: 0;
}
.toggle i {
width: 46px;
height: 26px;
border-radius: 999px;
background: var(--line-strong);
transition: background 160ms ease;
}
.toggle i::after {
content: "";
position: absolute;
width: 20px;
height: 20px;
top: 3px;
left: 3px;
border-radius: 999px;
background: #fff;
box-shadow: 0 2px 8px rgba(26, 39, 52, 0.18);
transition: transform 160ms ease;
}
.toggle input:checked + i { background: var(--accent); }
.toggle input:checked + i::after { transform: translateX(20px); }
.segments {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
padding: 5px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel-soft);
}
.segment {
position: relative;
min-width: 0;
}
.segment input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.segment span {
display: grid;
place-items: center;
min-height: 36px;
padding: 0 8px;
border-radius: 6px;
color: var(--muted);
font-size: 0.82rem;
font-weight: 850;
text-align: center;
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.segment input:checked + span {
color: var(--ink);
background: #fff;
box-shadow: 0 1px 0 rgba(26, 39, 52, 0.08), 0 8px 18px rgba(26, 39, 52, 0.08);
}
.primary-button {
display: inline-flex;
justify-content: center;
align-items: center;
gap: 10px;
border: 0;
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
color: var(--accent-ink);
font-weight: 800;
cursor: pointer;
box-shadow: 0 12px 24px rgba(15, 118, 110, 0.22);
transition: transform 160ms ease, box-shadow 160ms ease, opacity 160ms ease;
}
.primary-button:hover {
transform: translateY(-1px);
box-shadow: 0 16px 30px rgba(15, 118, 110, 0.26);
}
.primary-button:active {
transform: translateY(0);
box-shadow: 0 8px 18px rgba(15, 118, 110, 0.2);
}
button:disabled {
opacity: 0.7;
cursor: wait;
transform: none;
}
.button-icon {
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 999px;
color: var(--accent);
background: #fff;
font-weight: 900;
}
.result-head {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 18px;
border-bottom: 1px solid var(--line);
background: linear-gradient(180deg, #fff, var(--panel-soft));
}
.result-head h2 { font-size: 1rem; }
.result-body {
padding: 18px;
animation: rise-in 220ms ease both;
}
.result-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.empty {
min-height: 380px;
display: grid;
place-items: center;
padding: 28px;
text-align: center;
}
.empty-state {
display: grid;
gap: 12px;
justify-items: center;
max-width: 440px;
}
.empty-icon {
display: grid;
place-items: center;
width: 58px;
height: 58px;
border-radius: 8px;
color: var(--accent);
background: rgba(15, 118, 110, 0.1);
font-size: 1.45rem;
font-weight: 900;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 68px;
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: 0;
margin-bottom: 14px;
overflow: hidden;
background: #fff;
}
.check-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px;
background: var(--panel-soft);
border-bottom: 1px solid var(--line);
}
.check-content {
display: grid;
gap: 12px;
padding: 14px;
}
h3 {
font-size: 0.95rem;
letter-spacing: 0;
}
.summary-line {
color: var(--ink-soft);
font-weight: 650;
}
.summary-grid, .freshness-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-bottom: 14px;
}
.metric {
min-height: 76px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel-soft);
}
.metric span {
display: block;
color: var(--muted);
font-size: 0.76rem;
font-weight: 800;
text-transform: uppercase;
}
.metric strong {
display: block;
margin-top: 6px;
font-size: 1.25rem;
line-height: 1.1;
overflow-wrap: anywhere;
}
.freshness-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.freshness {
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
}
.freshness strong {
display: block;
margin-bottom: 4px;
overflow-wrap: anywhere;
}
.freshness p {
font-size: 0.84rem;
}
.freshness.behind {
border-color: rgba(180, 35, 24, 0.24);
background: var(--danger-soft);
}
.freshness.ok {
border-color: rgba(22, 114, 73, 0.24);
background: var(--success-soft);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.92rem;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 8px;
}
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; }
td.actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
details {
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
}
summary {
min-height: 40px;
padding: 10px 12px;
cursor: pointer;
color: var(--ink-soft);
font-size: 0.86rem;
font-weight: 800;
}
pre {
max-height: 220px;
overflow: auto;
white-space: pre-wrap;
margin: 0;
background: var(--code);
color: #e7edf0;
padding: 12px;
font-size: 0.84rem;
line-height: 1.45;
}
.meta {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 14px;
}
.meta-item {
min-width: 0;
padding: 11px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel-soft);
color: var(--muted);
font-size: 0.8rem;
line-height: 1.35;
}
.meta-item strong {
display: block;
margin-bottom: 4px;
color: var(--ink);
font-size: 0.86rem;
overflow-wrap: anywhere;
}
.status-list {
display: grid;
gap: 8px;
width: min(420px, 100%);
text-align: left;
}
.status-step {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--muted);
font-size: 0.88rem;
font-weight: 750;
}
.status-dot {
width: 9px;
height: 9px;
border-radius: 999px;
background: var(--line-strong);
}
.status-step.active .status-dot {
background: var(--warn);
box-shadow: 0 0 0 5px rgba(161, 92, 7, 0.12);
}
.guide {
margin-top: 14px;
padding: 14px;
box-shadow: none;
background: rgba(255, 255, 255, 0.78);
}
.guide h3 {
margin-bottom: 10px;
font-size: 0.9rem;
}
.guide ol {
display: grid;
gap: 9px;
margin: 0;
padding-left: 20px;
color: var(--muted);
font-size: 0.84rem;
line-height: 1.45;
}
.guide-section + .guide-section {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--line);
}
.history-list {
display: grid;
gap: 8px;
}
.history-empty {
color: var(--muted);
font-size: 0.84rem;
}
.toast {
position: fixed;
right: 18px;
bottom: 18px;
max-width: min(360px, calc(100% - 36px));
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
box-shadow: var(--shadow);
color: var(--ink-soft);
font-size: 0.88rem;
font-weight: 750;
transform: translateY(16px);
opacity: 0;
pointer-events: none;
transition: transform 180ms ease, opacity 180ms ease;
}
.toast.show {
transform: translateY(0);
opacity: 1;
}
.copy-fallback {
position: fixed;
inset: auto 18px 18px auto;
z-index: 5;
width: min(520px, calc(100% - 36px));
padding: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
box-shadow: var(--shadow);
}
.copy-fallback h3 { margin-bottom: 6px; }
.copy-fallback textarea {
width: 100%;
min-height: 160px;
margin: 10px 0;
padding: 10px;
border: 1px solid var(--line);
border-radius: 6px;
font: 0.82rem/1.45 ui-monospace, SFMono-Regular, Consolas, monospace;
resize: vertical;
}
@keyframes rise-in {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
scroll-behavior: auto !important;
transition-duration: 0.001ms !important;
}
}
@media (max-width: 820px) {
main { width: min(100% - 24px, 1180px); padding-top: 18px; }
.topbar, .hero, .shell, .row, .meta, .summary-grid { grid-template-columns: 1fr; }
.topbar, .hero { display: grid; align-items: start; }
.trust-strip { min-width: 0; width: 100%; }
}
@media (max-width: 540px) {
.segments { grid-template-columns: 1fr; }
.trust-strip { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.trust-item { min-height: 54px; padding: 10px; }
.trust-item strong { margin-bottom: 0; font-size: 0.82rem; line-height: 1.2; }
.trust-item span { display: none; }
.result-head, .form-head { display: grid; }
.hero { padding: 16px; }
}
</style>
</head>
<body>
<main>
<nav class="topbar" aria-label="App">
<div class="brand">
<div class="mark" aria-hidden="true">CC</div>
<div>
<h1>Conflict Catcher</h1>
<div class="eyebrow">Local Git preflight</div>
</div>
</div>
<span class="badge">Temporary worktrees only</span>
</nav>
<section class="hero">
<div>
<h2>Check merge and rebase risk before your real branch gets involved.</h2>
<p>Run clean-room Git dry runs, review exactly which files would conflict, then go back to your normal workflow with the answer in hand.</p>
</div>
<div class="trust-strip" aria-label="Safety notes">
<div class="trust-item"><strong>No checkout</strong><span>Your active working tree stays untouched.</span></div>
<div class="trust-item"><strong>No commit</strong><span>Dry runs abort and clean up after themselves.</span></div>
<div class="trust-item"><strong>Both paths</strong><span>Test merge, rebase, or both in one pass.</span></div>
</div>
</section>
<section class="shell">
<div>
<form id="check-form">
<div class="form-head">
<div>
<h2>Preflight setup</h2>
<p>Point at a repo and name the branch direction.</p>
</div>
<span class="badge warn">Ready</span>
</div>
<div class="input-stack">
<label>
Repository path
<input id="repo-path" name="repoPath" placeholder="C:\Users\you\code\repo" autocomplete="off" required>
</label>
<div class="quick-actions">
<button class="link-button" type="button" id="remember-repo">Use last repo</button>
<button class="link-button" type="button" id="load-branches">Load branches</button>
<button class="link-button" type="button" id="clear-repo">Clear</button>
</div>
<datalist id="branch-options"></datalist>
<label>
Branch spec
<input id="branch-spec" name="branchSpec" placeholder="main<develop" autocomplete="off">
</label>
<p class="field-note">Use target&lt;source. You can also fill the separate fields below if that is clearer.</p>
<div class="row">
<label>
Target/base
<input name="targetBranch" placeholder="main" autocomplete="off" list="branch-options">
</label>
<label>
Source/incoming
<input name="sourceBranch" placeholder="develop" autocomplete="off" list="branch-options">
</label>
</div>
<label class="toggle-row">
<span>
Fetch before check
<small>Runs git fetch --all --prune before the dry runs.</small>
</span>
<span class="toggle">
<input type="checkbox" name="fetchBeforeCheck" value="true">
<i aria-hidden="true"></i>
</span>
</label>
<fieldset style="border:0;padding:0;margin:0;">
<legend style="font-size:0.84rem;font-weight:800;margin-bottom:8px;">Check type</legend>
<div class="segments">
<label class="segment">
<input type="radio" name="operation" value="both" checked>
<span>Both</span>
</label>
<label class="segment">
<input type="radio" name="operation" value="merge">
<span>Merge</span>
</label>
<label class="segment">
<input type="radio" name="operation" value="rebase">
<span>Rebase</span>
</label>
</div>
</fieldset>
<button class="primary-button" id="submit-button" type="submit">
<span class="button-icon" aria-hidden="true">></span>
Run preflight
</button>
</div>
</form>
<aside class="guide" aria-label="Preflight helpers">
<section class="guide-section">
<h3>Branch direction</h3>
<ol>
<li><strong>Target/base</strong> is the branch you want to land onto.</li>
<li><strong>Source/incoming</strong> is the branch carrying changes.</li>
<li><strong>main&lt;feature</strong> means: test feature into main.</li>
</ol>
</section>
<section class="guide-section">
<h3>Recent checks</h3>
<div id="history-list" class="history-list">
<p class="history-empty">Successful checks will appear here.</p>
</div>
</section>
<section class="guide-section">
<h3>Editor command</h3>
<label>
Open conflict files with
<input id="editor-command" placeholder="code --goto {path}" autocomplete="off">
</label>
<p class="field-note">Leave empty to use the system default app.</p>
</section>
</aside>
</div>
<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">
<div class="empty-state">
<div class="empty-icon" aria-hidden="true">?</div>
<div>
<h3>Ready when your branch pair is.</h3>
<p>Enter a repository path and a branch pair such as <strong>main&lt;develop</strong>.</p>
</div>
</div>
</div>
</section>
</section>
</main>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<div id="copy-fallback" class="copy-fallback" hidden>
<h3>Copy text</h3>
<p>Your browser blocked direct clipboard access. The text below is selected.</p>
<textarea id="copy-fallback-text" readonly></textarea>
<button class="link-button" type="button" id="close-copy-fallback">Close</button>
</div>
<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 repoPath = document.querySelector("#repo-path");
const branchSpec = document.querySelector("#branch-spec");
const rememberRepo = document.querySelector("#remember-repo");
const loadBranches = document.querySelector("#load-branches");
const clearRepo = document.querySelector("#clear-repo");
const branchOptions = document.querySelector("#branch-options");
const historyList = document.querySelector("#history-list");
const editorCommand = document.querySelector("#editor-command");
const toast = document.querySelector("#toast");
const copyFallback = document.querySelector("#copy-fallback");
const copyFallbackText = document.querySelector("#copy-fallback-text");
const closeCopyFallback = document.querySelector("#close-copy-fallback");
const storageKey = "conflict-catcher:last-repo";
const historyKey = "conflict-catcher:history";
const editorKey = "conflict-catcher:editor-command";
let currentResult = null;
const escapeHtml = (value) => String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
const setBadge = (text, className) => {
badge.textContent = text;
badge.className = `badge ${className || ""}`.trim();
};
const showToast = (message) => {
toast.textContent = message;
toast.classList.add("show");
window.clearTimeout(showToast.timer);
showToast.timer = window.setTimeout(() => toast.classList.remove("show"), 2200);
};
const postJson = async (url, payload) => {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Request failed.");
return data;
};
const readHistory = () => {
try {
const value = JSON.parse(localStorage.getItem(historyKey) || "[]");
return Array.isArray(value) ? value : [];
} catch {
return [];
}
};
const writeHistory = (entry) => {
const next = [
entry,
...readHistory().filter((item) => `${item.repo}|${item.spec}|${item.operation}` !== `${entry.repo}|${entry.spec}|${entry.operation}`),
].slice(0, 5);
localStorage.setItem(historyKey, JSON.stringify(next));
renderHistory();
};
const renderHistory = () => {
const items = readHistory();
if (!items.length) {
historyList.innerHTML = '<p class="history-empty">Successful checks will appear here.</p>';
return;
}
historyList.innerHTML = items.map((item, index) => `
<button class="link-button" type="button" data-history-index="${index}">
${escapeHtml(item.spec)} · ${escapeHtml(getOperationLabel(item.operation))}
</button>
`).join("");
};
const getOperationLabel = (operation) => ({
merge: "Merge",
rebase: "Rebase",
both: "Merge + rebase",
}[operation] || operation);
const formatGitOutput = (value) => String(value ?? "")
.replace(
"Automatic merge went well; stopped before committing as requested",
"Merge dry run completed cleanly before any commit was made."
)
.replace(
"Successfully rebased and updated detached HEAD.",
"Rebase dry run completed cleanly in the temporary worktree."
);
const buildPlainSummary = (data) => {
const hasConflict = data.checks.some((check) => !check.ok);
const lines = [
`Conflict Catcher: ${hasConflict ? "review needed" : "clean"}`,
`Repo: ${data.repo}`,
`Target/base: ${data.target} (${data.targetSha})`,
`Source/incoming: ${data.source} (${data.sourceSha})`,
"",
"Freshness:",
...data.freshness.map((item) => `- ${item.summary}`),
"",
"Checks:",
];
for (const check of data.checks) {
lines.push(`- ${getOperationLabel(check.operation)}: ${check.ok ? "clean" : "conflicts"}`);
for (const conflict of check.conflicts) {
lines.push(` - ${conflict.status} ${conflict.path} (${conflict.meaning})`);
}
}
return lines.join("\n");
};
const downloadText = (filename, text) => {
const blob = new Blob([text], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
const copyText = async (text) => {
try {
window.focus();
await navigator.clipboard.writeText(text);
return;
} catch {
const field = document.createElement("textarea");
field.value = text;
field.setAttribute("readonly", "");
field.style.position = "fixed";
field.style.left = "-9999px";
document.body.append(field);
field.focus({ preventScroll: true });
field.select();
const ok = document.execCommand("copy");
field.remove();
if (!ok) {
copyFallback.hidden = false;
copyFallbackText.value = text;
copyFallbackText.focus({ preventScroll: true });
copyFallbackText.select();
throw new Error("Clipboard was blocked. The text is selected in the copy box.");
}
}
};
const renderLoading = (operation) => {
body.className = "result-body empty";
body.innerHTML = `
<div class="empty-state">
<div class="empty-icon" aria-hidden="true">...</div>
<div>
<h3>Running ${escapeHtml(getOperationLabel(operation).toLowerCase())} preflight.</h3>
<p>Temporary worktrees are being created, checked, and removed.</p>
</div>
<div class="status-list">
<div class="status-step active"><span class="status-dot"></span><span>Verify branch refs</span></div>
<div class="status-step active"><span class="status-dot"></span><span>Create detached worktree</span></div>
<div class="status-step active"><span class="status-dot"></span><span>Run Git dry check</span></div>
</div>
</div>
`;
};
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>
<td class="actions">
<button class="mini-button" type="button" data-copy-path="${escapeHtml(item.path)}">Copy</button>
<button class="mini-button" type="button" data-reveal-path="${escapeHtml(item.path)}">Reveal</button>
<button class="mini-button" type="button" data-open-path="${escapeHtml(item.path)}">Open</button>
</td>
</tr>
`).join("");
return `
<article class="check">
<div class="check-title">
<h3>${escapeHtml(getOperationLabel(check.operation))}</h3>
<span class="badge ${check.ok ? "ok" : "bad"}">${check.ok ? "Clean" : "Conflicts"}</span>
</div>
<div class="check-content">
<p class="summary-line">${escapeHtml(check.summary)}</p>
${check.conflicts.length ? `
<table>
<thead>
<tr><th>Status</th><th>Meaning</th><th>Path</th><th>Actions</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
` : `<p>No conflicting files reported by Git.</p>`}
${check.gitOutput ? `
<details>
<summary>Dry-run details</summary>
<pre>${escapeHtml(formatGitOutput(check.gitOutput))}</pre>
</details>
` : ""}
</div>
</article>
`;
};
const renderFreshness = (items) => `
<div class="freshness-grid">
${items.map((item) => `
<div class="freshness ${escapeHtml(item.state)}">
<strong>${escapeHtml(item.ref)}</strong>
<p>${escapeHtml(item.summary)}</p>
</div>
`).join("")}
</div>
`;
const renderResult = (data) => {
const hasConflict = data.checks.some((check) => !check.ok);
const conflictCount = new Set(data.checks.flatMap((check) => check.conflicts.map((item) => item.path))).size;
setBadge(hasConflict ? "Attention" : "Clean", hasConflict ? "bad" : "ok");
subtitle.textContent = `${data.source} -> ${data.target}`;
body.className = "result-body";
body.innerHTML = `
<div class="result-actions">
<button class="link-button" type="button" id="copy-summary">Copy summary</button>
<button class="link-button" type="button" id="export-report">Export report</button>
</div>
<div class="summary-grid">
<div class="metric"><span>Checks run</span><strong>${data.checks.length}</strong></div>
<div class="metric"><span>Conflict files</span><strong>${conflictCount}</strong></div>
<div class="metric"><span>Status</span><strong>${hasConflict ? "Review" : "Clean"}</strong></div>
</div>
${data.fetch ? `
<article class="check">
<div class="check-title">
<h3>Fetch</h3>
<span class="badge ok">Done</span>
</div>
<div class="check-content">
<p class="summary-line">${escapeHtml(data.fetch.summary)}</p>
${data.fetch.gitOutput ? `<details><summary>Fetch details</summary><pre>${escapeHtml(data.fetch.gitOutput)}</pre></details>` : ""}
</div>
</article>
` : ""}
<div class="meta">
<div class="meta-item"><strong>Repo</strong>${escapeHtml(data.repo)}</div>
<div class="meta-item"><strong>Refs</strong>${escapeHtml(data.targetSha)} <- ${escapeHtml(data.sourceSha)}</div>
<div class="meta-item"><strong>Target/base</strong>${escapeHtml(data.target)}</div>
<div class="meta-item"><strong>Source/incoming</strong>${escapeHtml(data.source)}</div>
</div>
${renderFreshness(data.freshness || [])}
${data.checks.map(renderCheck).join("")}
`;
};
const lastRepo = localStorage.getItem(storageKey);
if (lastRepo) repoPath.value = lastRepo;
editorCommand.value = localStorage.getItem(editorKey) || "";
renderHistory();
editorCommand.addEventListener("change", () => {
localStorage.setItem(editorKey, editorCommand.value.trim());
showToast("Editor command saved.");
});
closeCopyFallback.addEventListener("click", () => {
copyFallback.hidden = true;
});
rememberRepo.addEventListener("click", () => {
const value = localStorage.getItem(storageKey);
if (!value) {
showToast("No saved repo yet. A successful run saves the repo path.");
return;
}
repoPath.value = value;
showToast("Last repo restored.");
});
clearRepo.addEventListener("click", () => {
repoPath.value = "";
repoPath.focus();
});
loadBranches.addEventListener("click", async () => {
try {
loadBranches.disabled = true;
loadBranches.textContent = "Loading...";
const data = await postJson("/api/branches", { repoPath: repoPath.value });
branchOptions.innerHTML = data.all.map((branch) => `<option value="${escapeHtml(branch)}"></option>`).join("");
if (data.current && !form.elements.sourceBranch.value && !branchSpec.value) {
form.elements.sourceBranch.value = data.current;
}
showToast(`Loaded ${data.all.length} branches.`);
} catch (error) {
showToast(error.message);
} finally {
loadBranches.disabled = false;
loadBranches.textContent = "Load branches";
}
});
historyList.addEventListener("click", (event) => {
const button = event.target.closest("[data-history-index]");
if (!button) return;
const item = readHistory()[Number(button.dataset.historyIndex)];
if (!item) return;
repoPath.value = item.repo;
branchSpec.value = item.spec;
form.elements.targetBranch.value = item.target;
form.elements.sourceBranch.value = item.source;
const op = form.querySelector(`input[name="operation"][value="${item.operation}"]`);
if (op) op.checked = true;
showToast("Recent check restored.");
});
branchSpec.addEventListener("input", () => {
if (!branchSpec.value.includes("<")) return;
const [target, source] = branchSpec.value.split("<", 2).map((part) => part.trim());
if (!target || !source) return;
form.elements.targetBranch.value = target;
form.elements.sourceBranch.value = source;
});
body.addEventListener("click", async (event) => {
const target = event.target.closest("button");
if (!target) return;
try {
if (target.id === "copy-summary" && currentResult) {
await copyText(buildPlainSummary(currentResult));
showToast("Summary copied.");
}
if (target.id === "export-report" && currentResult) {
const data = await postJson("/api/report", currentResult);
const filename = `conflict-catcher-${currentResult.target}-from-${currentResult.source}.md`
.replace(/[\\/:*?"<>|]+/g, "-");
downloadText(filename, data.markdown);
showToast("Report exported.");
}
if (target.dataset.copyPath) {
await copyText(target.dataset.copyPath);
showToast("Path copied.");
}
if (target.dataset.revealPath && currentResult) {
await postJson("/api/reveal", { repoPath: currentResult.repo, path: target.dataset.revealPath });
showToast("Opened containing folder.");
}
if (target.dataset.openPath && currentResult) {
await postJson("/api/open-editor", {
repoPath: currentResult.repo,
path: target.dataset.openPath,
editorCommand: editorCommand.value.trim(),
});
showToast("Opened file.");
}
} catch (error) {
showToast(error.message);
}
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const payload = Object.fromEntries(new FormData(form).entries());
payload.fetchBeforeCheck = form.elements.fetchBeforeCheck.checked;
button.disabled = true;
button.innerHTML = '<span class="button-icon" aria-hidden="true">...</span> Checking';
setBadge("Running", "warn");
subtitle.textContent = "Creating temporary worktrees and asking Git.";
renderLoading(payload.operation);
try {
const data = await postJson("/api/check", payload);
localStorage.setItem(storageKey, data.repo);
currentResult = data;
writeHistory({
repo: data.repo,
spec: `${data.target}<${data.source}`,
target: data.target,
source: data.source,
operation: payload.operation,
});
renderResult(data);
} catch (error) {
currentResult = null;
setBadge("Error", "bad");
subtitle.textContent = "The check could not finish.";
body.className = "result-body";
body.innerHTML = `
<article class="check">
<div class="check-title">
<h3>Check failed</h3>
<span class="badge bad">Error</span>
</div>
<div class="check-content">
<p class="summary-line">${escapeHtml(error.message)}</p>
</div>
</article>
`;
} finally {
button.disabled = false;
button.innerHTML = '<span class="button-icon" aria-hidden="true">></span> Run preflight';
}
});
</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:
path = urlparse(self.path).path
if 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:
path = urlparse(self.path).path
handlers = {
"/api/check": check_conflicts,
"/api/branches": lambda payload: list_branches(repo_root(str(payload.get("repoPath", "")))),
"/api/report": lambda payload: {"markdown": generate_report(payload)},
"/api/reveal": reveal_path,
"/api/open-editor": open_in_editor,
}
handler = handlers.get(path)
if handler is None:
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, handler(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.")