git/list[1] front-page[2] threads[3] people[4] search[5] about
 

[PATCH] logging branch deletion to help recovering from mistakes

From
Junio C Hamano <gitster@pobox.com>
Date
Dec 6, 2010, 21:16 UTC
Message-ID
<7vlj42siu5.fsf@alter.siamese.dyndns.org>

This adds core.logrefdeletion configuration variable (enabled by default in a repository with a work tree, just like core.logallrefupdates), and logs deletion of refs via "update-ref -d", "branch -d", etc.

"git branch" learns a new "--list-deleted" option to help users view the names of branches and the commit objects that were at the tip of them when the branches were deleted.

Signed-off-by: Junio C Hamano <gitster@pobox.com>
---
 * This recently came up at $dayjob.  The new option is not '--undelete'
   and this is deliberate, as we do not have any information other than
   the tip of the branch to recreate tracking and other frills.
 Documentation/config.txt             |    6 +
 Documentation/git-branch.txt         |    6 +
 builtin/branch.c                     |   21 ++++-
 cache.h                              |    1 +
 config.c                             |    5 +
 environment.c                        |    1 +
 refs.c                               |  167 +++++++++++++++++++++++++---------
 refs.h                               |    2 +
 t/t1400-update-ref.sh                |   53 ++++++++++-
 t/t7701-repack-unpack-unreachable.sh |    4 +
 10 files changed, 219 insertions(+), 47 deletions(-)
diff --git a/Documentation/config.txt b/Documentation/config.txt
index d82c0da..bdf90eb 100644
--- a/Documentation/config.txt
+++ b/Documentation/config.txt
@@ -351,6 +351,12 @@ This value is true by default in a repository that has
 a working directory associated with it, and false by
 default in a bare repository.
 
+core.logRefDeletion::
+	Enable logging of eletion of refs (e.g. branches), allowing `git
+	branch --list-deleted` to help you recover branches lost by
+	running `git branch -d` by mistake.  This is enabled in a
+	repository that has a working tree associated with it by default.
+
 core.repositoryFormatVersion::
 	Internal variable identifying the repository format and layout
 	version.
diff --git a/Documentation/git-branch.txt b/Documentation/git-branch.txt
index 1940256..07ec47b 100644
--- a/Documentation/git-branch.txt
+++ b/Documentation/git-branch.txt
@@ -14,6 +14,7 @@ SYNOPSIS
 'git branch' [--set-upstream | --track | --no-track] [-l] [-f] <branchname> [<start-point>]
 'git branch' (-m | -M) [<oldbranch>] <newbranch>
 'git branch' (-d | -D) [-r] <branchname>...
+'git branch' --list-deleted [<pattern>...]
 
 DESCRIPTION
 -----------
@@ -70,6 +71,11 @@ OPTIONS
 -D::
 	Delete a branch irrespective of its merged status.
 
+--list-deleted::
+	List names of recently deleted branches together with the object
+	names of the commits that were at the tip of them.  Glob patterns
+	can be used to limit the branches that are shown.
+
 -l::
 	Create the branch's reflog.  This activates recording of
 	all changes made to the branch ref, enabling use of date
diff --git a/builtin/branch.c b/builtin/branch.c
index 87976f0..68604c2 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -15,6 +15,7 @@
 #include "branch.h"
 #include "diff.h"
 #include "revision.h"
