1871 lines
56 KiB
Python
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<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<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<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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
|
|
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.")
|