"GOT", but the "O" is a cute, smiling pufferfish. Index | Thread | Search

From:
Stefan Sperling <stsp@stsp.name>
Subject:
Re: Got publishes local unversioned bytes during merge and rebase
To:
Yann Lorwyn <r00tth3w0r1d@gmail.com>
Cc:
gameoftrees@openbsd.org
Date:
Sun, 20 Sep 2026 13:22:57 +0200

Download raw body.

Thread
On Sun, Sep 20, 2026 at 11:17:01AM +0800, Yann Lorwyn wrote:
> Thank you for acknowledging the issue and for preparing the proposed test
> and
> fix. I replayed it against Got 0.128. It blocks the regular-file and ignored
> regular-file cases, but I found two residual cases:
> 
> - With the patch as written, a local symlink to a directory is followed by
>   `stat()`, reported as obstructed, and `got merge` still advances
>   `refs/heads/main` while omitting the incoming path from the merge tree.
> - A local unversioned directory produces the same result.
> 
> Changing `stat()` to `lstat()` blocks the symlink case, but the directory
> case
> remains because merge, rebase, and histedit do not treat `upa.obstructed`
> as a
> stop condition. I suggest using `lstat()` and adding `upa.obstructed > 0` to
> those postpone guards. I also recommend regression tests for symlink-to-
> directory and directory collisions that assert that the target ref does not
> move and that the incoming tree entry is not silently omitted.

Right. Our work tree code should always be using lstat() since we need
to always consider the symlink case.

> So I believe the original issue is addressed for regular files, but the
> current patch still has a history-integrity residual for obstructing
> directories and symlinks. I have recorded the replay details and exact
> outcomes, and I can provide a shorter regression-test-oriented patch if that
> would be useful.

I agree that obstructions should interrupt the merge to allow users to
double-check the merge result.

I have added more tests which should cover all cases. And I have updated
our documentation to mention the term "obstruction" since users should
be aware of this term.

The problem does not affect 'got merge' only. The 'got histedit' and
'got rebsae' commands use a similar mechanism to interrupt the operation
if a problem is found. These two commands were also blind to obstructions,
which I am fixing below. I am not adding separate test cases for these
commands right now. It would be good to test them, too, but we'd be mostly
adding test coverage for trivial code in got.c, since the shared worktree.c
code path is already covered via 'got merge'.

Other commands have to deal with obstructions are 'got update' and commands
which inherit behaviour from 'got update', such as 'got branch' and
'got integrate'. I have added a new test which covers 'got integrate' to
our Git repository's main branch, which shows that the repository state
is OK after integrating a branch with an obstruction in the work tree.
The obstruction only persists in the work tree which triggered the integration
merge. This is a fast-forward merge in Git terms, so there is no risk of
exposing files which haven't already been committed.

Does this patch look complete to you?

 
M  got/got.1                    |   12+  9-
M  got/got.c                    |    7+  5-
M  lib/worktree.c               |   15+  0-
M  regress/cmdline/histedit.sh  |   17+  6-
M  regress/cmdline/merge.sh     |  365+  0-

5 files changed, 416 insertions(+), 20 deletions(-)

commit - 9a4a6d862f3654b1d1d114f098cf7c487ea71660
commit + 81c98775c04265beb18bc4203bca6a627ddfcfac
blob - 63826d61eff2e1b84f540049b83bc496e59594cb
blob + 30e95121f9931e85ac3aa874bffa1a6d9525a861
--- got/got.1
+++ got/got.1
@@ -2621,7 +2621,7 @@ Show the status of each affected file, using the follo
 .It D Ta file was deleted
 .It d Ta file's deletion was prevented by local modifications
 .It A Ta new file was added
