Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ jobs:
ai/tests/test-ai-installers.sh
ai/tests/test-command-log.sh
ai/tests/test-log-step-done.sh
ai/skills/resolve-conflicts/scripts/test-conflict-status.sh
ai/skills/resolve-conflicts/scripts/test-categorize-conflicts.sh
ai/skills/resolve-conflicts/scripts/test-ensure-diff3-markers.sh
ai/skills/ran/scripts/tests/test-ran-report.sh
ai/helpers/tests/test-repo-context.sh
bin/lib/test-git-pr.sh
34 changes: 24 additions & 10 deletions ai/skills/resolve-conflicts/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,19 @@ For non-rebase contexts, omit the step count:

> **Conflicts (merge):**

#### 2b: Resolve by Category
#### 2b: Ensure diff3 Conflict Markers

Step 2d reads the base section of each hunk to tell a stacked-PR duplicate from a real divergence. Git writes that section only when the host sets `merge.conflictStyle` to `diff3` or `zdiff3`, so this script re-creates the markers in diff3 style when the host sets neither.

The rewrite re-merges each file from its index stages, which costs two things. It relabels the markers `ours` and `theirs`, so a rebase conflict loses the sha and subject of the commit being replayed. It also reverts any resolution already in the file. The script skips a file with no conflict markers, which is where a rerere replay lands, but it cannot detect a half-finished hand edit. Ask the user before running it if they edited a conflict before invoking this skill.

```bash
scripts/ensure-diff3-markers.sh
```

The script prints one `rewrote\t<file>` line per file that gained a base section. It reads the `conflict-marker-size` attribute of each file, so it still finds the markers in a repository that sets one. Report the rewritten files to the user, then continue.

#### 2c: Resolve by Category

Process conflicts in this order:

Expand Down Expand Up @@ -112,7 +124,7 @@ Run mergiraf as a second pass (it may have already run as a merge driver during
mergiraf solve -- <file> --compact --keep-backup=false
```

After running mergiraf, read the file and check for remaining conflict markers (`<<<<<<<`). If conflict markers remain, proceed with AI analysis (see Step 2c).
After running mergiraf, read the file and check for remaining conflict markers (`<<<<<<<`). If conflict markers remain, proceed with AI analysis (see Step 2d).

If mergiraf fully resolves the file (no markers remain), stage it:

Expand All @@ -122,25 +134,23 @@ git add <file>

**4. Other files (`other`)**

Read the file contents and resolve using AI analysis (see Step 2c).

#### 2c: AI Conflict Analysis
Read the file contents and resolve using AI analysis (see Step 2d).

For conflicts that remain after mergiraf (or for `other` category files), read the file and analyze each conflict hunk. Conflict markers may appear in diff3 style (with a base section) or standard style (without). Handle both:
#### 2d: AI Conflict Analysis

diff3 style (preferred, enabled via `merge.conflictStyle = diff3`):
For conflicts that remain after mergiraf (or for `other` category files), read the file and analyze each conflict hunk. A file step 2b rewrote carries diff3 markers labeled `ours` and `theirs`:

```text
<<<<<<< HEAD
<<<<<<< ours
[head_code]
||||||| base
[base_code]
=======
[incoming_code]
>>>>>>> commit message
>>>>>>> theirs
```

Standard style (no base section):
A file step 2b skipped keeps the labels the merge wrote, where the last one names the commit. That file carries a base section only if the host already sets `merge.conflictStyle`:

```text
<<<<<<< HEAD
Expand All @@ -150,6 +160,10 @@ Standard style (no base section):
>>>>>>> commit message
```

A skipped path may also hold no markers at all, because the conflict is binary, it is a delete/modify, or rerere replayed a resolution into it. Stage that file as it stands instead of reading it as a hunk.

Git sets the marker width from the `conflict-marker-size` attribute, so a repository that sets it writes runs other than seven characters long.

**Stacked PR duplicate detection:**

When the base section is empty or contains substantially less code than both sides, this often indicates a stacked PR scenario where a sub-PR was merged, duplicating code that also exists in the feature branch.
Expand Down
69 changes: 69 additions & 0 deletions ai/skills/resolve-conflicts/scripts/ensure-diff3-markers.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/bin/bash
# Rewrite conflicted working-tree files so their markers carry a base section.
#
# Usage: ensure-diff3-markers.sh
#
# Output format (tab-separated, one per line):
# rewrote\t<file_path>
#
# Git writes the "||||||| " base section only when merge.conflictStyle is
# diff3 or zdiff3. This script re-creates the markers in diff3 style when the
# host sets neither.

set -euo pipefail

toplevel=$(git rev-parse --show-toplevel 2>/dev/null) || {
echo "Error: not in a git repository" >&2
exit 1
}

# git diff reports paths from the top of the working tree, so the greps and
# the checkout below resolve them only from there.
cd "$toplevel"

style=$(git config --get merge.conflictStyle || true)
case "$style" in
diff3|zdiff3) exit 0 ;;
esac

# The conflict-marker-size attribute sets how many characters a marker runs
# for, and git accepts any value down to 1. A fixed seven-character pattern
# would miss a shorter marker and report the file as having none.
marker_size() {
local size
size=$(git check-attr conflict-marker-size -- "$1" 2>/dev/null | sed 's/.*: //')
case "$size" in
[1-9]|[1-9][0-9]*) echo "$size" ;;
*) echo 7 ;;
esac
}

has_marker() {
local char="$1" file="$2" size="$3"
grep -qIE "^[$char]{$size}( |\$)" -- "$file" 2>/dev/null
}

while IFS= read -r -d '' file; do
size=$(marker_size "$file")

# A conflicted path with no "<<<<<<<" holds a binary or delete/modify
# conflict, or a resolution that rerere replayed. A rewrite destroys
# that resolution.
if ! has_marker '<' "$file" "$size"; then
continue
fi
if has_marker '|' "$file" "$size"; then
continue
fi

if ! git checkout --conflict=diff3 -- "$file" 2>/dev/null; then
echo "Warning: could not rewrite conflict markers in $file" >&2
continue
fi

# A merge driver named by a gitattributes "merge=" rule re-runs here.
# Its output wins, so the re-checkout does not always add a base section.
if has_marker '|' "$file" "$size"; then
printf "rewrote\t%s\n" "$file"
fi
done < <(git diff --name-only --diff-filter=U -z 2>/dev/null)
Loading
Loading