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

From:
Stefan Sperling <stsp@stsp.name>
Subject:
preserve binary files during updates and merges
To:
gameoftrees@openbsd.org
Date:
Mon, 15 Nov 2021 14:56:04 +0100

Download raw body.

Thread
This patch solves a long-standing issue where binary files are reduced
to empty files when they get updated or merged into. Obviously bad when
updating files such as images or PDFs that will appear on a web site!

The files are left empty because our diff3 code is not set up to deal
with embedded NUL characters and produces empty output. I have tried to
fix this but eventually gave up and decided to write a custom function
for dealing with binary file merges instead.

This needs to be done in particular because in case of a conflict we
cannot simply embed conflict markers in a binary. Instead, conflicts
on binaries will now behave similarly to how symlink conflicts behave.
When there is a conflict the binary file gets replaced with a text
file which contains conflict markers. This allows such conflicts to
persist, as in they will be shown by 'got status', and future updates
and merges will know to skip this conflicted file.
This text file also contains paths to all versions of the file involved
in the merge. All versions are preserved as temporary files in the work
tree and left for the user to deal with. Some of these files may actually
contain text because this new behaviour will trigger as soon as a single
binary file shows up.

I have added test cases for 'got update' and 'got cherrypick'.
This should be enough to other relevant commands since they all share
the same file merge code.

The tests are using /bin/ls, /bin/cat, and /bin/cp as binary file test data.
Is this a bad idea? This approach works on OpenBSD but I am not sure if
it will work on every -portable platform because some Linux distributions
tend to put things in odd places. Other suggestions?
But I think that fixing this later, once this patch has been committed, will
be easier than trying to find a perfect solution before an initial commit.

ok?

diff d34cb66cb9f25f0f06b250b99d9aa3de97c3cb7d a0d0a18b3fec3a6979cdac678a1a1d915f6aea4a
blob - e1becc061137b20d5e2b85ad0fb8830565d9e2db
blob + 78a5edb08ad1bbb3feb172d3b16c537a9fe1c3cb
--- include/got_error.h
+++ include/got_error.h
@@ -161,6 +161,7 @@
 #define GOT_ERR_MERGE_COMMIT_OUT_OF_DATE 143
 #define GOT_ERR_MERGE_BUSY	144
 #define GOT_ERR_MERGE_PATH	145
