Repository: akuity/kargo (2026-03-26T21:08:07Z)

Issue:
path filtering on merge commits silently skips changes
Other recent work intersected with the issue originally reported in #3475. With ample hindsight, the issue being reported by #3475 was legitimate -- apologies to both @nghazali and @dooman87. I think we all got hung up on debating a wrong solution and it clouded our view of what the actual problem was. There was a lot of discussion of the `-m` flag, which actually would have tipped things too far in the opposite direction, resulting in many false positive commit selections.

Let's give this another try.

## The problem

When `NewestFromBranch` walks a branch's history with `includePaths` configured, for each commit, it finds files the commit touched by running:

```
git show --pretty= --name-only <commitID>
```

For a regular commit, this shows files changed relative to that commit's one parent. For a merge commit, this commit produces a combined diff, which only lists files that differ from *all* parents. For a clean merge with no conflicts, this produces empty output (every file in the result matches at least one parent, so nothing appears in the combined diff).

The result: clean merge commits are invisible to path filtering. The history walk moves on to that commit's parents. Either or both of those may match the path filter, but may be missing content brought into the branch by the other parent.

## Demonstration (no Kargo involved)

```sh
#!/usr/bin/env bash
set -euo pipefail
export GIT_PAGER=cat

# Setup
dir=$(mktemp -d)
echo "Working in $dir"
cd "$dir"
git init -b main
mkdir -p {foo,bar}

echo "base" > foo/file1.txt
echo "base" > foo/file2.txt
echo "base" > bar/other.txt
git add . && git commit -m "initial"

# Branch A: touches foo/file1.txt
git checkout -b branch-a
echo "changed by branch-a" > foo/file1.txt
git add . && git commit -m "branch-a: modify file1"

# Branch B: touches foo/file2.txt
git checkout main
git checkout -b branch-b
echo "changed by branch-b" > foo/file2.txt
git add . && git commit -m "branch-b: modify file2"

# Merge B then A into main
git checkout main
git merge branch-b --no-ff -m "merge branch-b"
git merge branch-a --no-ff -m "merge branch-a"
MERGE_A=$(git rev-parse HEAD)

echo ""
echo "=== git log ==="
git log --oneline --graph

echo ""
echo "=== git show on merge commit: no flags (current behavior) ==="
git show --pretty= --name-only "$MERGE_A"
echo "(empty — combined diff, merge commit invisible to path filtering)"

# Cleanup hint
echo ""
echo "Cleanup: rm -rf $dir"
```

Sample output:

```
=== git log ===
*   e5fdb29 merge branch-a
|\
| * 1a510c7 branch-a: modify file1
* |   78884a9 merge branch-b
|\ \
| |/
|/|
| * afa608f branch-b: modify file2
|/
* 25c01af initial

=== git show on merge commit: no flags (current behavior) ===
(empty — combined diff, merge commit invisible to path filtering)
```

The merge commit is essentially invisible, exactly as #3475 said.

Here's what we extensively debated in #3475, tacked on toward the end of the script:

```sh
echo ""
echo "=== git show on merge commit: -m ==="
git show --pretty= --name-only -m "$MERGE_A"
echo "(shows files from both parent diffs — noisy, includes false positives)"
```

Sample output:

```
=== git show on merge commit: -m ===
foo/file1.txt
foo/file2.txt
(shows files from both parent diffs — noisy, includes false positives)
```

This is no good. The changes to `foo/file2.txt` were not actually introduced by this merge commit (e5fdb29). They were already on the branch, having already been introduced by a different merge commit (78884a9).

Proposing a different solution:

```sh
echo "=== git show on merge commit: --first-parent ==="
git show --pretty= --name-only --first-parent "$MERGE_A"
echo "(shows only what the merge introduced to main — correct)"
```

```
=== git show on merge commit: --first-parent ===
foo/file1.txt
(shows only what the merge introduced to main — correct)
```

This correctly identifies `foo/file1.txt` as the only change introduced to main by the merge commit (e5fdb29).


Discussion:

