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

From:
Stefan Sperling <stsp@stsp.name>
Subject:
removing unversioned files with 'got rm'
To:
gameoftrees@openbsd.org
Date:
Tue, 7 Jul 2026 17:03:54 +0200

Download raw body.

Thread
  • Stefan Sperling:

    removing unversioned files with 'got rm'

We have long been very careful about removing files that are not tracked
under version control since there is a real risk of data loss if new files
are removed before being committed (i.e. copied to the repository).

In a recent project, where I tidy up some legacy Java code, there are many
generated files which get in my way and I am too lazy to write 'ant clean'
targets for them. So I finally have an unfortunate need for deleting such
files from a work tree with 'got rm'. Removing them by hand with rm or with
find -delete, or by generating arguments for 'rm' from the output of
'got status -s ?' are all worse options than this.

To avoid accidental deletion of files I did not want to tie this feature
into the usual -f (force) option. Rather, deletion of unversioned files
needs to be requested via the status code option: got rm -s ? ...
Which is safe enough to avoid accidents, I hope.

Please do not use this to implement 'make clean' in your Makefiles.

ok?

M  cvg/cvg.c               |    1+   1-
M  got/got.1               |    7+   0-
M  got/got.c               |    5+   2-
M  include/got_worktree.h  |    1+   1-
M  lib/worktree.c          |   40+  19-
M  regress/cmdline/rm.sh   |  115+   0-

6 files changed, 169 insertions(+), 23 deletions(-)

commit - d982353621b77ce15a0c2304c8dd4ab4bf58d377
commit + e58cd6c07e37081d096b425a39c9f4b74c1688d2
blob - 01cb4e7ea65057c71ac726267aea2f5cf2e25801
blob + 63826144eaeb4656eb58f5a3124aefdf31707773
--- cvg/cvg.c
+++ cvg/cvg.c
@@ -6777,7 +6777,7 @@ cmd_remove(int argc, char *argv[])
 
 	error = got_worktree_schedule_delete(worktree, &paths,
 	    delete_local_mods, status_codes, print_remove_status, NULL,
-	    repo, keep_on_disk, ignore_missing_paths);
+	    repo, keep_on_disk, ignore_missing_paths, 0);
 done:
 	if (repo) {
 		const struct got_error *close_err = got_repo_close(repo);
blob - aaf85dac9d67ac0abd3101fa829a22473cdf9c93
blob + 6b0e0497e4f97647326e69d2e6d30cb9e0046992
--- got/got.1
+++ got/got.1
@@ -1950,7 +1950,14 @@ The following status codes may be specified:
 .Fl f
 option)
 .It ! Ta versioned file expected on disk but missing
+.It ? Ta unversioned file (deletion is permanent!)
 .El
+.Pp
+The
+.Fl s\ ?
+option in combination with
+.Fl k
+results in a no-op since the unversioned files will be kept on disk.
 .El
 .Tg pa
 .It Xo
blob - f57ee6d3b40565d80708f683462754d670794ab1
blob + 5f8225f16e839e72a106efef0b3877245ccfc081
--- got/got.c
+++ got/got.c
@@ -8441,7 +8441,7 @@ cmd_remove(int argc, char *argv[])
 	struct got_pathlist_head paths;
 	struct got_pathlist_entry *pe;
 	int ch, delete_local_mods = 0, can_recurse = 0, keep_on_disk = 0, i;
-	int ignore_missing_paths = 0;
+	int ignore_missing_paths = 0, delete_unversioned = 0;
 	int *pack_fds = NULL;
 
 	RB_INIT(&paths);
@@ -8473,6 +8473,9 @@ cmd_remove(int argc, char *argv[])
 				case GOT_STATUS_MISSING:
 					ignore_missing_paths = 1;
 					break;
+				case GOT_STATUS_UNVERSIONED:
+					delete_unversioned = 1;
+					break;
 				default:
 					errx(1, "invalid status code '%c'",
 					    optarg[i]);