+#define GOT_ERR_FILE_BINARY	146
 
 static const struct got_error {
 	int code;
@@ -337,6 +338,7 @@ static const struct got_error {
 	    "work tree and must be continued or aborted first" },
 	{ GOT_ERR_MERGE_PATH,	"cannot merge branch which contains "
 	    "changes outside of this work tree's path prefix" },
+	{ GOT_ERR_FILE_BINARY, "found a binary file instead of text" },
 };
 
 /*
blob - 8ec39e3af09c02989e1df349d533b1442550f19d
blob + 95fdb0726bce1615a82c90e53763702fa49bc139
--- lib/diff3.c
+++ lib/diff3.c
@@ -223,10 +223,20 @@ diffreg(BUF **d, const char *path1, const char *path2,
 	if (err)
 		goto done;
 
-	err = got_diffreg(&diffreg_result, f1, f2, diff_algo, 0, 1);
+	err = got_diffreg(&diffreg_result, f1, f2, diff_algo, 0, 0);
 	if (err)
 		goto done;
 
+	if (diffreg_result) {
+		struct diff_result *diff_result = diffreg_result->result;
+		int atomizer_flags = (diff_result->left->atomizer_flags |
+		    diff_result->right->atomizer_flags);
+		if ((atomizer_flags & DIFF_ATOMIZER_FOUND_BINARY_DATA)) {
+			err = got_error(GOT_ERR_FILE_BINARY);
+			goto done;
+		}
+	}
+
 	err = got_diffreg_output(NULL, NULL, diffreg_result, 1, 1, "", "",
 	    GOT_DIFF_OUTPUT_EDSCRIPT, 0, outfile);
 	if (err)
blob - 8b445649ab78fb4dde26c6c0d79295a825c10599
blob + 10c1f56c45d827a37480453d28144748ecda99a7
--- lib/worktree.c
+++ lib/worktree.c
@@ -759,6 +759,159 @@ check_files_equal(int *same, FILE *f1, FILE *f2)
 	return check_file_contents_equal(same, f1, f2);
 }
 
+static const struct got_error *
+copy_file_to_fd(off_t *outsize, FILE *f, int outfd)
+{
+	uint8_t fbuf[65536];
+	size_t flen;
+	ssize_t outlen;
+
+	*outsize = 0;
+
+	if (fseek(f, 0L, SEEK_SET) == -1)
+		return got_ferror(f, GOT_ERR_IO);
+
+	for (;;) {
+		flen = fread(fbuf, 1, sizeof(fbuf), f);
+		if (flen == 0) {
+			if (ferror(f))
+				return got_error_from_errno("fread");
+			if (feof(f))
+				break;
+		}
+		outlen = write(outfd, fbuf, flen);
+		if (outlen == -1)
+			return got_error_from_errno("write");
+		if (outlen != flen)
+			return got_error(GOT_ERR_IO);
+		*outsize += outlen;
+	}
+
+	return NULL;
+}
+
+static const struct got_error *
+merge_binary_file(int *overlapcnt, int merged_fd,
+    FILE *f_deriv, FILE *f_orig, FILE *f_deriv2,
+    const char *label_deriv, const char *label_orig, const char *label_deriv2,
+    const char *ondisk_path)
+{
+	const struct got_error *err = NULL;
+	int same_content, changed_deriv, changed_deriv2;
+	int fd_orig = -1, fd_deriv = -1, fd_deriv2 = -1;
+	off_t size_orig = 0, size_deriv = 0, size_deriv2 = 0;
+	char *path_orig = NULL, *path_deriv = NULL, *path_deriv2 = NULL;
+	char *base_path_orig = NULL, *base_path_deriv = NULL;
+	char *base_path_deriv2 = NULL;
+
+	*overlapcnt = 0;
+
+	err = check_files_equal(&same_content, f_deriv, f_deriv2);
+	if (err)
+		return err;
+
+	if (same_content)
+		return copy_file_to_fd(&size_deriv, f_deriv, merged_fd);
+
+	err = check_files_equal(&same_content, f_deriv, f_orig);
+	if (err)
+		return err;
+	changed_deriv = !same_content;
+	err = check_files_equal(&same_content, f_deriv2, f_orig);
+	if (err)
+		return err;
+	changed_deriv2 = !same_content;
+
+	if (changed_deriv && changed_deriv2) {
+		*overlapcnt = 1;
+		if (asprintf(&base_path_orig, "%s-orig", ondisk_path) == -1) {
+			err = got_error_from_errno("asprintf");
+			goto done;
+		}
+		if (asprintf(&base_path_deriv, "%s-1", ondisk_path) == -1) {
+			err = got_error_from_errno("asprintf");
+			goto done;
+		}
+		if (asprintf(&base_path_deriv2, "%s-2", ondisk_path) == -1) {
+			err = got_error_from_errno("asprintf");
+			goto done;
+		}
+		err = got_opentemp_named_fd(&path_orig, &fd_orig,
+		    base_path_orig);
+		if (err)
+			goto done;
+		err = got_opentemp_named_fd(&path_deriv, &fd_deriv,
+		    base_path_deriv);
+		if (err)
+			goto done;
+		err = got_opentemp_named_fd(&path_deriv2, &fd_deriv2,
+		    base_path_deriv2);
+		if (err)
+			goto done;
+		err = copy_file_to_fd(&size_orig, f_orig, fd_orig);
+		if (err)
+			goto done;
+		err = copy_file_to_fd(&size_deriv, f_deriv, fd_deriv);
+		if (err)
+			goto done;
+		err = copy_file_to_fd(&size_deriv2, f_deriv2, fd_deriv2);
+		if (err)
+			goto done;
+		if (dprintf(merged_fd, "Binary files differ and cannot be "
+		    "merged automatically:\n") < 0) {
+			err = got_error_from_errno("dprintf");
+			goto done;
+		}
+		if (dprintf(merged_fd, "%s%s%s\nfile %s\n",
+		    GOT_DIFF_CONFLICT_MARKER_BEGIN,
+		    label_deriv ? " " : "",
+		    label_deriv ? label_deriv : "",
+		    path_deriv) < 0) {
+			err = got_error_from_errno("dprintf");
+			goto done;
+		}
+		if (size_orig > 0) {
+			if (dprintf(merged_fd, "%s%s%s\nfile %s\n",
+			    GOT_DIFF_CONFLICT_MARKER_ORIG,
+			    label_orig ? " " : "",
+			    label_orig ? label_orig : "",
+			    path_orig) < 0) {
+				err = got_error_from_errno("dprintf");
+				goto done;
+			}
+		}
+		if (dprintf(merged_fd, "%s\nfile %s\n%s%s%s\n",
+		    GOT_DIFF_CONFLICT_MARKER_SEP,
+		    path_deriv2,
+		    GOT_DIFF_CONFLICT_MARKER_END,
+		    label_deriv2 ?  " " : "",
+		    label_deriv2 ? label_deriv2 : "") < 0) {
+			err = got_error_from_errno("dprintf");
+			goto done;
+		}
+	} else if (changed_deriv)
+		err = copy_file_to_fd(&size_deriv, f_deriv, merged_fd);
+	else if (changed_deriv2)
+		err = copy_file_to_fd(&size_deriv2, f_deriv2, merged_fd);
+done:
+	if (size_orig == 0 && path_orig && unlink(path_orig) == -1 &&
+	    err == NULL)
+		err = got_error_from_errno2("unlink", path_orig);
+	if (fd_orig != -1 && close(fd_orig) == -1 && err == NULL)
+		err = got_error_from_errno2("close", path_orig);
+	if (fd_deriv != -1 && close(fd_deriv) == -1 && err == NULL)
+		err = got_error_from_errno2("close", path_deriv);
+	if (fd_deriv2 != -1 && close(fd_deriv2) == -1 && err == NULL)
+		err = got_error_from_errno2("close", path_deriv2);
+	free(path_orig);
+	free(path_deriv);
+	free(path_deriv2);
+	free(base_path_orig);
+	free(base_path_deriv);
+	free(base_path_deriv2);
+	return err;
+}
+
 /*
  * Perform a 3-way merge where the file f_orig acts as the common
  * ancestor, the file f_deriv acts as the first derived version,
@@ -798,8 +951,15 @@ merge_file(int *local_changes_subsumed, struct got_wor
 
 	err = got_merge_diff3(&overlapcnt, merged_fd, f_deriv, f_orig,
 	    f_deriv2, label_deriv, label_orig, label_deriv2, diff_algo);
-	if (err)
-		goto done;
+	if (err) {
+		if (err->code != GOT_ERR_FILE_BINARY)
+			goto done;
+		err = merge_binary_file(&overlapcnt, merged_fd, f_deriv,
+		    f_orig, f_deriv2, label_deriv, label_orig, label_deriv2,
+		    ondisk_path);
+		if (err)
+			goto done;
+	}
 
 	err = (*progress_cb)(progress_arg,
 	    overlapcnt > 0 ? GOT_STATUS_CONFLICT : GOT_STATUS_MERGE, path);
blob - e03fd43fd1cb428c958535c9201a1f65fe2366ad
blob + bb31a4ff0f60f6eae6b54b90abdbf7ce98b11618
--- regress/cmdline/cherrypick.sh
+++ regress/cmdline/cherrypick.sh
@@ -1462,6 +1462,233 @@ test_cherrypick_dot_on_a_line_by_itself() {
 	test_done "$testroot" "$ret"
 }
 
+test_cherrypick_binary_file() {
+	local testroot=`test_init cherrypick_binary_file`
+	local commit_id0=`git_show_head $testroot/repo`
+
+	got checkout $testroot/repo $testroot/wt > /dev/null
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	cp /bin/ls $testroot/wt/foo
+	chmod 755 $testroot/wt/foo
+	(cd $testroot/wt && got add foo >/dev/null)
+	(cd $testroot/wt && got commit -m 'add binary file' > /dev/null)
+	local commit_id1=`git_show_head $testroot/repo`
+
+	cp /bin/cat $testroot/wt/foo
+	chmod 755 $testroot/wt/foo
+	(cd $testroot/wt && got commit -m 'change binary file' > /dev/null)
+	local commit_id2=`git_show_head $testroot/repo`
+
+	cp /bin/cp $testroot/wt/foo
+	chmod 755 $testroot/wt/foo
+	(cd $testroot/wt && got commit -m 'change binary file' > /dev/null)
+	local commit_id3=`git_show_head $testroot/repo`
+
+	(cd $testroot/wt && got rm foo >/dev/null)
+	(cd $testroot/wt && got commit -m 'remove binary file' > /dev/null)
+	local commit_id4=`git_show_head $testroot/repo`
+
+	# backdate the work tree to make it usable for cherry-picking
+	(cd $testroot/wt && got up -c $commit_id0 > /dev/null)
+
+	# cherry-pick addition of a binary file
+	(cd $testroot/wt && got cy $commit_id1 > $testroot/stdout)
+
+	echo "A  foo" > $testroot/stdout.expected
+	echo "Merged commit $commit_id1" >> $testroot/stdout.expected
+
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	cp /bin/ls $testroot/content.expected
+	chmod 755 $testroot/content.expected
+	cp $testroot/wt/foo $testroot/content
+	cmp -s $testroot/content.expected $testroot/content
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/content.expected $testroot/content
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	# cherry-pick modification of a binary file
+	(cd $testroot/wt && got cy $commit_id2 > $testroot/stdout)
+
+	echo "G  foo" > $testroot/stdout.expected
+	echo "Merged commit $commit_id2" >> $testroot/stdout.expected
+
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	cp /bin/cat $testroot/content.expected
+	chmod 755 $testroot/content.expected
+	cp $testroot/wt/foo $testroot/content
+	cmp -s $testroot/content.expected $testroot/content
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/content.expected $testroot/content
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	# cherry-pick conflicting addition of a binary file
+	(cd $testroot/wt && got cy $commit_id1 > $testroot/stdout)
+
+	echo "C  foo" > $testroot/stdout.expected
+	echo "Merged commit $commit_id1" >> $testroot/stdout.expected
+	echo "Files with new merge conflicts: 1" >> $testroot/stdout.expected
+
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	echo "Binary files differ and cannot be merged automatically:" \
+		> $testroot/content.expected
+	echo "<<<<<<< merged change: commit $commit_id1" \
+		>> $testroot/content.expected
+	echo -n "file " >> $testroot/content.expected
+	ls $testroot/wt/foo-1-* >> $testroot/content.expected
+	echo '=======' >> $testroot/content.expected
+	echo -n "file " >> $testroot/content.expected
+	ls $testroot/wt/foo-2-* >> $testroot/content.expected
+	echo '>>>>>>>' >> $testroot/content.expected
+	cp $testroot/wt/foo $testroot/content
+	cmp -s $testroot/content.expected $testroot/content
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/content.expected $testroot/content
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	# revert local changes to allow further testing
+	(cd $testroot/wt && got revert -R . >/dev/null)
+
+	(cd $testroot/wt && got status > $testroot/stdout)
+	echo '?  foo' > $testroot/stdout.expected
+	echo -n '?  ' >> $testroot/stdout.expected
+	(cd $testroot/wt && ls foo-1-* >> $testroot/stdout.expected)
+	echo -n '?  ' >> $testroot/stdout.expected
+	(cd $testroot/wt && ls foo-2-* >> $testroot/stdout.expected)
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	# tidy up
+	rm $testroot/wt/foo $testroot/wt/foo-1-* $testroot/wt/foo-2-*
+	(cd $testroot/wt && got up -c $commit_id1 > /dev/null)
+
+	# cherry-pick conflicting modification of a binary file
+	(cd $testroot/wt && got cy $commit_id3 > $testroot/stdout)
+
+	echo "C  foo" > $testroot/stdout.expected
+	echo "Merged commit $commit_id3" >> $testroot/stdout.expected
+	echo "Files with new merge conflicts: 1" >> $testroot/stdout.expected
+
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	echo "Binary files differ and cannot be merged automatically:" \
+		> $testroot/content.expected
+	echo '<<<<<<<' >> $testroot/content.expected
+	echo -n "file " >> $testroot/content.expected
+	ls $testroot/wt/foo-1-* >> $testroot/content.expected
+	echo "||||||| 3-way merge base: commit $commit_id2" \
+		>> $testroot/content.expected
+	echo -n "file " >> $testroot/content.expected
+	ls $testroot/wt/foo-orig-* >> $testroot/content.expected
+	echo '=======' >> $testroot/content.expected
+	echo -n "file " >> $testroot/content.expected
+	ls $testroot/wt/foo-2-* >> $testroot/content.expected
+	echo ">>>>>>> merged change: commit $commit_id3" \
+		>> $testroot/content.expected
+	cp $testroot/wt/foo $testroot/content
+	cmp -s $testroot/content.expected $testroot/content
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/content.expected $testroot/content
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	cp /bin/ls $testroot/content.expected
+	chmod 755 $testroot/content.expected
+	cat $testroot/wt/foo-1-* > $testroot/content
+	cmp -s $testroot/content.expected $testroot/content
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/content.expected $testroot/content
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	cp /bin/cp $testroot/content.expected
+	chmod 755 $testroot/content.expected
+	cat $testroot/wt/foo-2-* > $testroot/content
+	cmp -s $testroot/content.expected $testroot/content
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/content.expected $testroot/content
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	# revert local changes to allow further testing
+	(cd $testroot/wt && got revert -R . > /dev/null)
+	rm $testroot/wt/foo-1-*
+	rm $testroot/wt/foo-2-*
+	(cd $testroot/wt && got up -c $commit_id3 > /dev/null)
+
+	# cherry-pick deletion of a binary file
+	(cd $testroot/wt && got cy $commit_id4 > $testroot/stdout)
+
+	echo "D  foo" > $testroot/stdout.expected
+	echo "Merged commit $commit_id4" >> $testroot/stdout.expected
+
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	if [ -e $testroot/wt/foo ]; then
+		echo "removed file foo still exists on disk" >&2
+		test_done "$testroot" "1"
+		return 1
+	fi
+	test_done "$testroot" "0"
+}
+
 test_parseargs "$@"
 run_test test_cherrypick_basic
 run_test test_cherrypick_root_commit
@@ -1478,3 +1705,4 @@ run_test test_cherrypick_conflict_no_eol2
 run_test test_cherrypick_unrelated_changes
 run_test test_cherrypick_same_branch
 run_test test_cherrypick_dot_on_a_line_by_itself
+run_test test_cherrypick_binary_file
blob - d064dee9ea07f4c1490ffd37ad9aed281f7f6cb0
blob + 51b2e89261f318fde6e149922ab6d66ef3629aac
--- regress/cmdline/update.sh
+++ regress/cmdline/update.sh
@@ -2743,6 +2743,267 @@ test_update_quiet() {
 	test_done "$testroot" "$ret"
 }
 
+test_update_binary_file() {
+	local testroot=`test_init update_binary_file`
+	local commit_id0=`git_show_head $testroot/repo`
+
+	got checkout $testroot/repo $testroot/wt > /dev/null
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	cp /bin/ls $testroot/wt/foo
+	chmod 755 $testroot/wt/foo
+	(cd $testroot/wt && got add foo >/dev/null)
+	(cd $testroot/wt && got commit -m 'add binary file' > /dev/null)
+	local commit_id1=`git_show_head $testroot/repo`
+
+	cp /bin/cat $testroot/wt/foo
+	chmod 755 $testroot/wt/foo
+	(cd $testroot/wt && got commit -m 'change binary file' > /dev/null)
+	local commit_id2=`git_show_head $testroot/repo`
+
+	cp /bin/cp $testroot/wt/foo
+	chmod 755 $testroot/wt/foo
+	(cd $testroot/wt && got commit -m 'change binary file' > /dev/null)
+	local commit_id3=`git_show_head $testroot/repo`
+
+	(cd $testroot/wt && got rm foo >/dev/null)
+	(cd $testroot/wt && got commit -m 'remove binary file' > /dev/null)
+	local commit_id4=`git_show_head $testroot/repo`
+
+	# backdate the work tree to make it usable for updating
+	(cd $testroot/wt && got up -c $commit_id0 > /dev/null)
+
+	# update which adds a binary file
+	(cd $testroot/wt && got up -c $commit_id1 > $testroot/stdout)
+
+	echo "A  foo" > $testroot/stdout.expected
+	echo -n "Updated to refs/heads/master: $commit_id1" \
+		>> $testroot/stdout.expected
+	echo >> $testroot/stdout.expected
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	cp /bin/ls $testroot/content.expected
+	chmod 755 $testroot/content.expected
+	cat $testroot/wt/foo > $testroot/content
+
+	cmp -s $testroot/content.expected $testroot/content
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/content.expected $testroot/content
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	# update which adds a conflicting binary file
+	(cd $testroot/wt && got up -c $commit_id0 > /dev/null)
+	cp /bin/cat $testroot/wt/foo
+	chmod 755 $testroot/wt/foo
+	(cd $testroot/wt && got add foo > /dev/null)
+	(cd $testroot/wt && got up -c $commit_id1 > $testroot/stdout)
+
+	echo "C  foo" > $testroot/stdout.expected
+	echo "Updated to refs/heads/master: $commit_id1" \
+		>> $testroot/stdout.expected
+	echo "Files with new merge conflicts: 1" >> $testroot/stdout.expected
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	echo "Binary files differ and cannot be merged automatically:" \
+		> $testroot/content.expected
+	echo "<<<<<<< merged change: commit $commit_id1" \
+		>> $testroot/content.expected
+	echo -n "file " >> $testroot/content.expected
+	ls $testroot/wt/foo-1-* >> $testroot/content.expected
+	echo '=======' >> $testroot/content.expected
+	echo -n "file " >> $testroot/content.expected
+	ls $testroot/wt/foo-2-* >> $testroot/content.expected
+	echo ">>>>>>>" >> $testroot/content.expected
+	cat $testroot/wt/foo > $testroot/content
+
+	cmp -s $testroot/content.expected $testroot/content
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/content.expected $testroot/content
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	cp /bin/ls $testroot/content.expected
+	chmod 755 $testroot/content.expected
+	cat $testroot/wt/foo-1-* > $testroot/content
+
+	cmp -s $testroot/content.expected $testroot/content
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/content.expected $testroot/content
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	cp /bin/cat $testroot/content.expected
+	chmod 755 $testroot/content.expected
+	cat $testroot/wt/foo-2-* > $testroot/content
+
+	cmp -s $testroot/content.expected $testroot/content
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/content.expected $testroot/content
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	# tidy up
+	(cd $testroot/wt && got revert -R . >/dev/null)
+	rm $testroot/wt/foo-1-* $testroot/wt/foo-2-*
+	(cd $testroot/wt && got up -c $commit_id1 > /dev/null)
+
+	# update which changes a binary file
+	(cd $testroot/wt && got up -c $commit_id2 > $testroot/stdout)
+
+	echo "U  foo" > $testroot/stdout.expected
+	echo -n "Updated to refs/heads/master: $commit_id2" \
+		>> $testroot/stdout.expected
+	echo >> $testroot/stdout.expected
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	cp /bin/cat $testroot/content.expected
+	chmod 755 $testroot/content.expected
+	cat $testroot/wt/foo > $testroot/content
+
+	cmp -s $testroot/content.expected $testroot/content
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/content.expected $testroot/content
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	# update which changes a locally modified binary file
+	cp /bin/ls $testroot/wt/foo
+	chmod 755 $testroot/wt/foo
+	(cd $testroot/wt && got up -c $commit_id3 > $testroot/stdout)
+
+	echo "C  foo" > $testroot/stdout.expected
+	echo -n "Updated to refs/heads/master: $commit_id3" \
+		>> $testroot/stdout.expected
+	echo >> $testroot/stdout.expected
+	echo "Files with new merge conflicts: 1" >> $testroot/stdout.expected
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	echo "Binary files differ and cannot be merged automatically:" \
+		> $testroot/content.expected
+	echo "<<<<<<< merged change: commit $commit_id3" \
+		>> $testroot/content.expected
+	echo -n "file " >> $testroot/content.expected
+	ls $testroot/wt/foo-1-* >> $testroot/content.expected
+	echo "||||||| 3-way merge base: commit $commit_id2" \
+		>> $testroot/content.expected
+	echo -n "file " >> $testroot/content.expected
+	ls $testroot/wt/foo-orig-* >> $testroot/content.expected
+	echo '=======' >> $testroot/content.expected
+	echo -n "file " >> $testroot/content.expected
+	ls $testroot/wt/foo-2-* >> $testroot/content.expected
+	echo ">>>>>>>" >> $testroot/content.expected
+	cat $testroot/wt/foo > $testroot/content
+
+	cmp -s $testroot/content.expected $testroot/content
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/content.expected $testroot/content
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	cp /bin/cp $testroot/content.expected
+	chmod 755 $testroot/content.expected
+	cp $testroot/wt/foo-1-* $testroot/content
+	cmp -s $testroot/content.expected $testroot/content
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/content.expected $testroot/content
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	cp /bin/ls $testroot/content.expected
+	chmod 755 $testroot/content.expected
+	cp $testroot/wt/foo-2-* $testroot/content
+	cmp -s $testroot/content.expected $testroot/content
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/content.expected $testroot/content
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	(cd $testroot/wt && got status > $testroot/stdout)
+	echo 'C  foo' > $testroot/stdout.expected
+	echo -n '?  ' >> $testroot/stdout.expected
+	(cd $testroot/wt && ls foo-1-* >> $testroot/stdout.expected)
+	echo -n '?  ' >> $testroot/stdout.expected
+	(cd $testroot/wt && ls foo-2-* >> $testroot/stdout.expected)
+	echo -n '?  ' >> $testroot/stdout.expected
+	(cd $testroot/wt && ls foo-orig-* >> $testroot/stdout.expected)
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	# tidy up
+	(cd $testroot/wt && got revert -R . > /dev/null)
+	rm $testroot/wt/foo-orig-* $testroot/wt/foo-1-* $testroot/wt/foo-2-*
+
+	# update which deletes a binary file
+	(cd $testroot/wt && got up -c $commit_id4 > $testroot/stdout)
+	echo "D  foo" > $testroot/stdout.expected
+	echo -n "Updated to refs/heads/master: $commit_id4" \
+		>> $testroot/stdout.expected
+	echo >> $testroot/stdout.expected
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret="$?"
+	if [ "$ret" != "0" ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+	fi
+
+	if [ -e $testroot/wt/foo ]; then
+		echo "removed file foo still exists on disk" >&2
+		test_done "$testroot" "1"
+		return 1
+	fi
+	test_done "$testroot" "0"
+}
+
 test_parseargs "$@"
 run_test test_update_basic
 run_test test_update_adds_file
@@ -2786,3 +3047,4 @@ run_test test_update_single_file
 run_test test_update_file_skipped_due_to_conflict
 run_test test_update_file_skipped_due_to_obstruction
 run_test test_update_quiet
+run_test test_update_binary_file