builtin/refs: add "delete" subcommand

Reference-related functionality in Git is currently spread across many different commands: git-update-ref(1), git-for-each-ref(1), git-show-ref(1), git-pack-refs(1) and git-symbolic-ref(1). This makes it hard for users to discover what functionality we have available to work with references. We have thus started to consolidate this functionality into git-refs(1), which is a toolbox of everything related to references. Until now, the command doesn't handle functionality of git-update-ref(1). Fix this gap by introducing a new "delete" subcommand, which is the equivalent of `git update-ref -d`. Note that we're intentionally not using a generic "write" subcommand with a "-d" flag. This is rather harder to discover, and subcommands that are implmented as flags tend to be hard to reason about in the code as we'd have to handle mutually-exclusive flags that stem from the other subcommand-like modes. Signed-off-by: Patrick Steinhardt <ps@pks.im> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Patrick Steinhardt committed Jul 6, 2026 at 15:27 UTC b3355995cf5adfa4b0b0c6dfddf4449f457e3912
4 files changed +221
Documentation/git-refs.adoc
+17
@@ -20,6 +20,7 @@ git refs list [--count=<count>] [--shell|--perl|--python|--tcl]
20 [ --stdin | (<pattern>...)]
21 git refs exists <ref>
22 git refs optimize [--all] [--no-prune] [--auto] [--include <pattern>] [--exclude <pattern>]
23 +git refs delete [--message=<reason>] [--no-deref] <ref> [<old-value>]
24
25 DESCRIPTION
26 -----------
@@ -51,6 +52,12 @@ optimize::
52 usage. This subcommand is an alias for linkgit:git-pack-refs[1] and
53 offers identical functionality.
54
55 +delete::
56 + Delete the given reference. This subcommand mirrors `git update-ref -d`
57 + (see linkgit:git-update-ref[1]). When `<old-value>` is given, the
58 + reference is only deleted after verifying that it currently contains
59 + `<old-value>`.
60 +
61 OPTIONS
62 -------
63
@@ -90,6 +97,16 @@ The following options are specific to 'git refs optimize':
97
98 include::pack-refs-options.adoc[]
99
100 +The following options are specific to commands which write references:
101 +
102 +`--message=<reason>`::
103 + Use the given <reason> string for the reflog entry associated with the
104 + update. An empty message is rejected.
105 +
106 +`--no-deref`::
107 + Operate on <ref> itself rather than the reference it points to via a
108 + symbolic ref.
109 +
110 KNOWN LIMITATIONS
111 -----------------
112
builtin/refs.c
+51
@@ -21,6 +21,9 @@
21 #define REFS_OPTIMIZE_USAGE \
22 N_("git refs optimize " PACK_REFS_OPTS)
23
24 +#define REFS_DELETE_USAGE \
25 + N_("git refs delete [--message=<reason>] [--no-deref] <ref> [<old-value>]")
26 +
27 static int cmd_refs_migrate(int argc, const char **argv, const char *prefix,
28 struct repository *repo)
29 {
@@ -175,6 +178,52 @@ static int cmd_refs_optimize(int argc, const char **argv, const char *prefix,
178 return pack_refs_core(argc, argv, prefix, repo, refs_optimize_usage);
179 }
180
181 +static int cmd_refs_delete(int argc, const char **argv, const char *prefix,
182 + struct repository *repo)
183 +{
184 + static char const * const refs_delete_usage[] = {
185 + REFS_DELETE_USAGE,
186 + NULL
187 + };
188 + const char *message = NULL;
189 + unsigned flags = 0;
190 + struct option opts[] = {
191 + OPT_STRING(0, "message", &message, N_("reason"),
192 + N_("reason of the update")),
193 + OPT_BIT(0 ,"no-deref", &flags,
194 + N_("update <refname> not the one it points to"),
195 + REF_NO_DEREF),
196 + OPT_END(),
197 + };
198 + struct object_id oldoid;
199 + const char *refname;
200 + int ret;
201 +
202 + argc = parse_options(argc, argv, prefix, opts, refs_delete_usage, 0);
203 + if (argc < 1 || argc > 2)
204 + usage(_("delete requires reference name and an optional old object ID"));
205 +
206 + if (message && !*message)
207 + die(_("refusing to perform update with empty message"));
208 +
209 + repo_config(repo, git_default_config, NULL);
210 +
211 + refname = argv[0];
212 + if (argc == 2) {
213 + if (repo_get_oid_with_flags(repo, argv[1], &oldoid, GET_OID_SKIP_AMBIGUITY_CHECK))
214 + die(_("invalid old object ID: '%s'"), argv[1]);
215 + if (is_null_oid(&oldoid))
216 + die(_("cannot delete reference with null old object ID"));
217 + }
218 +
219 + ret = refs_delete_ref(get_main_ref_store(repo), message, refname,
220 + argc == 2 ? &oldoid : NULL, flags);
221 +
222 + if (ret < 0)
223 + ret = 1;
224 + return ret;
225 +}
226 +
227 int cmd_refs(int argc,
228 const char **argv,
229 const char *prefix,
@@ -186,6 +235,7 @@ int cmd_refs(int argc,
235 "git refs list " COMMON_USAGE_FOR_EACH_REF,
236 REFS_EXISTS_USAGE,
237 REFS_OPTIMIZE_USAGE,
238 + REFS_DELETE_USAGE,
239 NULL,
240 };
241 parse_opt_subcommand_fn *fn = NULL;
@@ -195,6 +245,7 @@ int cmd_refs(int argc,
245 OPT_SUBCOMMAND("list", &fn, cmd_refs_list),
246 OPT_SUBCOMMAND("exists", &fn, cmd_refs_exists),
247 OPT_SUBCOMMAND("optimize", &fn, cmd_refs_optimize),
248 + OPT_SUBCOMMAND("delete", &fn, cmd_refs_delete),
249 OPT_END(),
250 };
251
t/meson.build
+1
@@ -223,6 +223,7 @@ integration_tests = [
223 't1461-refs-list.sh',
224 't1462-refs-exists.sh',
225 't1463-refs-optimize.sh',
226 + 't1464-refs-delete.sh',
227 't1500-rev-parse.sh',
228 't1501-work-tree.sh',
229 't1502-rev-parse-parseopt.sh',
t/t1464-refs-delete.sh new
+152
@@ -0,0 +1,152 @@
1 +#!/bin/sh
2 +
3 +test_description='git refs delete'
4 +
5 +. ./test-lib.sh
6 +
7 +setup_repo () {
8 + git init "$1" &&
9 + test_commit -C "$1" A &&
10 + test_commit -C "$1" B
11 +}
12 +
13 +test_expect_success 'delete without oldvalue verification' '
14 + test_when_finished "rm -rf repo" &&
15 + setup_repo repo &&
16 + (
17 + cd repo &&
18 + A=$(git rev-parse A) &&
19 + git update-ref refs/heads/foo $A &&
20 + git refs delete refs/heads/foo &&
21 + test_must_fail git refs exists refs/heads/foo
22 + )
23 +'
24 +
25 +test_expect_success 'delete with matching oldvalue' '
26 + test_when_finished "rm -rf repo" &&
27 + setup_repo repo &&
28 + (
29 + cd repo &&
30 + A=$(git rev-parse A) &&
31 + git update-ref refs/heads/foo $A &&
32 + git refs delete refs/heads/foo $A &&
33 + test_must_fail git refs exists refs/heads/foo
34 + )
35 +'
36 +
37 +test_expect_success 'delete with stale oldvalue fails' '
38 + test_when_finished "rm -rf repo" &&
39 + setup_repo repo &&
40 + (
41 + cd repo &&
42 + A=$(git rev-parse A) &&
43 + B=$(git rev-parse B) &&
44 + git update-ref refs/heads/foo $A &&
45 + test_must_fail git refs delete refs/heads/foo $B 2>err &&
46 + test_grep " but expected " err &&
47 + git refs exists refs/heads/foo
48 + )
49 +'
50 +
51 +test_expect_success 'delete with null oldvalue fails' '
52 + test_when_finished "rm -rf repo" &&
53 + setup_repo repo &&
54 + (
55 + cd repo &&
56 + A=$(git rev-parse A) &&
57 + git update-ref refs/heads/foo $A &&
58 + test_must_fail git refs delete refs/heads/foo $ZERO_OID 2>err &&
59 + test_grep "null old object ID" err &&
60 + git refs exists refs/heads/foo
61 + )
62 +'
63 +
64 +test_expect_success 'delete with invalid oldvalue fails' '
65 + test_when_finished "rm -rf repo" &&
66 + setup_repo repo &&
67 + (
68 + cd repo &&
69 + A=$(git rev-parse A) &&
70 + git update-ref refs/heads/foo $A &&
71 + test_must_fail git refs delete refs/heads/foo invalid-oid 2>err &&
72 + test_grep "invalid old object ID" err &&
73 + git refs exists refs/heads/foo
74 + )
75 +'
76 +
77 +test_expect_success 'delete symref with --no-deref leaves target intact' '
78 + test_when_finished "rm -rf repo" &&
79 + setup_repo repo &&
80 + (
81 + cd repo &&
82 + A=$(git rev-parse A) &&
83 + git update-ref refs/heads/foo $A &&
84 + git symbolic-ref refs/heads/symref refs/heads/foo &&
85 + git refs delete --no-deref refs/heads/symref &&
86 + test_must_fail git refs exists refs/heads/symref &&
87 + git refs exists refs/heads/foo
88 + )
89 +'
90 +
91 +test_expect_success 'delete symref with --no-deref verifies target OID' '
92 + test_when_finished "rm -rf repo" &&
93 + setup_repo repo &&
94 + (
95 + cd repo &&
96 + A=$(git rev-parse A) &&
97 + B=$(git rev-parse B) &&
98 + git update-ref refs/heads/foo $A &&
99 + git symbolic-ref refs/heads/symref refs/heads/foo &&
100 +
101 + test_must_fail git refs delete --no-deref refs/heads/symref $B &&
102 + git refs exists refs/heads/symref &&
103 +
104 + git refs delete --no-deref refs/heads/symref $A &&
105 + test_must_fail git refs exists refs/heads/symref &&
106 + git refs exists refs/heads/foo
107 + )
108 +'
109 +
110 +test_expect_success 'delete with message records reason in reflog' '
111 + test_when_finished "rm -rf repo" &&
112 + setup_repo repo &&
113 + (
114 + cd repo &&
115 + A=$(git rev-parse A) &&
116 + git update-ref refs/heads/foo $A &&
117 + git symbolic-ref HEAD refs/heads/foo &&
118 + git refs delete --message=delete-reason refs/heads/foo &&
119 + test_must_fail git refs exists refs/heads/foo &&
120 + test-tool ref-store main for-each-reflog-ent HEAD >actual &&
121 + test_grep "delete-reason$" actual
122 + )
123 +'
124 +
125 +test_expect_success 'delete with empty message fails' '
126 + test_when_finished "rm -rf repo" &&
127 + setup_repo repo &&
128 + (
129 + cd repo &&
130 + A=$(git rev-parse A) &&
131 + git update-ref refs/heads/foo $A &&
132 + test_must_fail git refs delete --message= refs/heads/foo 2>err &&
133 + test_grep "empty message" err &&
134 + git refs exists refs/heads/foo
135 + )
136 +'
137 +
138 +test_expect_success 'delete without arguments fails' '
139 + test_when_finished "rm -rf repo" &&
140 + setup_repo repo &&
141 + test_must_fail git -C repo refs delete 2>err &&
142 + test_grep "requires reference name" err
143 +'
144 +
145 +test_expect_success 'delete with too many arguments fails' '
146 + test_when_finished "rm -rf repo" &&
147 + setup_repo repo &&
148 + test_must_fail git refs delete one two three 2>err &&
149 + test_grep "requires reference name" err
150 +'
151 +
152 +test_done