@@ -8554,7 +8557,7 @@ cmd_remove(int argc, char *argv[])
 
 	error = got_worktree_schedule_delete(worktree, &paths,
 	    delete_local_mods, status_codes, print_remove_status, NULL,
-	    repo, keep_on_disk, ignore_missing_paths);
+	    repo, keep_on_disk, ignore_missing_paths, delete_unversioned);
 done:
 	if (repo) {
 		const struct got_error *close_err = got_repo_close(repo);
blob - 667a2af518d6c6a7baff55df5072d8a0d95e6810
blob + f875c7a075d9aa4b99c1585c45c322658131589f
--- include/got_worktree.h
+++ include/got_worktree.h
@@ -238,7 +238,7 @@ const struct got_error *got_worktree_schedule_add(stru
 const struct got_error *
 got_worktree_schedule_delete(struct got_worktree *,
     struct got_pathlist_head *, int, const char *,
-    got_worktree_delete_cb, void *, struct got_repository *, int, int);
+    got_worktree_delete_cb, void *, struct got_repository *, int, int, int);
 
 /* A callback function which is used to select or reject a patch. */
 typedef const struct got_error *(*got_worktree_patch_cb)(int *, void *,
blob - 2f2919d37662afe63851b5b51815d5022a69051e
blob + 47e5edd5d36cc81b08f7c69c522d9c1f99d9c543
--- lib/worktree.c
+++ lib/worktree.c
@@ -4554,6 +4554,7 @@ struct schedule_deletion_args {
 	int delete_local_mods;
 	int keep_on_disk;
 	int ignore_missing_paths;
+	int delete_unversioned;
 	const char *status_path;
 	size_t status_path_len;
 	const char *status_codes;
@@ -4577,25 +4578,35 @@ schedule_for_deletion(void *arg, unsigned char status,
 		return got_error_set_errno(ENOENT, relpath);
 	}
 
-	ie = got_fileindex_entry_get(a->fileindex, relpath, strlen(relpath));
-	if (ie == NULL)
-		return got_error_path(relpath, GOT_ERR_FILE_STATUS);
-
-	staged_status = get_staged_status(ie);
-	if (staged_status != GOT_STATUS_NO_CHANGE) {
-		if (staged_status == GOT_STATUS_DELETE)
+	if (status == GOT_STATUS_UNVERSIONED) {
+		if (a->keep_on_disk)
 			return NULL;
-		return got_error_path(relpath, GOT_ERR_FILE_STAGED);
+		if (!a->delete_unversioned)
+			return got_error_path(relpath, GOT_ERR_FILE_STATUS);
+	} else {
+		ie = got_fileindex_entry_get(a->fileindex, relpath,
+		    strlen(relpath));
+		if (ie == NULL)
+			return got_error_path(relpath, GOT_ERR_FILE_STATUS);
+
+		staged_status = get_staged_status(ie);
+		if (staged_status != GOT_STATUS_NO_CHANGE) {
+			if (staged_status == GOT_STATUS_DELETE)
+				return NULL;
+			return got_error_path(relpath, GOT_ERR_FILE_STAGED);
+		}
 	}
 
 	if (asprintf(&ondisk_path, "%s/%s", a->worktree->root_path,
 	    relpath) == -1)
 		return got_error_from_errno("asprintf");
 
-	err = get_file_status(&status, &sb, ie, ondisk_path, dirfd, de_name,
-	    a->repo);
-	if (err)
-		goto done;
+	if (ie) {
+		err = get_file_status(&status, &sb, ie, ondisk_path, dirfd, de_name,
+		    a->repo);
+		if (err)
+			goto done;
+	}
 
 	if (a->status_codes) {
 		size_t ncodes = strlen(a->status_codes);
@@ -4610,7 +4621,8 @@ schedule_for_deletion(void *arg, unsigned char status,
 			return NULL;
 		}
 		if (a->status_codes[i] != GOT_STATUS_MODIFY &&
-		    a->status_codes[i] != GOT_STATUS_MISSING) {
+		    a->status_codes[i] != GOT_STATUS_MISSING &&
+		    a->status_codes[i] != GOT_STATUS_UNVERSIONED) {
 			static char msg[64];
 			snprintf(msg, sizeof(msg),
 			    "invalid status code '%c'", a->status_codes[i]);
@@ -4630,8 +4642,13 @@ schedule_for_deletion(void *arg, unsigned char status,
 			err = got_error_set_errno(ENOENT, relpath);
 			goto done;
 		}
+		if (status == GOT_STATUS_UNVERSIONED && !a->delete_unversioned) {
+			err = got_error_path(relpath, GOT_ERR_FILE_STATUS);
+			goto done;
+		}
 		if (status != GOT_STATUS_MODIFY &&
-		    status != GOT_STATUS_MISSING) {
+		    status != GOT_STATUS_MISSING &&
+		    status != GOT_STATUS_UNVERSIONED)  {
 			err = got_error_path(relpath, GOT_ERR_FILE_STATUS);
 			goto done;
 		}
@@ -4675,10 +4692,12 @@ schedule_for_deletion(void *arg, unsigned char status,
 		    strlen(ondisk_path), root_len) != 0);
 	}
 
-	if (got_fileindex_entry_has_blob(ie))
-		got_fileindex_entry_mark_deleted_from_disk(ie);
-	else
-		got_fileindex_entry_remove(a->fileindex, ie);
+	if (ie) {
+		if (got_fileindex_entry_has_blob(ie))
+			got_fileindex_entry_mark_deleted_from_disk(ie);
+		else
+			got_fileindex_entry_remove(a->fileindex, ie);
+	}
 done:
 	free(ondisk_path);
 	if (err)
@@ -4694,7 +4713,8 @@ got_worktree_schedule_delete(struct got_worktree *work
     struct got_pathlist_head *paths, int delete_local_mods,
     const char *status_codes,
     got_worktree_delete_cb progress_cb, void *progress_arg,
-    struct got_repository *repo, int keep_on_disk, int ignore_missing_paths)
+    struct got_repository *repo, int keep_on_disk, int ignore_missing_paths,
+    int delete_unversioned)
 {
 	struct got_fileindex *fileindex = NULL;
 	char *fileindex_path = NULL;
@@ -4719,6 +4739,7 @@ got_worktree_schedule_delete(struct got_worktree *work
 	sda.delete_local_mods = delete_local_mods;
 	sda.keep_on_disk = keep_on_disk;
 	sda.ignore_missing_paths = ignore_missing_paths;
+	sda.delete_unversioned = delete_unversioned;
 	sda.status_codes = status_codes;
 
 	RB_FOREACH(pe, got_pathlist_head, paths) {
blob - 8ee0c9db46392914b731af78dcd15c1cc3684308
blob + a53c0b627756932fc184cc81e76141e96b3ee4f6
--- regress/cmdline/rm.sh
+++ regress/cmdline/rm.sh
@@ -614,6 +614,121 @@ test_rm_status_code() {
 		return 1
 	fi
 
+	(cd $testroot/wt && got revert -R . > /dev/null)
+
+	# Test deletion of unversioned files via "-s ?".
+	touch $testroot/wt/unversioned
+	touch $testroot/wt/epsilon/unversioned
+
+	echo '?  epsilon/unversioned' > $testroot/stdout.expected
+	echo '?  unversioned' >> $testroot/stdout.expected
+	(cd $testroot/wt && got status > $testroot/stdout)
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "1"
+		return 1
+	fi
+
+	# Unversioned files should never be removed without -s ?.
+	for flag in "" "-k" "-f" "-R"; do
+		(cd $testroot/wt && got rm $flag unversioned \
+			>$testroot/stdout 2>$testroot/stderr)
+
+		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" "1"
+			return 1
+		fi
+
+		if [ -n "$flag" -a "$flag" = "-k" ]; then
+			echo -n > $testroot/stderr.expected
+		else
+			echo "got: unversioned: file has unexpected status" \
+				> $testroot/stderr.expected
+		fi
+
+		cmp -s $testroot/stderr.expected $testroot/stderr
+		ret=$?
+		if [ $ret -ne 0 ]; then
+			diff -u $testroot/stderr.expected $testroot/stderr
+			test_done "$testroot" "1"
+			return 1
+		fi
+
+		echo '?  epsilon/unversioned' > $testroot/stdout.expected
+		echo '?  unversioned' >> $testroot/stdout.expected
+		(cd $testroot/wt && got status > $testroot/stdout)
+		cmp -s $testroot/stdout.expected $testroot/stdout
+		ret=$?
+		if [ $ret -ne 0 ]; then
+			diff -u $testroot/stdout.expected $testroot/stdout
+			test_done "$testroot" "1"
+			return 1
+		fi
+	done
+
+	# -k takes priority over -s ?, such that files are kept on disk.
+	(cd $testroot/wt && got rm -k -s ? unversioned \
+		>$testroot/stdout 2>$testroot/stderr)
+
+	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" "1"
+		return 1
+	fi
+
+	echo -n > $testroot/stderr.expected
+	cmp -s $testroot/stderr.expected $testroot/stderr
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stderr.expected $testroot/stderr
+		test_done "$testroot" "1"
+		return 1
+	fi
+
+	# Remove all unversioned files from the work tree with -R -s ?
+	(cd $testroot/wt && got rm -R -s ? . \
+		>$testroot/stdout 2>$testroot/stderr)
+
+	echo "D  epsilon/unversioned" > $testroot/stdout.expected
+	echo "D  unversioned" >> $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" "1"
+		return 1
+	fi
+
+	echo -n > $testroot/stderr.expected
+	cmp -s $testroot/stderr.expected $testroot/stderr
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stderr.expected $testroot/stderr
+		test_done "$testroot" "1"
+		return 1
+	fi
+
+	if [ -e "$testroot/wt/unversioned" ]; then
+		echo "file $testroot/wt/unversioned still exists on disk" &>2
+		test_done "$testroot" "1"
+		return 1
+	fi
+
+	if [ -e "$testroot/wt/epsilon/unversioned" ]; then
+		echo "file $testroot/wt/epsilon/unversioned still exists on disk" &>2
+		test_done "$testroot" "1"
+		return 1
+	fi
+
 	test_done "$testroot" "$ret"
 }