
Resolve Merge Conflicts
- 16.2k installs
- 148 repo stars
- Updated July 24, 2026
- warpdotdev/common-skills
How to inspect and resolve Git merge conflicts without loading full files into context, using a Python script to extract only unresolved paths and conflict hunks.
About
This skill provides a Python script that extracts unresolved merge conflicts without loading entire files into context. It generates a summary of conflicted paths and their conflict hunks, then allows drilling into individual files to view compact diffs between ours/theirs versions. Developers use it when merges, rebases, cherry-picks, or stash pops stop on conflicts. The workflow prioritizes minimal context by showing only nearby conflict regions and unified diffs, then prompts resolution via git checkout or direct editing, followed by validation to ensure no markers remain.
- Extracts conflict summary showing unresolved paths, index stages, and hunk counts without loading full files
- Drills into one conflicted file at a time with compact unified diff between ours and theirs sections
- Provides JSON output and tunable context window (lines and surrounding context) for flexible inspection
- Handles both marker-based text conflicts and index-only conflicts like add/add or modify/delete
- Integrated workflow: summarize, inspect, resolve with git checkout or manual edit, re-check, validate markers removed
Resolve Merge Conflicts by the numbers
- 16,187 all-time installs (skills.sh)
- +1,947 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #6 of 739 Git & Pull Requests skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
resolve-merge-conflicts capabilities & compatibility
- Capabilities
- extract unresolved conflict paths · view compact conflict hunks per file · generate unified diffs (ours vs theirs) · provide json structured output · summarize conflict metadata · validate markers removed
- Works with
- github · gitlab · bitbucket
- Use cases
- code review · debugging
- Platforms
- macOS · Windows · Linux · WSL
- Runs
- Runs locally
- Pricing
- Free
What resolve-merge-conflicts says it does
Resolve conflicts without opening full files unless the compact view is insufficient. Start with a summary, then inspect one conflicted file at a time.
npx skills add https://github.com/warpdotdev/common-skills --skill resolve-merge-conflictsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 16.2k |
|---|---|
| repo stars | ★ 148 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 24, 2026 |
| Repository | warpdotdev/common-skills ↗ |
What it does
Extract and resolve Git merge conflicts efficiently by viewing only unresolved paths and compact diffs instead of loading full files.
Who is it for?
Developers resolving merge conflicts during code integration, rebase workflows, or stash operations who need to keep context lean.
Skip if: Automated conflict resolution without human judgment; large binary conflicts; workflows that require full file context.
When should I use this skill?
git status shows unmerged paths, merge/rebase/cherry-pick/stash pop stops, or files contain conflict markers.
What you get
Conflicts resolved with minimal context overhead, validated for marker removal, and staged for commit.
- Conflict summary report
- Per-file conflict detail view
- JSON output of conflicts
By the numbers
- Script shows only nearby context and conflict hunks, not full files
- Supports tunable context window (--context 3, --max-lines 60)
- Handles both marker-based and index-only conflicts (add/add, modify/delete)
Files
Resolve Merge Conflicts
Overview
Resolve conflicts without opening full files unless the compact view is insufficient. Start with a summary, then inspect one conflicted file at a time.
Workflow
1. Start with a summary.
python3 .agents/skills/resolve-merge-conflicts/scripts/extract_conflict_context.pyUse the summary to identify which files are unresolved, which index stages exist, and how many text hunks each file contains.
2. Drill into one file.
python3 .agents/skills/resolve-merge-conflicts/scripts/extract_conflict_context.py --file path/to/filePrefer this over reading the whole file. The script prints only nearby context, the ours / base / theirs sections for each hunk, and a compact unified diff between ours and theirs.
3. Resolve the file.
- Take one side wholesale with
git checkout --ours -- path/to/fileorgit checkout --theirs -- path/to/filewhen appropriate. - Otherwise edit the file directly and remove the conflict markers.
- Read more of the file only if the compact output is not enough to decide the correct merge.
4. Re-check unresolved files.
python3 .agents/skills/resolve-merge-conflicts/scripts/extract_conflict_context.py
git diff --name-only --diff-filter=U5. Validate the resolution.
- Ensure no unmerged paths remain.
- Ensure no
<<<<<<<,=======, or>>>>>>>markers remain in the resolved files. - Run targeted tests, builds, or linters for the touched area.
- Stage the resolved files.
Commands
Summary only
python3 .agents/skills/resolve-merge-conflicts/scripts/extract_conflict_context.pyDetailed view for one file
python3 .agents/skills/resolve-merge-conflicts/scripts/extract_conflict_context.py --file path/to/fileDetailed view for all conflicted files
python3 .agents/skills/resolve-merge-conflicts/scripts/extract_conflict_context.py --allJSON output
python3 .agents/skills/resolve-merge-conflicts/scripts/extract_conflict_context.py --file path/to/file --jsonTune output size
python3 .agents/skills/resolve-merge-conflicts/scripts/extract_conflict_context.py \
--file path/to/file \
--context 3 \
--max-lines 60Notes
- Use the script before opening conflicted files directly.
- Resolve one file at a time to keep context small.
- Expect marker-based text conflicts and index-only conflicts such as add/add or modify/delete. The script summarizes both, and it falls back to index-stage previews when the worktree file has no conflict markers.
#!/usr/bin/env python3
"""Summarize and extract compact merge-conflict context from a Git repository."""
from __future__ import annotations
import argparse
import difflib
import json
import re
import subprocess
import sys
from pathlib import Path
START_RE = re.compile(r"^<<<<<<<(?: (.*))?$")
BASE_RE = re.compile(r"^\|\|\|\|\|\|\|(?: (.*))?$")
END_RE = re.compile(r"^>>>>>>>(?: (.*))?$")
def run_git(repo_root: Path, *args: str) -> str:
result = subprocess.run(
["git", "-C", str(repo_root), *args],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
message = result.stderr.strip() or result.stdout.strip() or "unknown git error"
raise RuntimeError(f"git {' '.join(args)} failed: {message}")
return result.stdout
def find_repo_root(start: Path) -> Path:
result = subprocess.run(
["git", "-C", str(start), "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
message = result.stderr.strip() or result.stdout.strip() or "not a git repository"
raise RuntimeError(message)
return Path(result.stdout.strip()).resolve()
def get_unmerged_entries(repo_root: Path) -> dict[str, dict[int, dict[str, str]]]:
entries: dict[str, dict[int, dict[str, str]]] = {}
output = run_git(repo_root, "ls-files", "-u", "-z")
for record in output.split("\0"):
if not record:
continue
metadata, path = record.split("\t", 1)
mode, object_id, stage_text = metadata.split()
file_entry = entries.setdefault(path, {})
file_entry[int(stage_text)] = {"mode": mode, "object_id": object_id}
return entries
def read_text_file(path: Path) -> list[str] | None:
if not path.exists() or path.is_dir():
return None
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
if "\x00" in text:
return None
return text.splitlines()
def read_stage_text(repo_root: Path, path: str, stage: int) -> list[str] | None:
result = subprocess.run(
["git", "-C", str(repo_root), "show", f":{stage}:{path}"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return None
if "\x00" in result.stdout:
return None
return result.stdout.splitlines()
def truncate_lines(lines: list[str], max_lines: int) -> list[str]:
if len(lines) <= max_lines:
return lines
omitted = len(lines) - max_lines
return [*lines[:max_lines], f"... ({omitted} more lines omitted)"]
def build_diff(
left_lines: list[str],
right_lines: list[str],
left_label: str,
right_label: str,
max_lines: int,
) -> list[str]:
diff = list(
difflib.unified_diff(
left_lines,
right_lines,
fromfile=left_label,
tofile=right_label,
lineterm="",
)
)
if not diff:
diff = ["(no textual diff)"]
return truncate_lines(diff, max_lines)
def classify_conflict(stages: list[int], marker_hunks: int) -> str:
if marker_hunks:
return "text"
stage_set = set(stages)
if stage_set == {2, 3}:
return "add/add"
if stage_set == {1, 2}:
return "deleted-by-them"
if stage_set == {1, 3}:
return "deleted-by-us"
if stage_set == {1, 2, 3}:
return "index-only"
return "unmerged"
def normalize_requested_path(repo_root: Path, raw_path: str) -> str:
path = Path(raw_path)
candidate = path.resolve() if path.is_absolute() else (repo_root / path).resolve()
try:
return str(candidate.relative_to(repo_root))
except ValueError as error:
raise RuntimeError(f"path is outside repository: {raw_path}") from error
def parse_conflict_hunks(lines: list[str], context: int) -> tuple[list[dict[str, object]], str | None]:
hunks: list[dict[str, object]] = []
index = 0
while index < len(lines):
start_match = START_RE.match(lines[index])
if not start_match:
index += 1
continue
start_index = index
ours_label = start_match.group(1) or "ours"
index += 1
ours: list[str] = []
base: list[str] = []
theirs: list[str] = []
base_label: str | None = None
theirs_label = "theirs"
while index < len(lines):
base_match = BASE_RE.match(lines[index])
if base_match:
base_label = base_match.group(1) or "base"
index += 1
while index < len(lines) and lines[index] != "=======":
base.append(lines[index])
index += 1
break
if lines[index] == "=======":
break
ours.append(lines[index])
index += 1
if index >= len(lines) or lines[index] != "=======":
return hunks, f"unterminated conflict starting at line {start_index + 1}"
index += 1
end_index = index
while index < len(lines):
end_match = END_RE.match(lines[index])
if end_match:
theirs_label = end_match.group(1) or "theirs"
end_index = index
index += 1
break
theirs.append(lines[index])
index += 1
else:
return hunks, f"unterminated conflict starting at line {start_index + 1}"
hunks.append(
{
"start_line": start_index + 1,
"end_line": end_index + 1,
"before_context": lines[max(0, start_index - context):start_index],
"ours": ours,
"ours_label": ours_label,
"base": base or None,
"base_label": base_label,
"theirs": theirs,
"theirs_label": theirs_label,
"after_context": lines[index:index + context],
}
)
return hunks, None
def build_summary_report(repo_root: Path, path: str, stage_entries: dict[int, dict[str, str]], context: int) -> dict[str, object]:
worktree_lines = read_text_file(repo_root / path)
hunks: list[dict[str, object]] = []
parse_error = None
if worktree_lines is not None:
hunks, parse_error = parse_conflict_hunks(worktree_lines, context)
stages = sorted(stage_entries)
return {
"path": path,
"stages": stages,
"conflict_type": classify_conflict(stages, len(hunks)),
"marker_hunks": len(hunks),
"parse_error": parse_error,
"worktree_present": worktree_lines is not None,
"hunks": hunks,
}
def build_index_preview(repo_root: Path, report: dict[str, object], max_lines: int) -> dict[str, object]:
path = str(report["path"])
ours = read_stage_text(repo_root, path, 2)
theirs = read_stage_text(repo_root, path, 3)
base = read_stage_text(repo_root, path, 1)
preview: dict[str, object] = {
"ours": truncate_lines(ours, max_lines) if ours else None,
"theirs": truncate_lines(theirs, max_lines) if theirs else None,
"base": truncate_lines(base, max_lines) if base else None,
}
if ours and theirs:
preview["ours_vs_theirs_diff"] = build_diff(ours, theirs, "ours", "theirs", max_lines)
return preview
def section_lines(title: str, lines: list[str] | None) -> list[str]:
if lines is None:
return [f"{title}:", " (not present)"]
if not lines:
return [f"{title}:", " (empty)"]
return [f"{title}:", *[f" {line}" for line in lines]]
def render_summary_text(repo_root: Path, reports: list[dict[str, object]]) -> str:
lines = [f"repo: {repo_root}", f"conflicted files: {len(reports)}"]
for report in reports:
stages = ",".join(str(stage) for stage in report["stages"])
lines.append(
f"- {report['path']} | type={report['conflict_type']} | stages={stages} | hunks={report['marker_hunks']}"
)
if report["parse_error"]:
lines.append(f" parse-error: {report['parse_error']}")
lines.append("use --file <path> for compact hunk details or --all for every file")
return "\n".join(lines)
def render_detail_text(
repo_root: Path,
report: dict[str, object],
max_lines: int,
) -> str:
lines = [
f"== {report['path']} ==",
f"type: {report['conflict_type']}",
f"stages: {', '.join(str(stage) for stage in report['stages'])}",
]
parse_error = report["parse_error"]
if parse_error:
lines.append(f"parse-error: {parse_error}")
hunks = report["hunks"]
if hunks:
lines.append(f"hunks: {len(hunks)}")
for index, hunk in enumerate(hunks, start=1):
ours = list(hunk["ours"])
theirs = list(hunk["theirs"])
diff = build_diff(
ours,
theirs,
str(hunk["ours_label"]),
str(hunk["theirs_label"]),
max_lines,
)
lines.extend(
[
"",
f"[hunk {index}] current lines {hunk['start_line']}-{hunk['end_line']}",
*section_lines("before", truncate_lines(list(hunk["before_context"]), max_lines)),
*section_lines(
f"ours ({hunk['ours_label']})",
truncate_lines(ours, max_lines),
),
]
)
if hunk["base"] is not None:
lines.extend(
section_lines(
f"base ({hunk['base_label'] or 'base'})",
truncate_lines(list(hunk["base"]), max_lines),
)
)
lines.extend(
[
*section_lines(
f"theirs ({hunk['theirs_label']})",
truncate_lines(theirs, max_lines),
),
*section_lines("ours vs theirs diff", diff),
*section_lines("after", truncate_lines(list(hunk["after_context"]), max_lines)),
]
)
return "\n".join(lines)
preview = build_index_preview(repo_root, report, max_lines)
lines.append("hunks: 0")
lines.append("index preview:")
lines.extend(section_lines("ours", preview["ours"]))
lines.extend(section_lines("base", preview["base"]))
lines.extend(section_lines("theirs", preview["theirs"]))
if "ours_vs_theirs_diff" in preview:
lines.extend(section_lines("ours vs theirs diff", preview["ours_vs_theirs_diff"]))
return "\n".join(lines)
def render_json(
repo_root: Path,
reports: list[dict[str, object]],
include_details: bool,
max_lines: int,
) -> str:
files: list[dict[str, object]] = []
for report in reports:
file_entry: dict[str, object] = {
"path": report["path"],
"conflict_type": report["conflict_type"],
"stages": report["stages"],
"marker_hunks": report["marker_hunks"],
"parse_error": report["parse_error"],
}
if include_details:
if report["hunks"]:
file_entry["hunks"] = [
{
"start_line": hunk["start_line"],
"end_line": hunk["end_line"],
"before_context": truncate_lines(list(hunk["before_context"]), max_lines),
"ours_label": hunk["ours_label"],
"ours": truncate_lines(list(hunk["ours"]), max_lines),
"base_label": hunk["base_label"],
"base": truncate_lines(list(hunk["base"]), max_lines) if hunk["base"] else None,
"theirs_label": hunk["theirs_label"],
"theirs": truncate_lines(list(hunk["theirs"]), max_lines),
"after_context": truncate_lines(list(hunk["after_context"]), max_lines),
"ours_vs_theirs_diff": build_diff(
list(hunk["ours"]),
list(hunk["theirs"]),
str(hunk["ours_label"]),
str(hunk["theirs_label"]),
max_lines,
),
}
for hunk in report["hunks"]
]
else:
file_entry["index_preview"] = build_index_preview(repo_root, report, max_lines)
files.append(file_entry)
return json.dumps(
{
"repo_root": str(repo_root),
"conflicted_files": files,
},
indent=2,
)
def main() -> int:
parser = argparse.ArgumentParser(
description="Summarize and extract compact merge-conflict context."
)
parser.add_argument("--repo", default=".", help="Path inside the target repository.")
parser.add_argument(
"--file",
action="append",
default=[],
help="Conflicted file to inspect in detail. Repeat to inspect multiple files.",
)
parser.add_argument(
"--all",
action="store_true",
help="Print detailed output for every conflicted file.",
)
parser.add_argument(
"--json",
action="store_true",
help="Emit JSON instead of text.",
)
parser.add_argument(
"--context",
type=int,
default=2,
help="Lines of surrounding context to include around each conflict hunk.",
)
parser.add_argument(
"--max-lines",
type=int,
default=40,
help="Maximum lines to print for each section before truncating.",
)
args = parser.parse_args()
if args.all and args.file:
parser.error("--all cannot be combined with --file")
if args.context < 0:
parser.error("--context must be non-negative")
if args.max_lines <= 0:
parser.error("--max-lines must be positive")
try:
repo_root = find_repo_root(Path(args.repo).resolve())
entries = get_unmerged_entries(repo_root)
except RuntimeError as error:
print(f"error: {error}", file=sys.stderr)
return 2
reports = [
build_summary_report(repo_root, path, entries[path], args.context)
for path in sorted(entries)
]
if not reports:
message = json.dumps({"repo_root": str(repo_root), "conflicted_files": []}, indent=2) if args.json else f"repo: {repo_root}\nconflicted files: 0"
print(message)
return 0
if args.all:
selected_reports = reports
elif args.file:
try:
requested_paths = {normalize_requested_path(repo_root, path) for path in args.file}
except RuntimeError as error:
print(f"error: {error}", file=sys.stderr)
return 2
known_paths = {str(report["path"]) for report in reports}
missing = sorted(requested_paths - known_paths)
if missing:
for path in missing:
print(f"error: conflicted file not found: {path}", file=sys.stderr)
return 2
selected_reports = [report for report in reports if report["path"] in requested_paths]
else:
selected_reports = []
if args.json:
print(render_json(repo_root, selected_reports or reports, bool(selected_reports), args.max_lines))
return 0
if not selected_reports:
print(render_summary_text(repo_root, reports))
return 0
print("\n\n".join(render_detail_text(repo_root, report, args.max_lines) for report in selected_reports))
return 0
if __name__ == "__main__":
raise SystemExit(main())
Related skills
How it compares
Use resolve-merge-conflicts for structured conflict context extraction; use review-pr when the branch is already clean and ready for spec-based PR review.
FAQ
When should I use this skill?
Use it after a merge, rebase, cherry-pick, or stash pop fails with conflicts, or when git status shows unmerged paths.
What output does the summary provide?
It lists unresolved paths, which index stages exist for each file, and the count of text hunks in each conflicted file.
Can I resolve without reading the whole file?
Yes. The script shows compact context around each conflict hunk and unified diff between ours/theirs. Only open the full file if this is insufficient.
Is Resolve Merge Conflicts safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.