From: Stefan Sperling Subject: removing unversioned files with 'got rm' To: gameoftrees@openbsd.org Date: Tue, 7 Jul 2026 17:03:54 +0200 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" }