Patch:
diff --git a/pkg/controller/git/work_tree.go b/pkg/controller/git/work_tree.go
index 6c5349eb96..59f62a14e1 100644
--- a/pkg/controller/git/work_tree.go
+++ b/pkg/controller/git/work_tree.go
@@ -416,7 +416,7 @@ func (w *workTree) Fetch(opts *FetchOptions) error {
 }
 
 func (w *workTree) GetDiffPathsForCommitID(commitID string) ([]string, error) {
-	resBytes, err := libExec.Exec(w.buildGitCommand("show", "--pretty=", "--name-only", commitID))
+	resBytes, err := libExec.Exec(w.buildGitCommand("show", "--pretty=", "--name-only", "--first-parent", commitID))
 	if err != nil {
 		return nil, fmt.Errorf("error getting diff paths for commit %q: %w", commitID, err)
 	}


Test patch:
diff --git a/pkg/controller/git/repo_test.go b/pkg/controller/git/repo_test.go
index ddb73a18d4..afda5e40ea 100644
--- a/pkg/controller/git/repo_test.go
+++ b/pkg/controller/git/repo_test.go
@@ -8,6 +8,8 @@ import (
 
 	"github.com/google/uuid"
 	"github.com/stretchr/testify/require"
+
+	libExec "github.com/akuity/kargo/pkg/exec"
 )
 
 func TestRepo(t *testing.T) {
@@ -176,3 +178,87 @@ with a body
 	})
 
 }
+
+func TestGetDiffPathsForMergeCommit(t *testing.T) {
+	testServer, testRepoURL, testRepoCreds := setupRemoteRepo(t)
+	defer testServer.Close()
+
+	rep, err := Clone(
+		testRepoURL,
+		&ClientOptions{Credentials: &testRepoCreds},
+		nil,
+	)
+	require.NoError(t, err)
+	require.NotNil(t, rep)
+	defer rep.Close()
+
+	wt := internalWorkTree(t, rep)
+
+	// Create initial commit on main with base files
+	require.NoError(t, os.MkdirAll(fmt.Sprintf("%s/foo", rep.Dir()), 0o755))
+	require.NoError(
+		t,
+		os.WriteFile(
+			fmt.Sprintf("%s/foo/file1.txt", rep.Dir()),
+			[]byte("base"),
+			0o600,
+		),
+	)
+	require.NoError(
+		t,
+		os.WriteFile(
+			fmt.Sprintf("%s/foo/file2.txt", rep.Dir()),
+			[]byte("base"),
+			0o600,
+		),
+	)
+	require.NoError(t, rep.AddAllAndCommit("initial commit", nil))
+
+	// Create branch-a and modify file1
+	require.NoError(t, rep.CreateChildBranch("branch-a"))
+	require.NoError(
+		t,
+		os.WriteFile(
+			fmt.Sprintf("%s/foo/file1.txt", rep.Dir()),
+			[]byte("changed by branch-a"),
+			0o600,
+		),
+	)
+	require.NoError(t, rep.AddAllAndCommit("branch-a: modify file1", nil))
+
+	// Back to main, create branch-b, modify file2
+	require.NoError(t, rep.Checkout("main"))
+	require.NoError(t, rep.CreateChildBranch("branch-b"))
+	require.NoError(
+		t,
+		os.WriteFile(
+			fmt.Sprintf("%s/foo/file2.txt", rep.Dir()),
+			[]byte("changed by branch-b"),
+			0o600,
+		),
+	)
+	require.NoError(t, rep.AddAllAndCommit("branch-b: modify file2", nil))
+
+	// Merge branch-b into main
+	require.NoError(t, rep.Checkout("main"))
+	_, err = libExec.Exec(wt.buildGitCommand(
+		"merge", "branch-b", "--no-ff", "-m", "merge branch-b",
+	))
+	require.NoError(t, err)
+
+	// Merge branch-a into main
+	_, err = libExec.Exec(wt.buildGitCommand(
+		"merge", "branch-a", "--no-ff", "-m", "merge branch-a",
+	))
+	require.NoError(t, err)
+
+	mergeCommitID, err := rep.LastCommitID()
+	require.NoError(t, err)
+
+	// GetDiffPathsForCommitID on the merge commit should return only
+	// the file introduced by that merge (file1, from branch-a), not
+	// file2 which was already on main via the earlier merge of branch-b.
+	paths, err := rep.GetDiffPathsForCommitID(mergeCommitID)
+	require.NoError(t, err)
+	require.Equal(t, []string{"foo/file1.txt"}, paths)
+}