Skip to content

Commit

Permalink
Introduce script to automatically resolve conflicts after treewide ch…
Browse files Browse the repository at this point in the history
…anges (such as reformats) (NixOS#363759)
  • Loading branch information
dasJ authored Jan 21, 2025
2 parents d7964f4 + b0cfc97 commit 3425323
Show file tree
Hide file tree
Showing 7 changed files with 263 additions and 0 deletions.
6 changes: 6 additions & 0 deletions .git-blame-ignore-revs
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# This file contains a list of commits that are not likely what you
# are looking for in a blame, such as mass reformatting or renaming.
#
# If a commit's line ends with `# !autorebase <command>`,
# where <command> is an idempotent bash command that reapplies the changes from the commit,
# the `maintainers/scripts/auto-rebase/run.sh` script can be used to rebase
# across that commit while automatically resolving merge conflicts caused by the commit.
#
# You can set this file as a default ignore file for blame by running
# the following command.
#
Expand Down
16 changes: 16 additions & 0 deletions maintainers/scripts/auto-rebase/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Auto rebase script

The [`./run.sh` script](./run.sh) in this directory rebases the current branch onto a target branch,
while automatically resolving merge conflicts caused by marked commits in [`.git-blame-ignore-revs`](../../../.git-blame-ignore-revs).
See the header comment of that file to understand how to mark commits.

This is convenient for resolving merge conflicts for pull requests after e.g. treewide reformats.

## Testing

To run the tests in the [test directory](./test):
```
$ cd test
$ nix-shell
nix-shell> ./run.sh
```
61 changes: 61 additions & 0 deletions maintainers/scripts/auto-rebase/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail

if (( $# < 1 )); then
echo "Usage: $0 TARGET_BRANCH"
echo ""
echo "TARGET_BRANCH: Branch to rebase the current branch onto, e.g. master or release-24.11"
exit 1
fi

targetBranch=$1

# Loop through all autorebase-able commits in .git-blame-ignore-revs on the base branch
readarray -t autoLines < <(
git show "$targetBranch":.git-blame-ignore-revs \
| sed -n 's/^\([0-9a-f]\+\).*!autorebase \(.*\)$/\1 \2/p'
)
for line in "${autoLines[@]}"; do
read -r autoCommit autoCmd <<< "$line"

if ! git cat-file -e "$autoCommit"; then
echo "Not a valid commit: $autoCommit"
exit 1
elif git merge-base --is-ancestor "$autoCommit" HEAD; then
# Skip commits that we have already
continue
fi

echo -e "\e[32mAuto-rebasing commit $autoCommit with command '$autoCmd'\e[0m"

# The commit before the commit
parent=$(git rev-parse "$autoCommit"~)

echo "Rebasing on top of the previous commit, might need to manually resolve conflicts"
if ! git rebase --onto "$parent" "$(git merge-base "$targetBranch" HEAD)"; then
echo -e "\e[33m\e[1mRestart this script after resolving the merge conflict as described above\e[0m"
exit 1
fi

echo "Reapplying the commit on each commit of our branch"
# This does two things:
# - The parent filter inserts the auto commit between its parent and
# and our first commit. By itself, this causes our first commit to
# effectively "undo" the auto commit, since the tree of our first
# commit is unchanged. This is why the following is also necessary:
# - The tree filter runs the command on each of our own commits,
# effectively reapplying it.
FILTER_BRANCH_SQUELCH_WARNING=1 git filter-branch \
--parent-filter "sed 's/$parent/$autoCommit/'" \
--tree-filter "$autoCmd" \
"$autoCommit"..HEAD

# A tempting alternative is something along the lines of
# git rebase --strategy-option=theirs --onto "$rev" "$parent" \
# --exec '$autoCmd && git commit --all --amend --no-edit' \
# but this causes problems because merges are not guaranteed to maintain the formatting.
# The ./test.sh exercises such a case.
done

echo "Rebasing on top of the latest target branch commit"
git rebase --onto "$targetBranch" "$(git merge-base "$targetBranch" HEAD)"
46 changes: 46 additions & 0 deletions maintainers/scripts/auto-rebase/test/default.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
let
pkgs = import ../../../.. {
config = { };
overlays = [ ];
};

inherit (pkgs)
lib
stdenvNoCC
gitMinimal
treefmt
nixfmt-rfc-style
;
in

stdenvNoCC.mkDerivation {
name = "test";
src = lib.fileset.toSource {
root = ./..;
fileset = lib.fileset.unions [
../run.sh
./run.sh
./first.diff
./second.diff
];
};
nativeBuildInputs = [
gitMinimal
treefmt
nixfmt-rfc-style
];
patchPhase = ''
patchShebangs .
'';

buildPhase = ''
export HOME=$(mktemp -d)
export PAGER=true
git config --global user.email "Your Name"
git config --global user.name "[email protected]"
./test/run.sh
'';
installPhase = ''
touch $out
'';
}
11 changes: 11 additions & 0 deletions maintainers/scripts/auto-rebase/test/first.diff
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
diff --git a/b.nix b/b.nix
index 9d18f25..67b0466 100644
--- a/b.nix
+++ b/b.nix
@@ -1,5 +1,5 @@
{
this = "is";

- some = "set";
+ some = "value";
}
112 changes: 112 additions & 0 deletions maintainers/scripts/auto-rebase/test/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#!/usr/bin/env bash

set -euo pipefail

# https://stackoverflow.com/a/246128/6605742
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )

# Allows using a local directory for temporary files,
# which can then be inspected after the run
if (( $# > 0 )); then
tmp=$(realpath "$1/tmp")
if [[ -e "$tmp" ]]; then
rm -rf "$tmp"
fi
mkdir -p "$tmp"
else
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' exit
fi

# Tests a scenario where two poorly formatted files were modified on both the
# main branch and the feature branch, while the main branch also did a treewide
# format.

git init "$tmp/repo"
cd "$tmp/repo" || exit
git branch -m main

# Some initial poorly-formatted files
cat > a.nix <<EOF
{ x
, y
, z
}:
null
EOF

cat > b.nix <<EOF
{
this = "is";
some="set" ;
}
EOF

git add -A
git commit -m "init"

git switch -c feature

# Some changes
sed 's/set/value/' -i b.nix
git commit -a -m "change b"
sed '/, y/d' -i a.nix
git commit -a -m "change a"

git switch main

# A change to cause a merge conflict
sed 's/y/why/' -i a.nix
git commit -a -m "change a"

cat > treefmt.toml <<EOF
[formatter.nix]
command = "nixfmt"
includes = [ "*.nix" ]
EOF
git add -A
git commit -a -m "introduce treefmt"

# Treewide reformat
treefmt
git commit -a -m "format"

echo "$(git rev-parse HEAD) # !autorebase treefmt" > .git-blame-ignore-revs
git add -A
git commit -a -m "update ignored revs"

git switch feature

# Setup complete

git log --graph --oneline feature main

# This expectedly fails with a merge conflict that has to be manually resolved
"$SCRIPT_DIR"/../run.sh main && exit 1
sed '/<<</,/>>>/d' -i a.nix
git add a.nix
GIT_EDITOR=true git rebase --continue

"$SCRIPT_DIR"/../run.sh main

git log --graph --oneline feature main

checkDiff() {
local ref=$1
local file=$2
expectedDiff=$(cat "$file")
actualDiff=$(git diff "$ref"~ "$ref")
if [[ "$expectedDiff" != "$actualDiff" ]]; then
echo -e "Expected this diff:\n$expectedDiff"
echo -e "But got this diff:\n$actualDiff"
exit 1
fi
}

checkDiff HEAD~ "$SCRIPT_DIR"/first.diff
checkDiff HEAD "$SCRIPT_DIR"/second.diff

echo "Success!"
11 changes: 11 additions & 0 deletions maintainers/scripts/auto-rebase/test/second.diff
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
diff --git a/a.nix b/a.nix
index 18ba7ce..bcf38bc 100644
--- a/a.nix
+++ b/a.nix
@@ -1,6 +1,5 @@
{
x,
- why,

z,
}:

0 comments on commit 3425323

Please sign in to comment.