-.It \(a~ Ta changes destined for a non-regular file were not merged
+.It \(a~ Ta changes obstructed by a non-regular file were not merged
 .It ? Ta changes destined for an unversioned file were not merged
 .El
 .Pp
@@ -2764,7 +2764,7 @@ Show the status of each affected file, using the follo
 .It D Ta file was deleted
 .It d Ta file's deletion was prevented by local modifications
 .It A Ta new file was added
-.It \(a~ Ta changes destined for a non-regular file were not merged
+.It \(a~ Ta changes obstructed by a non-regular file were not merged
 .It ? Ta changes destined for an unversioned file were not merged
 .El
 .Pp
@@ -2927,13 +2927,14 @@ using the following status codes:
 .It D Ta file was deleted
 .It d Ta file's deletion was prevented by local modifications
 .It A Ta new file was added
-.It \(a~ Ta changes destined for a non-regular file were not merged
+.It \(a~ Ta changes obstructed by a non-regular file were not merged
 .It ? Ta changes destined for an unversioned file were not merged
 .El
 .Pp
 If merge conflicts occur, the rebase operation is interrupted and may
 be continued once conflicts have been resolved.
-If any files with destined changes are found to be missing or unversioned,
+If any files with destined changes are found to be missing, unversioned,
+or obstructed,
 or if files could not be deleted due to differences in deleted content,
 the rebase operation will be interrupted to prevent potentially incomplete
 changes from being committed to the repository without user intervention.
@@ -3196,13 +3197,14 @@ using the following status codes:
 .It D Ta file was deleted
 .It d Ta file's deletion was prevented by local modifications
 .It A Ta new file was added
-.It \(a~ Ta changes destined for a non-regular file were not merged
+.It \(a~ Ta changes obstructed by a non-regular file were not merged
 .It ? Ta changes destined for an unversioned file were not merged
 .El
 .Pp
 If merge conflicts occur, the histedit operation is interrupted and may
 be continued once conflicts have been resolved.
-If any files with destined changes are found to be missing or unversioned,
+If any files with destined changes are found to be missing, unversioned,
+or obstructed,
 or if files could not be deleted due to differences in deleted content,
 the histedit operation will be interrupted to prevent potentially incomplete
 changes from being committed to the repository without user intervention.
@@ -3497,13 +3499,14 @@ using the following status codes:
 .It D Ta file was deleted
 .It d Ta file's deletion was prevented by local modifications
 .It A Ta new file was added
-.It \(a~ Ta changes destined for a non-regular file were not merged
+.It \(a~ Ta changes obstructed by a non-regular file were not merged
 .It ? Ta changes destined for an unversioned file were not merged
 .El
 .Pp
 If merge conflicts occur, the merge operation is interrupted and conflicts
 must be resolved before the merge operation can continue.
-If any files with destined changes are found to be missing or unversioned,
+If any files with destined changes are found to be missing, unversioned,
+or obstructed,
 or if files could not be deleted due to differences in deleted content,
 the merge operation will be interrupted to prevent potentially incomplete
 changes from being committed to the repository without user intervention.
@@ -3726,7 +3729,7 @@ Show the status of each affected file, using the follo
 .It ! Ta changes destined for a missing file were not merged
 .It D Ta file was staged as deleted and still is deleted
 .It d Ta file's deletion was prevented by local modifications
-.It \(a~ Ta changes destined for a non-regular file were not merged
+.It \(a~ Ta changes obstructed by a non-regular file were not merged
 .El
 .Pp
 The options for
blob - 38f8789c90e1c72d22ff3a64eae928bd982965d5
blob + 590d82ff913f113811ff2c911339e3bd2ccd76a7
--- got/got.c
+++ got/got.c
@@ -12051,7 +12051,8 @@ cmd_rebase(int argc, char *argv[])
 
 		print_merge_progress_stats(&upa);
 		if (upa.conflicts > 0 || upa.missing > 0 ||
-		    upa.not_deleted > 0 || upa.unversioned > 0) {
+		    upa.not_deleted > 0 || upa.unversioned > 0 ||
+		    upa.obstructed > 0) {
 			if (upa.conflicts > 0) {
 				error = show_rebase_merge_conflict(&qid->id,
 				    repo);
@@ -12070,7 +12071,7 @@ cmd_rebase(int argc, char *argv[])
 	}
 
 	if (upa.conflicts > 0 || upa.missing > 0 ||
-	    upa.not_deleted > 0 || upa.unversioned > 0) {
+	    upa.not_deleted > 0 || upa.unversioned > 0 || upa.obstructed > 0) {
 		error = got_worktree_rebase_postpone(worktree, fileindex);
 		if (error)
 			goto done;
@@ -13476,7 +13477,8 @@ cmd_histedit(int argc, char *argv[])
 
 		print_merge_progress_stats(&upa);
 		if (upa.conflicts > 0 || upa.missing > 0 ||
-		    upa.not_deleted > 0 || upa.unversioned > 0) {
+		    upa.not_deleted > 0 || upa.unversioned > 0 ||
+		    upa.obstructed > 0) {
 			if (upa.conflicts > 0) {
 				error = show_rebase_merge_conflict(
 				    hle->commit_id, repo);
@@ -13518,7 +13520,7 @@ cmd_histedit(int argc, char *argv[])
 	}
 
 	if (upa.conflicts > 0 || upa.missing > 0 ||
-	    upa.not_deleted > 0 || upa.unversioned > 0) {
+	    upa.not_deleted > 0 || upa.unversioned > 0 || upa.obstructed > 0) {
 		error = got_worktree_histedit_postpone(worktree, fileindex);
 		if (error)
 			goto done;
@@ -14031,7 +14033,7 @@ cmd_merge(int argc, char *argv[])
 			goto done;
 		printf("Merge of %s interrupted on request\n", branch_name);
 	} else if (upa.conflicts > 0 || upa.missing > 0 ||
-	    upa.not_deleted > 0 || upa.unversioned > 0) {
+	    upa.not_deleted > 0 || upa.unversioned > 0 || upa.obstructed > 0) {
 		error = got_worktree_merge_postpone(worktree, fileindex);
 		if (error)
 			goto done;
blob - b3c50d7a852f627a1de9da592bcf0d73dcdd5ac4
blob + ce14148aeec6703b28ab237718530ce6b8debca7
--- lib/worktree.c
+++ lib/worktree.c
@@ -3345,6 +3345,21 @@ merge_file_cb(void *arg, struct got_blob_object *blob1
 					goto done;
 			}
 		} else {
+			if (lstat(ondisk_path, &sb) == -1) {
+				if (errno != ENOENT && errno != ENOTDIR) {
+					err = got_error_from_errno2("stat",
+					    ondisk_path);
+					goto done;
+				}
+			} else {
+				if (S_ISREG(sb.st_mode) || S_ISLNK(sb.st_mode))
+					status = GOT_STATUS_UNVERSIONED;
+				else
+					status = GOT_STATUS_OBSTRUCTED;
+				err = (*a->progress_cb)(a->progress_arg,
+				    status, path2);
+				goto done;
+			}
 			err = add_file(a->worktree, a->fileindex, NULL,
 			     ondisk_path, path2, blob2, mode2,
 			     0, 0, 1, a->allow_bad_symlinks,
blob - 1d993e2f9340895b63648eeed66a23d0497eaa8a
blob + c936996937241a7920b5ca639a7275d88af88a1c
--- regress/cmdline/histedit.sh
+++ regress/cmdline/histedit.sh
@@ -3030,17 +3030,17 @@ test_histedit_added_file() {
 	# +2f81c7988242 -> no-op change: add new file
 	echo new > $testroot/wt/new
 
-	(cd $testroot/wt && got histedit -f > $testroot/stdout)
+	(cd $testroot/wt && got histedit -f > $testroot/stdout \
+		2> $testroot/stderr)
 	local new_commit=`git_show_head $testroot/repo`
 
 	local short_commit2=`trim_obj_id 12 $commit2`
 	local short_new_commit=`trim_obj_id 12 $new_commit`
 
-	echo "A  new" > $testroot/stdout.expected
-	echo "$short_commit2 -> $short_new_commit: add new file" \
-		>> $testroot/stdout.expected
-	echo "Switching work tree to refs/heads/master" \
-		>> $testroot/stdout.expected
+	cat > $testroot/stdout.expected <<EOF
+?  new
+Files not merged because an unversioned file was found in the work tree: 1
+EOF
 
 	cmp -s $testroot/stdout.expected $testroot/stdout
 	ret=$?
@@ -3050,6 +3050,17 @@ test_histedit_added_file() {
 		return 1
 	fi
 
+	cat > $testroot/stderr.expected <<EOF
+got: changes destined for some files were not yet merged and should be merged manually if required before the histedit operation is continued
+EOF
+	cmp -s $testroot/stderr.expected $testroot/stderr
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stderr.expected $testroot/stderr
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
 	test_done "$testroot" "$ret"
 }
 
blob - 576400c021a6d57ecea5c86d8e2478d39b7be3b9
blob + 24217948b63b93f44d3919fe19ef8ca82e01b72d
--- regress/cmdline/merge.sh
+++ regress/cmdline/merge.sh
@@ -2331,6 +2331,367 @@ EOF
 	test_done "$testroot" "$ret"
 }
 
+test_merge_adds_ignored_file() {
+	local testroot=`test_init merge_adds_ignored_file`
+	local commit0=`git_show_head $testroot/repo`
+	local commit0_author_time=`git_show_author_time $testroot/repo`
+
+	git -C $testroot/repo checkout -q -b newbranch
+	echo "new file on branch" > $testroot/repo/gamma/new
+	git -C $testroot/repo add gamma/new > /dev/null
+	git_commit $testroot/repo -m "committing to new file on newbranch"
+	local branch_commit=`git_show_branch_head $testroot/repo newbranch`
+
+	git -C $testroot/repo checkout -q master
+	echo gamma/new > $testroot/repo/.gitignore
+	git -C $testroot/repo add .gitignore > /dev/null
+	echo "ignoring new file on master branch" > $testroot/repo/epsilon/zeta
+	git_commit $testroot/repo -m "committing to zeta on master"
+	local master_commit=`git_show_head $testroot/repo`
+
+	got checkout -b master $testroot/repo $testroot/wt > /dev/null
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got checkout failed unexpectedly" >&2
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	echo 'this file is being ignored' > $testroot/wt/gamma/new
+
+	(cd $testroot/wt && got status > $testroot/stdout)
+
+	echo -n > $testroot/stdout.expected
+
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	(cd $testroot/wt && got status -I > $testroot/stdout)
+
+	cat > $testroot/stdout.expected <<EOF
+?  gamma/new
+EOF
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	cat > $testroot/stderr.expected <<EOF
+got: changes destined for some files were not yet merged and should be merged manually if required before the merge operation is continued
+EOF
+	(cd $testroot/wt && got merge newbranch \
+		> $testroot/stdout 2> $testroot/stderr)
+	ret=$?
+	if [ $ret -eq 0 ]; then
+		echo "got merge succeeded unexpectedly" >&2
+		test_done "$testroot" "1"
+		return 1
+	fi
+
+	local merge_commit=`git_show_head $testroot/repo`
+
+	cat > $testroot/stdout.expected <<EOF
+?  gamma/new
+Files not merged because an unversioned file was found in the work tree: 1
+EOF
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	cmp -s $testroot/stderr.expected $testroot/stderr
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stderr.expected $testroot/stderr
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	test_done "$testroot" "$ret"
+}
+
+test_merge_adds_ignored_symlink() {
+	local testroot=`test_init merge_adds_ignored_symlink`
+	local commit0=`git_show_head $testroot/repo`
+	local commit0_author_time=`git_show_author_time $testroot/repo`
+
+	git -C $testroot/repo checkout -q -b newbranch
+	echo "new file on branch" > $testroot/repo/gamma/new
+	git -C $testroot/repo add gamma/new > /dev/null
+	git_commit $testroot/repo -m "committing to new file on newbranch"
+	local branch_commit=`git_show_branch_head $testroot/repo newbranch`
+
+	git -C $testroot/repo checkout -q master
+	echo gamma/new > $testroot/repo/.gitignore
+	git -C $testroot/repo add .gitignore > /dev/null
+	echo "ignoring new file on master branch" > $testroot/repo/epsilon/zeta
+	git_commit $testroot/repo -m "committing to zeta on master"
+	local master_commit=`git_show_head $testroot/repo`
+
+	got checkout -b master $testroot/repo $testroot/wt > /dev/null
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got checkout failed unexpectedly" >&2
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	ln -s ../alpha $testroot/wt/gamma/new
+
+	(cd $testroot/wt && got status > $testroot/stdout)
+
+	echo -n > $testroot/stdout.expected
+
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	(cd $testroot/wt && got status -I > $testroot/stdout)
+
+	cat > $testroot/stdout.expected <<EOF
+?  gamma/new
+EOF
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	cat > $testroot/stderr.expected <<EOF
+got: changes destined for some files were not yet merged and should be merged manually if required before the merge operation is continued
+EOF
+	(cd $testroot/wt && got merge newbranch \
+		> $testroot/stdout 2> $testroot/stderr)
+	ret=$?
+	if [ $ret -eq 0 ]; then
+		echo "got merge succeeded unexpectedly" >&2
+		test_done "$testroot" "1"
+		return 1
+	fi
+
+	local merge_commit=`git_show_head $testroot/repo`
+
+	cat > $testroot/stdout.expected <<EOF
+?  gamma/new
+Files not merged because an unversioned file was found in the work tree: 1
+EOF
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	cmp -s $testroot/stderr.expected $testroot/stderr
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stderr.expected $testroot/stderr
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	test_done "$testroot" "$ret"
+}
+
+test_merge_adds_ignored_dangling_symlink() {
+	local testroot=`test_init merge_adds_ignored_dangling_symlink`
+	local commit0=`git_show_head $testroot/repo`
+	local commit0_author_time=`git_show_author_time $testroot/repo`
+
+	git -C $testroot/repo checkout -q -b newbranch
+	echo "new file on branch" > $testroot/repo/gamma/new
+	git -C $testroot/repo add gamma/new > /dev/null
+	git_commit $testroot/repo -m "committing to new file on newbranch"
+	local branch_commit=`git_show_branch_head $testroot/repo newbranch`
+
+	git -C $testroot/repo checkout -q master
+	echo gamma/new > $testroot/repo/.gitignore
+	git -C $testroot/repo add .gitignore > /dev/null
+	echo "ignoring new file on master branch" > $testroot/repo/epsilon/zeta
+	git_commit $testroot/repo -m "committing to zeta on master"
+	local master_commit=`git_show_head $testroot/repo`
+
+	got checkout -b master $testroot/repo $testroot/wt > /dev/null
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got checkout failed unexpectedly" >&2
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	ln -f -s ../nonexistent $testroot/wt/gamma/new
+
+	(cd $testroot/wt && got status > $testroot/stdout)
+
+	echo -n > $testroot/stdout.expected
+
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	(cd $testroot/wt && got status -I > $testroot/stdout)
+
+	cat > $testroot/stdout.expected <<EOF
+?  gamma/new
+EOF
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	cat > $testroot/stderr.expected <<EOF
+got: changes destined for some files were not yet merged and should be merged manually if required before the merge operation is continued
+EOF
+	(cd $testroot/wt && got merge newbranch \
+		> $testroot/stdout 2> $testroot/stderr)
+	ret=$?
+	if [ $ret -eq 0 ]; then
+		echo "got merge succeeded unexpectedly" >&2
+		test_done "$testroot" "1"
+		return 1
+	fi
+
+	local merge_commit=`git_show_head $testroot/repo`
+
+	cat > $testroot/stdout.expected <<EOF
+?  gamma/new
+Files not merged because an unversioned file was found in the work tree: 1
+EOF
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	cmp -s $testroot/stderr.expected $testroot/stderr
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stderr.expected $testroot/stderr
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	test_done "$testroot" "$ret"
+}
+
+test_merge_adds_ignored_directory() {
+	local testroot=`test_init merge_adds_ignored_directory`
+	local commit0=`git_show_head $testroot/repo`
+	local commit0_author_time=`git_show_author_time $testroot/repo`
+
+	git -C $testroot/repo checkout -q -b newbranch
+	echo "new file on branch" > $testroot/repo/gamma/new
+	git -C $testroot/repo add gamma/new > /dev/null
+	git_commit $testroot/repo -m "committing to new file on newbranch"
+	local branch_commit=`git_show_branch_head $testroot/repo newbranch`
+
+	git -C $testroot/repo checkout -q master
+	echo gamma/new > $testroot/repo/.gitignore
+	git -C $testroot/repo add .gitignore > /dev/null
+	echo "ignoring new file on master branch" > $testroot/repo/epsilon/zeta
+	git_commit $testroot/repo -m "committing to zeta on master"
+	local master_commit=`git_show_head $testroot/repo`
+
+	got checkout -b master $testroot/repo $testroot/wt > /dev/null
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got checkout failed unexpectedly" >&2
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	mkdir -p $testroot/wt/gamma/new/
+	echo 'this file is being ignored' > $testroot/wt/gamma/new/ignored
+
+	(cd $testroot/wt && got status > $testroot/stdout)
+
+	echo -n > $testroot/stdout.expected
+
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	(cd $testroot/wt && got status -I > $testroot/stdout)
+
+	cat > $testroot/stdout.expected <<EOF
+?  gamma/new/ignored
+EOF
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	cat > $testroot/stderr.expected <<EOF
+got: changes destined for some files were not yet merged and should be merged manually if required before the merge operation is continued
+EOF
+	(cd $testroot/wt && got merge newbranch \
+		> $testroot/stdout 2> $testroot/stderr)
+	ret=$?
+	if [ $ret -eq 0 ]; then
+		echo "got merge succeeded unexpectedly" >&2
+		test_done "$testroot" "1"
+		return 1
+	fi
+
+	local merge_commit=`git_show_head $testroot/repo`
+
+	cat > $testroot/stdout.expected <<EOF
+~  gamma/new
+File paths obstructed by a non-regular file: 1
+EOF
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	cmp -s $testroot/stderr.expected $testroot/stderr
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stderr.expected $testroot/stderr
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	test_done "$testroot" "$ret"
+}
+
 test_parseargs "$@"
 run_test test_merge_basic
 run_test test_merge_forward
@@ -2352,3 +2713,7 @@ run_test test_merge_fetched_branch
 run_test test_merge_fetched_branch_remote
 run_test test_merge_tag
 run_test test_merge_tag_abort
+run_test test_merge_adds_ignored_file
+run_test test_merge_adds_ignored_symlink
+run_test test_merge_adds_ignored_dangling_symlink
+run_test test_merge_adds_ignored_directory