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
24 changes: 19 additions & 5 deletions .github/forward-merge-check/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,28 @@ failed synthetic 10.11 -> 11.4
When a merge fails, the script reports:

- blocked edge
- conflicted files
- first likely source-side commit that fails this target merge
- conflicted files, grouped by the source-side commit where each file starts
to conflict
- candidate source-side and target-side commits that touched conflicted files
(console output only)

To find where a file starts to conflict, the checker walks the source's own
first-parent line, newest first, and merges each commit into the target in
memory with `git merge-tree --write-tree` (git 2.38 or newer). A file's origin
is the commit where it last went from merging cleanly to conflicting.

Merge commits such as "Merge 10.11 into 11.4" are single steps in that walk.
The 10.11 commits they brought in are not merged into 11.8 on their own, because
that would use a different merge base and skip the merge that may already have
adapted them. When the origin is a merge, the report lists the commits it
brought in that touch the files, and names the merge author as "Merged by"
rather than "Author". When the origin is the checker's own test merge of earlier
branches, the report says "unmerged changes from earlier branches" and lists
the commits from those branches that touch the files.

Candidate commits come from `git log --left-right --merge` while the repository
is still in the conflicted merge state. The "first likely" commit is an
approximation. Git conflicts come from the interaction between both branches, so
this is a debugging hint, not proof.
is still in the conflicted merge state. They show who touched a conflicted file,
not whose change conflicts.

## Repository Model

Expand Down
56 changes: 52 additions & 4 deletions .github/forward-merge-check/checker/git_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,61 @@ def is_ancestor(repo: Path, maybe_ancestor: str, descendant: str) -> bool:
return proc.returncode == 0


def get_commit_info(repo: Path, sha: str) -> CommitInfo:
fmt = "%H%x00%an <%ae>%x00%s"
proc = git(repo, ["show", "-s", f"--format={fmt}", sha])
commit_sha, author, subject = proc.stdout.rstrip("\n").split("\x00", 2)
COMMIT_INFO_FORMAT = "%H%x00%an <%ae>%x00%s"


def parse_commit_info(line: str) -> CommitInfo:
commit_sha, author, subject = line.split("\x00", 2)
return CommitInfo(sha=commit_sha, author=author, subject=subject)


def get_commit_info(repo: Path, sha: str) -> CommitInfo:
proc = git(repo, ["show", "-s", f"--format={COMMIT_INFO_FORMAT}", sha])
return parse_commit_info(proc.stdout.rstrip("\n"))


def is_merge_commit(repo: Path, sha: str) -> bool:
parents = git(repo, ["show", "-s", "--format=%P", sha]).stdout.split()
return len(parents) > 1


def commits_brought_in_by_merge(repo: Path, merge_sha: str, paths: list[str]) -> list[CommitInfo]:
proc = git(
repo,
[
"log",
"--no-merges",
f"--format={COMMIT_INFO_FORMAT}",
f"{merge_sha}^1..{merge_sha}",
"--",
*paths,
],
)
return [parse_commit_info(line) for line in proc.stdout.splitlines() if line]


def merge_tree_conflicted_files(repo: Path, ours: str, theirs: str) -> list[str]:
"""
Merge in memory with `git merge-tree --write-tree` (git 2.38+), without a
worktree or checkout, and return the conflicted paths.
"""
proc = git(
repo,
["merge-tree", "--write-tree", "--name-only", "--no-messages", "-z", ours, theirs],
check=False,
)
# Exit code 0 is a clean merge and 1 is a merge with conflicts.
if proc.returncode not in (0, 1):
raise RuntimeError(
f"git merge-tree failed for {ours} and {theirs}\n"
f"exit code: {proc.returncode}\n"
f"stderr:\n{proc.stderr or ''}"
)

# The output is the merged tree id, then one entry per conflicted path.
return [path for path in proc.stdout.split("\0")[1:] if path]


def branch_head(repo: Path, branch: str) -> str:
return git(repo, ["rev-parse", require_branch_ref(repo, branch)]).stdout.strip()