+#include "string-list.h"
 
 static const char * const builtin_branch_usage[] = {
 	"git branch [options] [-r | -a] [--merged | --no-merged]",
@@ -144,6 +145,19 @@ static int branch_merged(int kind, const char *name,
 	return merged;
 }
 
+static int show_deleted(struct string_list_item *item, void *cb_data)
+{
+	printf("%s %s\n", (char *)item->util, item->string);
+	return 0;
+}
+
+static int list_deleted_branches(const char **argv)
+{
+	struct string_list *deleted = list_deleted_refs("refs/heads/", argv);
+	for_each_string_list(deleted, show_deleted, NULL);
+	return 0;
+}
+
 static int delete_branches(int argc, const char **argv, int force, int kinds)
 {
 	struct commit *rev, *head_rev = NULL;
@@ -612,7 +626,7 @@ static int opt_parse_merge_filter(const struct option *opt, const char *arg, int
 
 int cmd_branch(int argc, const char **argv, const char *prefix)
 {
-	int delete = 0, rename = 0, force_create = 0;
+	int delete = 0, rename = 0, force_create = 0, list_deleted = 0;
 	int verbose = 0, abbrev = DEFAULT_ABBREV, detached = 0;
 	int reflog = 0;
 	enum branch_track track;
@@ -652,6 +666,7 @@ int cmd_branch(int argc, const char **argv, const char *prefix)
 		OPT_BIT('M', NULL, &rename, "move/rename a branch, even if target exists", 2),
 		OPT_BOOLEAN('l', NULL, &reflog, "create the branch's reflog"),
 		OPT_BOOLEAN('f', "force", &force_create, "force creation (when already exists)"),
+		OPT_BOOLEAN(0, "list-deleted", &list_deleted, "list deleted branches"),
 		{
 			OPTION_CALLBACK, 0, "no-merged", &merge_filter_ref,
 			"commit", "print only not merged branches",
@@ -689,11 +704,13 @@ int cmd_branch(int argc, const char **argv, const char *prefix)
 
 	argc = parse_options(argc, argv, prefix, options, builtin_branch_usage,
 			     0);
-	if (!!delete + !!rename + !!force_create > 1)
+	if (!!list_deleted + !!delete + !!rename + !!force_create > 1)
 		usage_with_options(builtin_branch_usage, options);
 
 	if (delete)
 		return delete_branches(argc, argv, delete > 1, kinds);
+	else if (list_deleted)
+		return list_deleted_branches(argv);
 	else if (argc == 0)
 		return print_ref_list(kinds, detached, verbose, abbrev, with_commit);
 	else if (rename && (argc == 1))
diff --git a/cache.h b/cache.h
index 2ef2fa3..0a82612 100644
--- a/cache.h
+++ b/cache.h
@@ -537,6 +537,7 @@ extern int ignore_case;
 extern int assume_unchanged;
 extern int prefer_symlink_refs;
 extern int log_all_ref_updates;
+extern int log_ref_deletion;
 extern int warn_ambiguous_refs;
 extern int shared_repository;
 extern const char *apply_default_whitespace;
diff --git a/config.c b/config.c
index 4b0a820..cfa162a 100644
--- a/config.c
+++ b/config.c
@@ -509,6 +509,11 @@ static int git_default_core_config(const char *var, const char *value)
 		return 0;
 	}
 
+	if (!strcmp(var, "core.logrefdeletion")) {
+		log_ref_deletion = git_config_bool(var, value);
+		return 0;
+	}
+
 	if (!strcmp(var, "core.warnambiguousrefs")) {
 		warn_ambiguous_refs = git_config_bool(var, value);
 		return 0;
diff --git a/environment.c b/environment.c
index 2d0c315..12166d9 100644
--- a/environment.c
+++ b/environment.c
@@ -20,6 +20,7 @@ int assume_unchanged;
 int prefer_symlink_refs;
 int is_bare_repository_cfg = -1; /* unspecified */
 int log_all_ref_updates = -1; /* unspecified */
+int log_ref_deletion = -1; /* unspecified */
 int warn_ambiguous_refs = 1;
 int repository_format_version;
 const char *git_commit_encoding;
diff --git a/refs.c b/refs.c
index e3c0511..afdd634 100644
--- a/refs.c
+++ b/refs.c
@@ -3,11 +3,14 @@
 #include "object.h"
 #include "tag.h"
 #include "dir.h"
+#include "string-list.h"
 
 /* ISSYMREF=01 and ISPACKED=02 are public interfaces */
 #define REF_KNOWS_PEELED 04
 #define REF_BROKEN 010
 
+#define BRANCH_DELETION_LOG "DELETED-REFS"
+
 struct ref_list {
 	struct ref_list *next;
 	unsigned char flag; /* ISSYMREF? ISPACKED? */
@@ -1137,44 +1140,6 @@ static int repack_without_ref(const char *refname)
 	return commit_lock_file(&packlock);
 }
 
-int delete_ref(const char *refname, const unsigned char *sha1, int delopt)
-{
-	struct ref_lock *lock;
-	int err, i = 0, ret = 0, flag = 0;
-
-	lock = lock_ref_sha1_basic(refname, sha1, 0, &flag);
-	if (!lock)
-		return 1;
-	if (!(flag & REF_ISPACKED) || flag & REF_ISSYMREF) {
-		/* loose */
-		const char *path;
-
-		if (!(delopt & REF_NODEREF)) {
-			i = strlen(lock->lk->filename) - 5; /* .lock */
-			lock->lk->filename[i] = 0;
-			path = lock->lk->filename;
-		} else {
-			path = git_path("%s", refname);
-		}
-		err = unlink_or_warn(path);
-		if (err && errno != ENOENT)
-			ret = 1;
-
-		if (!(delopt & REF_NODEREF))
-			lock->lk->filename[i] = '.';
-	}
-	/* removing the loose one could have resurrected an earlier
-	 * packed one.  Also, if it was not loose we need to repack
-	 * without it.
-	 */
-	ret |= repack_without_ref(refname);
-
-	unlink_or_warn(git_path("logs/%s", lock->ref_name));
-	invalidate_cached_refs();
-	unlock_ref(lock);
-	return ret;
-}
-
 /*
  * People using contrib's git-new-workdir have .git/logs/refs ->
  * /some/other/path/.git/logs/refs, and that may live on another device.
@@ -1361,11 +1326,13 @@ int log_ref_setup(const char *ref_name, char *logfile, int bufsize)
 	int logfd, oflags = O_APPEND | O_WRONLY;
 
 	git_snpath(logfile, bufsize, "logs/%s", ref_name);
-	if (log_all_ref_updates &&
-	    (!prefixcmp(ref_name, "refs/heads/") ||
-	     !prefixcmp(ref_name, "refs/remotes/") ||
-	     !prefixcmp(ref_name, "refs/notes/") ||
-	     !strcmp(ref_name, "HEAD"))) {
+	if ((log_all_ref_updates &&
+	     (!prefixcmp(ref_name, "refs/heads/") ||
+	      !prefixcmp(ref_name, "refs/remotes/") ||
+	      !prefixcmp(ref_name, "refs/notes/") ||
+	      !strcmp(ref_name, "HEAD"))) ||
+	    (log_ref_deletion &&
+	     !strcmp(ref_name, BRANCH_DELETION_LOG))) {
 		if (safe_create_leading_directories(logfile) < 0)
 			return error("unable to create directory for %s",
 				     logfile);
@@ -1407,6 +1374,8 @@ static int log_ref_write(const char *ref_name, const unsigned char *old_sha1,
 
 	if (log_all_ref_updates < 0)
 		log_all_ref_updates = !is_bare_repository();
+	if (log_ref_deletion < 0)
+		log_ref_deletion = !is_bare_repository();
 
 	result = log_ref_setup(ref_name, log_file, sizeof(log_file));
 	if (result)
@@ -1432,6 +1401,118 @@ static int log_ref_write(const char *ref_name, const unsigned char *old_sha1,
 	return 0;
 }
 
+struct filter_deleted {
+	const char *pfx;
+	const char **pattern;
+	size_t pfxlen;
+	struct string_list *list;
+};
+
+static int collect_deleted(unsigned char *osha1, unsigned char *nsha1,
+			   const char *ident,
+			   unsigned long timestamp, int tz,
+			   const char *msg, void *cb_data)
+{
+	struct filter_deleted *filter = cb_data;
+	struct string_list_item *item;
+	const char *nameloc;
+	char *namebody;
+	char namebody_buf[1024];
+	size_t namebodylen;
+
+	if (prefixcmp(msg, "delete ") ||
+	    memcmp(msg + 7, filter->pfx, filter->pfxlen) ||
+	    !is_null_sha1(nsha1))
+		return 0;
+	nameloc = msg + 7 + filter->pfxlen;
+	namebodylen = strlen(nameloc); /* counts final LF */
+	if (!namebodylen || nameloc[namebodylen - 1] != '\n')
+		return 0;
+	if (sizeof(namebody_buf) <= namebodylen)
+		namebody = xmalloc(namebodylen);
+	else
+		namebody = namebody_buf;
+	memcpy(namebody, nameloc, namebodylen);
+	namebody[namebodylen - 1] = '\0';
+	if (filter->pattern[0]) {
+		int i, matches;
+		for (i = matches = 0; !matches && filter->pattern[i]; i++)
+			if (fnmatch(filter->pattern[i], namebody, 0))
+				matches = 1;
+		if (!matches)
+			goto free_return;
+	}
+	item = string_list_insert(filter->list, namebody);
+	if (!item->util)
+		item->util = xmalloc(41);
+	strcpy(item->util, sha1_to_hex(osha1));
+free_return:
+	if (namebody != namebody_buf)
+		free(namebody);
+	return 0;
+}
+
+struct string_list *list_deleted_refs(const char *pfx, const char **pattern)
+{
+	struct filter_deleted filter = { pfx, pattern };
+	filter.list = xcalloc(1, sizeof(*(filter.list)));
+	filter.list->strdup_strings = 1;
+	filter.pfxlen = strlen(pfx);
+
+	for_each_reflog_ent(BRANCH_DELETION_LOG, collect_deleted, &filter);
+	return filter.list;
+}
+
+int delete_ref(const char *refname, const unsigned char *sha1, int delopt)
+{
+	struct ref_lock *lock;
+	int err, i = 0, ret = 0, flag = 0;
+	struct strbuf logmsg = STRBUF_INIT;
+
+	lock = lock_ref_sha1_basic(refname, sha1, 0, &flag);
+	if (!lock)
+		return 1;
+	if (!(flag & REF_ISPACKED) || flag & REF_ISSYMREF) {
+		/* loose */
+		const char *path;
+
+		if (!(delopt & REF_NODEREF)) {
+			i = strlen(lock->lk->filename) - 5; /* .lock */
+			lock->lk->filename[i] = 0;
+			path = lock->lk->filename;
+		} else {
+			path = git_path("%s", refname);
+		}
+		err = unlink_or_warn(path);
+		if (err && errno != ENOENT)
+			ret = 1;
+
+		if (!(delopt & REF_NODEREF))
+			lock->lk->filename[i] = '.';
+	}
+	/*
+	 * removing the loose one could have resurrected an earlier
+	 * packed one.  Also, if it was not loose we need to repack
+	 * without it.
+	 */
+	ret |= repack_without_ref(refname);
+
+	unlink_or_warn(git_path("logs/%s", lock->ref_name));
+	invalidate_cached_refs();
+
+	strbuf_addf(&logmsg, "delete %s", refname);
+	if (log_ref_write(BRANCH_DELETION_LOG,
+			  lock->old_sha1, null_sha1, logmsg.buf))
+		/*
+		 * there isn't much we can do at this point upon
+		 * failing to record the branch deletion, and an error
+		 * messages have been already issued.
+		 */
+		;
+	unlock_ref(lock);
+	return ret;
+}
+
 static int is_branch(const char *refname)
 {
 	return !strcmp(refname, "HEAD") || !prefixcmp(refname, "refs/heads/");
diff --git a/refs.h b/refs.h
index 5e7a9a5..9fcf7ac 100644
--- a/refs.h
+++ b/refs.h
@@ -87,6 +87,8 @@ typedef int each_reflog_ent_fn(unsigned char *osha1, unsigned char *nsha1, const
 int for_each_reflog_ent(const char *ref, each_reflog_ent_fn fn, void *cb_data);
 int for_each_recent_reflog_ent(const char *ref, each_reflog_ent_fn fn, long, void *cb_data);
 
+struct string_list *list_deleted_refs(const char *prefix, const char **pattern);
+
 /*
  * Calls the specified function for each reflog file until it returns nonzero,
  * and returns the value
diff --git a/t/t1400-update-ref.sh b/t/t1400-update-ref.sh
index 54ba3df..ad9f461 100755
--- a/t/t1400-update-ref.sh
+++ b/t/t1400-update-ref.sh
@@ -9,6 +9,7 @@ test_description='Test git update-ref and basic ref logging'
 Z=0000000000000000000000000000000000000000
 
 test_expect_success setup '
+	git config core.logrefdeletion false &&
 
 	for name in A B C D E F
 	do
@@ -38,7 +39,8 @@ test_expect_success "fail to delete $m with stale ref" '
 '
 test_expect_success "delete $m" '
 	git update-ref -d $m $B &&
-	! test -f .git/$m
+	! test -f .git/$m &&
+	! test -f .git/logs/DELETED-REFS
 '
 rm -f .git/$m
 
@@ -46,7 +48,8 @@ test_expect_success "delete $m without oldvalue verification" "
 	git update-ref $m $A &&
 	test $A = \$(cat .git/$m) &&
 	git update-ref -d $m &&
-	! test -f .git/$m
+	! test -f .git/$m &&
+	! test -f .git/logs/DELETED-REFS
 "
 rm -f .git/$m
 
@@ -285,4 +288,50 @@ test_expect_success \
 	'git cat-file blob master@{2005-05-26 23:42}:F (expect OTHER)' \
 	'test OTHER = $(git cat-file blob "master@{2005-05-26 23:42}:F")'
 
+test_expect_success 'reflog for deletion' '
+	git config core.logrefdeletion yes &&
+
+	git branch frotz HEAD &&
+	git branch nitfol $A &&
+	git branch xyzzy $B &&
+
+	git branch -d frotz &&
+	git branch -D nitfol &&
+	git update-ref -d refs/heads/xyzzy &&
+	{
+		echo "$(git rev-parse HEAD) frotz"
+		echo "$A nitfol"
+		echo "$B xyzzy"
+	} >expect &&
+	git branch --list-deleted >actual &&
+	test_cmp expect actual
+'
+
+test_expect_success 'reflog for deletion disabled by default in a bare repo' '
+	rm -fr test &&
+	mkdir test &&
+	(
+		cd test && git --bare init &&
+		git fetch .. HEAD:refs/heads/master HEAD:refs/heads/slave &&
+		git update-ref -d refs/heads/slave &&
+		git branch --list-deleted >actual &&
+		>expect &&
+		test_cmp expect actual
+	)
+'
+
+test_expect_success 'reflog for deletion can be enabled in a bare repo' '
+	rm -fr test &&
+	mkdir test &&
+	(
+		cd test && git --bare init &&
+		git config core.logrefdeletion yes &&
+		git fetch .. HEAD:refs/heads/master HEAD:refs/heads/slave &&
+		git update-ref -d refs/heads/slave &&
+		git branch --list-deleted >actual &&
+		echo "$(git rev-parse master) slave" >expect &&
+		test_cmp expect actual
+	)
+'
+
 test_done
diff --git a/t/t7701-repack-unpack-unreachable.sh b/t/t7701-repack-unpack-unreachable.sh
index 200ab61..f6209d2 100755
--- a/t/t7701-repack-unpack-unreachable.sh
+++ b/t/t7701-repack-unpack-unreachable.sh
@@ -9,6 +9,10 @@ csha1=
 tsha1=
 
 test_expect_success '-A with -d option leaves unreachable objects unpacked' '
+	# The test expects branch removal to lose the last reference to
+	# lost objects.  Disable branch deletion log to achieve this.
+	git config core.logrefdeletion false &&
+
 	echo content > file1 &&
 	git add . &&
 	test_tick &&
Next: Štěpán Němec
Message 1 of 28 in “logging branch deletion to help recovering from mistakes”
  1. logging branch deletion to help recovering from mistakesJunio C Hamano, Dec 6, 2010
  2. Štěpán NěmecDec 6, 2010
  3. Andreas SchwabDec 6, 2010
  4. Nguyen Thai Ngoc DuyDec 7, 2010
  5. Junio C HamanoDec 7, 2010
  6. Nguyen Thai Ngoc DuyDec 7, 2010
  7. Michael J GruberDec 7, 2010
  8. Nguyen Thai Ngoc DuyDec 7, 2010
  9. Jakub NarebskiDec 7, 2010
  10. Nguyen Thai Ngoc DuyDec 7, 2010
  11. Jeff KingDec 7, 2010
  12. Shawn PearceDec 7, 2010
  13. Jeff KingDec 7, 2010
  14. Shawn O. PearceDec 7, 2010
  15. Jeff KingDec 7, 2010
  16. Shawn O. PearceDec 7, 2010
  17. Jeff KingDec 7, 2010
  18. Shawn O. PearceDec 7, 2010
  19. Junio C HamanoDec 7, 2010
  20. Jeff KingDec 7, 2010
  21. Casey DahlinDec 7, 2010
  22. Jonathan NiederDec 7, 2010
  23. Casey DahlinDec 7, 2010
  24. Jonathan NiederDec 7, 2010
  25. Casey DahlinDec 7, 2010
  26. Junio C HamanoDec 7, 2010
  27. Jonathan NiederDec 7, 2010
  28. Jeff KingDec 7, 2010

Read the whole thread, see it on lore, or plain text.

$ cat FOOTERMessages come from the public archive at lore.kernel.org/git, fetched every hour. The front page is chosen and written each morning by an AI editor and can be wrong; the threads themselves are the record. About and API. For agents: an MCP server at https://gitlist.dev/mcp, and any thread, story or person page as Markdown by adding .md to its URL (or sending Accept: text/markdown). Details in /llms.txt.