Expand Down
116 changes: 89 additions & 27 deletions .github/forward-merge-check/checker/merge_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,35 @@

from .git_ops import (
add_worktree,
commits_brought_in_by_merge,
ensure_ref,
get_commit_info,
get_conflicted_files,
git,
is_ancestor,
is_merge_commit,
merge_tree_conflicted_files,
remove_worktree,
require_branch_ref,
)
from .models import CommitInfo, ConflictCommit, MergeResult
from .models import ConflictCommit, ConflictOrigin, MergeResult


def source_commits(repo: Path, target_ref: str, source_ref: str) -> list[str]:
"""
Commits on the source's own first-parent line that the target does not
have yet, newest first.

Merge commits stay in the walk because "Merge 10.11 into 11.4" is a step on
11.4's line. Commits that only arrived through such a merge are left out:
merging one of them on its own into the target uses a different merge base
and skips the merge that may already have adapted it.
"""
proc = git(
repo,
[
"rev-list",
"--reverse",
"--no-merges",
"--first-parent",
f"{target_ref}..{source_ref}",
],
)
Expand Down Expand Up @@ -118,33 +129,82 @@ def try_merge(
remove_worktree(repo, worktree)


def find_first_conflicting_commit(
def conflict_start_commits(
repo: Path,
source_ref: str,
target: str,
scratch_root: Path,
) -> Optional[CommitInfo]:
target_ref: str,
commits: list[str],
conflicted_files: list[str],
) -> dict[str, Optional[str]]:
"""
Approximation.
Map each conflicted file to the source-side commit where it last went from
merging cleanly to conflicting.

Each source-side non-merge commit is tried independently against the target.
Git conflicts are produced by both branches together, so this is not a
perfect root cause. It is a practical answer to "which source-side commit
should I inspect first?" for scheduled chain-health runs too, not only PRs.
Walks the source commits from the tip back towards the target, merging each
one into the target in memory, and stops once every file has merged
cleanly at some point. A file that conflicts at every commit started at the
oldest one, whose parent the target already has. A file that does not
conflict at the tip maps to None.
"""
target_ref = require_branch_ref(repo, target)
started_at: dict[str, Optional[str]] = {}
pending = set(conflicted_files)
newer: Optional[str] = None

for sha in commits:
if not pending:
break

conflicted = set(merge_tree_conflicted_files(repo, target_ref, sha))
for path in pending - conflicted:
started_at[path] = newer
pending &= conflicted
newer = sha

for path in pending:
started_at[path] = newer

return started_at


for sha in source_commits(repo, target_ref, source_ref):
ok, _files, _merge_sha, _candidates = try_merge(
repo,
target_ref,
sha,
scratch_root,
def find_conflict_origins(
repo: Path,
target_ref: str,
source_ref: str,
conflicted_files: list[str],
source_is_test_merge: bool = False,
) -> list[ConflictOrigin]:
commits = source_commits(repo, target_ref, source_ref)
started_at = conflict_start_commits(repo, target_ref, commits, conflicted_files)
origins: list[ConflictOrigin] = []

# Oldest commit first, then any files that could not be attributed.
for sha in [*reversed(commits), None]:
files = [path for path in conflicted_files if started_at.get(path) == sha]
if not files:
continue

if sha is None:
origins.append(ConflictOrigin(files=files))
continue

via_commits = (
commits_brought_in_by_merge(repo, sha, files)
if is_merge_commit(repo, sha)
else []
)
if not ok:
return get_commit_info(repo, sha)
if source_is_test_merge and sha == commits[0]:
origins.append(
ConflictOrigin(files=files, via_commits=via_commits, from_test_merge=True)
)
else:
origins.append(
ConflictOrigin(
files=files,
commit=get_commit_info(repo, sha),
via_commits=via_commits,
)
)

return None
return origins


def check_merge(
Expand All @@ -153,6 +213,7 @@ def check_merge(
source_ref: str,
target: str,
scratch_root: Path,
source_is_test_merge: bool = False,
) -> MergeResult:
target_ref = require_branch_ref(repo, target)
ensure_ref(repo, target_ref)
Expand Down Expand Up @@ -185,11 +246,12 @@ def check_merge(
message=f"{source_label} merges cleanly into {target}.",
)

first_conflicting_commit = find_first_conflicting_commit(
conflict_origins = find_conflict_origins(
repo=repo,
target_ref=target_ref,
source_ref=source_ref,
target=target,
scratch_root=scratch_root,
conflicted_files=conflicted_files,
source_is_test_merge=source_is_test_merge,
)
return MergeResult(
source_label=source_label,
Expand All @@ -198,7 +260,7 @@ def check_merge(
status="conflict",
message=f"{source_label} cannot be cleanly merged into {target}.",
conflicted_files=conflicted_files,
first_conflicting_commit=first_conflicting_commit,
conflict_origins=conflict_origins,
candidate_commits=candidates,
)

Expand Down
18 changes: 17 additions & 1 deletion .github/forward-merge-check/checker/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@ class ConflictCommit:
subject: str


@dataclass
class ConflictOrigin:
"""
Conflicted files grouped by the source-side commit where they start to
conflict. When that commit is a merge, via_commits lists the non-merge
commits it brought in that touch the files.
"""

files: list[str]
commit: Optional[CommitInfo] = None
via_commits: list[CommitInfo] = field(default_factory=list)
# The files start to conflict at the checker's own test merge of earlier
# branches. That commit only exists locally, so it is not reported.
from_test_merge: bool = False


@dataclass
class MergeResult:
source_label: str
Expand All @@ -26,7 +42,7 @@ class MergeResult:
status: str
message: str
conflicted_files: list[str] = field(default_factory=list)
first_conflicting_commit: Optional[CommitInfo] = None
conflict_origins: list[ConflictOrigin] = field(default_factory=list)
candidate_commits: list[ConflictCommit] = field(default_factory=list)


Expand Down
21 changes: 14 additions & 7 deletions .github/forward-merge-check/checker/modes.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
from .output import print_result


def next_source_after_result(repo: Path, result: MergeResult) -> tuple[str, str]:
def next_source_after_result(repo: Path, result: MergeResult) -> tuple[str, str, bool]:
"""Return the next source label, ref and whether that ref is a local test merge."""
if result.status == "merge_ok":
return f"test merge through {result.target}", result.source_ref
return f"test merge through {result.target}", result.source_ref, True

return result.target, require_branch_ref(repo, result.target)
return result.target, require_branch_ref(repo, result.target), False


def first_downstream_broken_target_index(
Expand Down Expand Up @@ -73,16 +74,19 @@ def run_pr_mode(

source_label = "PR head"
source_ref = pr_ref
source_is_test_merge = False

for target in targets:
result = check_merge(repo, source_label, source_ref, target, scratch_root)
result = check_merge(
repo, source_label, source_ref, target, scratch_root, source_is_test_merge
)
results.append(result)
print_result(result)

if result.status == "conflict":
break

source_label, source_ref = next_source_after_result(repo, result)
source_label, source_ref, source_is_test_merge = next_source_after_result(repo, result)

return results

Expand All @@ -91,12 +95,15 @@ def run_chain_health_mode(repo: Path, branches: list[str], base_branch: str, scr
results: list[MergeResult] = []
source_label = base_branch
source_ref = require_branch_ref(repo, base_branch)
source_is_test_merge = False

for target in branch_targets_after(branches, base_branch):
result = check_merge(repo, source_label, source_ref, target, scratch_root)
result = check_merge(
repo, source_label, source_ref, target, scratch_root, source_is_test_merge
)
results.append(result)
print_result(result)

source_label, source_ref = next_source_after_result(repo, result)
source_label, source_ref, source_is_test_merge = next_source_after_result(repo, result)

return results
Loading
Loading