Git development
 help / color / mirror / Atom feed
* [PATCH 0/4] builtin/refs: add ability to write references
@ 2026-06-16  8:44 Patrick Steinhardt
  2026-06-16  8:44 ` [PATCH 1/4] builtin/refs: drop `the_repository` Patrick Steinhardt
                   ` (4 more replies)
  0 siblings, 5 replies; 20+ messages in thread
From: Patrick Steinhardt @ 2026-06-16  8:44 UTC (permalink / raw)
  To: git; +Cc: Junio C Hamano

Hi,

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).

This patch series backfills most of the functionality by introducing
three new commands:

  - `git refs delete` to delete references. This is the equivalent of
    `git update-ref -d`.

  - `git refs update` to update references. This is the equivalent of
    `git update-ref <refname> <oldvalue> <newvalue>`.

  - `git refs rename` to rename a reference, including its reflog. This
    does not have an equivalent in git-update-ref(1), but is inspired by
    and supersedes [1].

Thanks!

Patrick

[1]: <xmqqv7brz9ba.fsf@gitster.g>

---
Patrick Steinhardt (4):
      builtin/refs: drop `the_repository`
      builtin/refs: add "delete" subcommand
      builtin/refs: add "update" subcommand
      builtin/refs: add "rename" subcommand

 Documentation/git-refs.adoc |  34 +++++++++
 builtin/refs.c              | 153 +++++++++++++++++++++++++++++++++++--
 t/meson.build               |   3 +
 t/t1464-refs-delete.sh      | 133 ++++++++++++++++++++++++++++++++
 t/t1465-refs-update.sh      | 179 ++++++++++++++++++++++++++++++++++++++++++++
 t/t1466-refs-rename.sh      | 131 ++++++++++++++++++++++++++++++++
 6 files changed, 625 insertions(+), 8 deletions(-)


---
base-commit: 700432b2ba22603a0bcb71475c9c333d17c9b0d1
change-id: 20260616-pks-refs-writing-subcommands-7a77be5bda9b


^ permalink raw reply	[flat|nested] 20+ messages in thread

* [PATCH 1/4] builtin/refs: drop `the_repository`
  2026-06-16  8:44 [PATCH 0/4] builtin/refs: add ability to write references Patrick Steinhardt
@ 2026-06-16  8:44 ` Patrick Steinhardt
  2026-06-16  8:44 ` [PATCH 2/4] builtin/refs: add "delete" subcommand Patrick Steinhardt
                   ` (3 subsequent siblings)
  4 siblings, 0 replies; 20+ messages in thread
From: Patrick Steinhardt @ 2026-06-16  8:44 UTC (permalink / raw)
  To: git; +Cc: Junio C Hamano

We still have a couple of uses of `the_repository` in "builtin/refs.c".
All of those are trivial to convert though as the command always
requires a repository to exist.

Convert them to use the passed-in repository and drop
`USE_THE_REPOSITORY_VARIABLE`.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 builtin/refs.c | 15 +++++++--------
 1 file changed, 7 insertions(+), 8 deletions(-)

diff --git a/builtin/refs.c b/builtin/refs.c
index e3125bc61b..f0faabf45a 100644
--- a/builtin/refs.c
+++ b/builtin/refs.c
@@ -1,4 +1,3 @@
-#define USE_THE_REPOSITORY_VARIABLE
 #include "builtin.h"
 #include "config.h"
 #include "fsck.h"
@@ -23,7 +22,7 @@
 	N_("git refs optimize " PACK_REFS_OPTS)
 
 static int cmd_refs_migrate(int argc, const char **argv, const char *prefix,
-			    struct repository *repo UNUSED)
+			    struct repository *repo)
 {
 	const char * const migrate_usage[] = {
 		REFS_MIGRATE_USAGE,
@@ -59,13 +58,13 @@ static int cmd_refs_migrate(int argc, const char **argv, const char *prefix,
 		goto out;
 	}
 
-	if (the_repository->ref_storage_format == format) {
+	if (repo->ref_storage_format == format) {
 		err = error(_("repository already uses '%s' format"),
 			    ref_storage_format_to_name(format));
 		goto out;
 	}
 
-	if (repo_migrate_ref_storage_format(the_repository, format, flags, &errbuf) < 0) {
+	if (repo_migrate_ref_storage_format(repo, format, flags, &errbuf) < 0) {
 		err = error("%s", errbuf.buf);
 		goto out;
 	}
@@ -99,8 +98,8 @@ static int cmd_refs_verify(int argc, const char **argv, const char *prefix,
 	if (argc)
 		usage(_("'git refs verify' takes no arguments"));
 
-	repo_config(the_repository, git_fsck_config, &fsck_refs_options);
-	prepare_repo_settings(the_repository);
+	repo_config(repo, git_fsck_config, &fsck_refs_options);
+	prepare_repo_settings(repo);
 
 	worktrees = get_worktrees_without_reading_head();
 	for (size_t i = 0; worktrees[i]; i++)
@@ -124,7 +123,7 @@ static int cmd_refs_list(int argc, const char **argv, const char *prefix,
 }
 
 static int cmd_refs_exists(int argc, const char **argv, const char *prefix,
-			   struct repository *repo UNUSED)
+			   struct repository *repo)
 {
 	struct strbuf unused_referent = STRBUF_INIT;
 	struct object_id unused_oid;
@@ -145,7 +144,7 @@ static int cmd_refs_exists(int argc, const char **argv, const char *prefix,
 		die(_("'git refs exists' requires a reference"));
 
 	ref = *argv++;
-	if (refs_read_raw_ref(get_main_ref_store(the_repository), ref,
+	if (refs_read_raw_ref(get_main_ref_store(repo), ref,
 			      &unused_oid, &unused_referent, &unused_type,
 			      &failure_errno)) {
 		if (failure_errno == ENOENT || failure_errno == EISDIR) {

-- 
2.55.0.rc0.786.g65d90a0328.dirty


^ permalink raw reply related	[flat|nested] 20+ messages in thread

* [PATCH 2/4] builtin/refs: add "delete" subcommand
  2026-06-16  8:44 [PATCH 0/4] builtin/refs: add ability to write references Patrick Steinhardt
  2026-06-16  8:44 ` [PATCH 1/4] builtin/refs: drop `the_repository` Patrick Steinhardt
@ 2026-06-16  8:44 ` Patrick Steinhardt
  2026-06-16  8:44 ` [PATCH 3/4] builtin/refs: add "update" subcommand Patrick Steinhardt
                   ` (2 subsequent siblings)
  4 siblings, 0 replies; 20+ messages in thread
From: Patrick Steinhardt @ 2026-06-16  8:44 UTC (permalink / raw)
  To: git; +Cc: Junio C Hamano

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>
---
 Documentation/git-refs.adoc |  17 ++++++
 builtin/refs.c              |  46 +++++++++++++++
 t/meson.build               |   1 +
 t/t1464-refs-delete.sh      | 133 ++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 197 insertions(+)

diff --git a/Documentation/git-refs.adoc b/Documentation/git-refs.adoc
index fa33680cc7..c03e8e6ac3 100644
--- a/Documentation/git-refs.adoc
+++ b/Documentation/git-refs.adoc
@@ -20,6 +20,7 @@ git refs list [--count=<count>] [--shell|--perl|--python|--tcl]
 		   [ --stdin | (<pattern>...)]
 git refs exists <ref>
 git refs optimize [--all] [--no-prune] [--auto] [--include <pattern>] [--exclude <pattern>]
+git refs delete [--message=<reason>] [--no-deref] <ref> [<oldvalue>]
 
 DESCRIPTION
 -----------
@@ -51,6 +52,12 @@ optimize::
 	usage. This subcommand is an alias for linkgit:git-pack-refs[1] and
 	offers identical functionality.
 
+delete::
+	Delete the given reference. This subcommand mirrors `git update-ref -d`
+	(see linkgit:git-update-ref[1]). When `<oldvalue>` is given, the
+	reference is only deleted after verifying that it currently contains
+	`<oldvalue>`.
+
 OPTIONS
 -------
 
@@ -90,6 +97,16 @@ The following options are specific to 'git refs optimize':
 
 include::pack-refs-options.adoc[]
 
+The following options are specific to commands which write references:
+
+`--message=<reason>`::
+	Use the given <reason> string for the reflog entry associated with the
+	update. An empty message is rejected.
+
+`--no-deref`::
+	Operate on <ref> itself rather than the reference it points to via a
+	symbolic ref.
+
 KNOWN LIMITATIONS
 -----------------
 
diff --git a/builtin/refs.c b/builtin/refs.c
index f0faabf45a..69eb528522 100644
--- a/builtin/refs.c
+++ b/builtin/refs.c
@@ -21,6 +21,9 @@
 #define REFS_OPTIMIZE_USAGE \
 	N_("git refs optimize " PACK_REFS_OPTS)
 
+#define REFS_DELETE_USAGE \
+	N_("git refs delete [--message=<reason>] [--no-deref] <ref> [<oldvalue>]")
+
 static int cmd_refs_migrate(int argc, const char **argv, const char *prefix,
 			    struct repository *repo)
 {
@@ -175,6 +178,47 @@ static int cmd_refs_optimize(int argc, const char **argv, const char *prefix,
 	return pack_refs_core(argc, argv, prefix, repo, refs_optimize_usage);
 }
 
+static int cmd_refs_delete(int argc, const char **argv, const char *prefix,
+			   struct repository *repo)
+{
+	static char const * const refs_delete_usage[] = {
+		REFS_DELETE_USAGE,
+		NULL
+	};
+	const char *message = NULL;
+	unsigned flags = 0;
+	struct option opts[] = {
+		OPT_STRING(0, "message", &message, N_("reason"),
+			   N_("reason of the update")),
+		OPT_BIT(0 ,"no-deref", &flags,
+			N_("update <refname> not the one it points to"),
+			REF_NO_DEREF),
+		OPT_END(),
+	};
+	struct object_id oldoid;
+	const char *refname;
+
+	argc = parse_options(argc, argv, prefix, opts, refs_delete_usage, 0);
+	if (argc < 1 || argc > 2)
+		usage(_("delete requires reference name and an optional old object ID"));
+
+	if (message && !*message)
+		die(_("refusing to perform update with empty message"));
+
+	repo_config(repo, git_default_config, NULL);
+
+	refname = argv[0];
+	if (argc == 2) {
+		if (repo_get_oid_with_flags(repo, argv[1], &oldoid, GET_OID_SKIP_AMBIGUITY_CHECK))
+			die(_("invalid old object ID: '%s'"), argv[1]);
+		if (is_null_oid(&oldoid))
+			die(_("cannot delete object with null old object ID"));
+	}
+
+	return refs_delete_ref(get_main_ref_store(repo), message, refname,
+			       argc == 2 ? &oldoid : NULL, flags);
+}
+
 int cmd_refs(int argc,
 	     const char **argv,
 	     const char *prefix,
@@ -186,6 +230,7 @@ int cmd_refs(int argc,
 		"git refs list " COMMON_USAGE_FOR_EACH_REF,
 		REFS_EXISTS_USAGE,
 		REFS_OPTIMIZE_USAGE,
+		REFS_DELETE_USAGE,
 		NULL,
 	};
 	parse_opt_subcommand_fn *fn = NULL;
@@ -195,6 +240,7 @@ int cmd_refs(int argc,
 		OPT_SUBCOMMAND("list", &fn, cmd_refs_list),
 		OPT_SUBCOMMAND("exists", &fn, cmd_refs_exists),
 		OPT_SUBCOMMAND("optimize", &fn, cmd_refs_optimize),
+		OPT_SUBCOMMAND("delete", &fn, cmd_refs_delete),
 		OPT_END(),
 	};
 
diff --git a/t/meson.build b/t/meson.build
index c5832fee05..1ccf08a3b5 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -223,6 +223,7 @@ integration_tests = [
   't1461-refs-list.sh',
   't1462-refs-exists.sh',
   't1463-refs-optimize.sh',
+  't1464-refs-delete.sh',
   't1500-rev-parse.sh',
   't1501-work-tree.sh',
   't1502-rev-parse-parseopt.sh',
diff --git a/t/t1464-refs-delete.sh b/t/t1464-refs-delete.sh
new file mode 100755
index 0000000000..4a36d3866b
--- /dev/null
+++ b/t/t1464-refs-delete.sh
@@ -0,0 +1,133 @@
+#!/bin/sh
+
+test_description='git refs delete'
+
+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main
+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
+
+. ./test-lib.sh
+
+setup_repo () {
+	git init "$1" &&
+	test_commit -C "$1" A &&
+	test_commit -C "$1" B
+}
+
+test_expect_success 'delete without oldvalue verification' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	A=$(git -C repo rev-parse A) &&
+	git -C repo update-ref refs/heads/foo $A &&
+	git -C repo refs delete refs/heads/foo &&
+	test_must_fail git -C repo show-ref --verify -q refs/heads/foo
+'
+
+test_expect_success 'delete with matching oldvalue' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git update-ref refs/heads/foo $A &&
+		git refs delete refs/heads/foo $A &&
+		test_must_fail git refs exists refs/heads/foo
+	)
+'
+
+test_expect_success 'delete with stale oldvalue fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git update-ref refs/heads/foo $A &&
+		test_must_fail git refs delete refs/heads/foo $B 2>err &&
+		test_grep " but expected " err &&
+		git refs exists refs/heads/foo
+	)
+'
+
+test_expect_success 'delete with null oldvalue fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git update-ref refs/heads/foo $A &&
+		test_must_fail git refs delete refs/heads/foo $ZERO_OID 2>err &&
+		test_grep "null old object ID" err &&
+		git refs exists refs/heads/foo
+	)
+'
+
+test_expect_success 'delete with invalid oldvalue fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git update-ref refs/heads/foo $A &&
+		test_must_fail git refs delete refs/heads/foo invalid-oid 2>err &&
+		test_grep "invalid old object ID" err &&
+		git refs exists refs/heads/foo
+	)
+'
+
+test_expect_success 'delete symref with --no-deref leaves target intact' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git update-ref refs/heads/foo $A &&
+		git symbolic-ref refs/heads/symref refs/heads/foo &&
+		git refs delete --no-deref refs/heads/symref &&
+		test_must_fail git refs exists refs/heads/symref &&
+		git refs exists refs/heads/foo
+	)
+'
+
+test_expect_success 'delete with message records reason in reflog' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git update-ref refs/heads/foo $A &&
+		git symbolic-ref HEAD refs/heads/foo &&
+		git refs delete --message=delete-reason refs/heads/foo &&
+		test_must_fail git refs exists refs/heads/foo &&
+		test-tool ref-store main for-each-reflog-ent HEAD >actual &&
+		test_grep "delete-reason$" actual
+	)
+'
+
+test_expect_success 'delete with empty message fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git update-ref refs/heads/foo $A &&
+		test_must_fail git refs delete --message= refs/heads/foo 2>err &&
+		test_grep "empty message" err &&
+		git refs exists refs/heads/foo
+	)
+'
+
+test_expect_success 'delete without arguments fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	test_must_fail git -C repo refs delete 2>err &&
+	test_grep "requires reference name" err
+'
+
+test_expect_success 'delete with too many arguments fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	test_must_fail git refs delete one two three 2>err &&
+	test_grep "requires reference name" err
+'
+
+test_done

-- 
2.55.0.rc0.786.g65d90a0328.dirty


^ permalink raw reply related	[flat|nested] 20+ messages in thread

* [PATCH 3/4] builtin/refs: add "update" subcommand
  2026-06-16  8:44 [PATCH 0/4] builtin/refs: add ability to write references Patrick Steinhardt
  2026-06-16  8:44 ` [PATCH 1/4] builtin/refs: drop `the_repository` Patrick Steinhardt
  2026-06-16  8:44 ` [PATCH 2/4] builtin/refs: add "delete" subcommand Patrick Steinhardt
@ 2026-06-16  8:44 ` Patrick Steinhardt
  2026-06-16 11:17   ` Junio C Hamano
  2026-06-16 14:52   ` Junio C Hamano
  2026-06-16  8:44 ` [PATCH 4/4] builtin/refs: add "rename" subcommand Patrick Steinhardt
  2026-06-17 10:15 ` [PATCH v2 0/5] builtin/refs: add ability to write references Patrick Steinhardt
  4 siblings, 2 replies; 20+ messages in thread
From: Patrick Steinhardt @ 2026-06-16  8:44 UTC (permalink / raw)
  To: git; +Cc: Junio C Hamano

Add a new "update" subcommand which mirrors `git update-ref <refname>
<oldoid> <newoid>`. This follows the same reasoning as the preceding
commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-refs.adoc |   7 ++
 builtin/refs.c              |  50 +++++++++++++
 t/meson.build               |   1 +
 t/t1465-refs-update.sh      | 179 ++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 237 insertions(+)

diff --git a/Documentation/git-refs.adoc b/Documentation/git-refs.adoc
index c03e8e6ac3..0a887cf5e5 100644
--- a/Documentation/git-refs.adoc
+++ b/Documentation/git-refs.adoc
@@ -21,6 +21,7 @@ git refs list [--count=<count>] [--shell|--perl|--python|--tcl]
 git refs exists <ref>
 git refs optimize [--all] [--no-prune] [--auto] [--include <pattern>] [--exclude <pattern>]
 git refs delete [--message=<reason>] [--no-deref] <ref> [<oldvalue>]
+git refs update [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value> [<old-value>]
 
 DESCRIPTION
 -----------
@@ -58,6 +59,12 @@ delete::
 	reference is only deleted after verifying that it currently contains
 	`<oldvalue>`.
 
+update::
+	Update the given reference to point at `<new-value>`. This subcommand
+	mirrors `git update-ref` (see linkgit:git-update-ref[1]). When
+	`<old-value>` is given, the reference is only updated after verifying
+	that it currently contains `<old-value>`.
+
 OPTIONS
 -------
 
diff --git a/builtin/refs.c b/builtin/refs.c
index 69eb528522..3238ddf3f0 100644
--- a/builtin/refs.c
+++ b/builtin/refs.c
@@ -24,6 +24,9 @@
 #define REFS_DELETE_USAGE \
 	N_("git refs delete [--message=<reason>] [--no-deref] <ref> [<oldvalue>]")
 
+#define REFS_UPDATE_USAGE \
+	N_("git refs update [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value> [<old-value>]")
+
 static int cmd_refs_migrate(int argc, const char **argv, const char *prefix,
 			    struct repository *repo)
 {
@@ -219,6 +222,51 @@ static int cmd_refs_delete(int argc, const char **argv, const char *prefix,
 			       argc == 2 ? &oldoid : NULL, flags);
 }
 
+static int cmd_refs_update(int argc, const char **argv, const char *prefix,
+			   struct repository *repo)
+{
+	static char const * const refs_update_usage[] = {
+		REFS_UPDATE_USAGE,
+		NULL
+	};
+	const char *message = NULL;
+	unsigned flags = 0;
+	struct option opts[] = {
+		OPT_STRING(0, "message", &message, N_("reason"),
+			   N_("reason of the update")),
+		OPT_BIT(0 ,"no-deref", &flags,
+			N_("update <refname> not the one it points to"),
+			REF_NO_DEREF),
+		OPT_BIT(0, "create-reflog", &flags, N_("create a reflog"),
+			REF_FORCE_CREATE_REFLOG),
+		OPT_END(),
+	};
+	struct object_id newoid, oldoid;
+	const char *refname;
+
+	argc = parse_options(argc, argv, prefix, opts, refs_update_usage, 0);
+	if (argc < 2 || argc > 3)
+		usage(_("update requires reference name, new value and an optional old value"));
+
+	if (message && !*message)
+		die(_("refusing to perform update with empty message"));
+
+	repo_config(repo, git_default_config, NULL);
+
+	refname = argv[0];
+	if (repo_get_oid_with_flags(repo, argv[1], &newoid,
+				    GET_OID_SKIP_AMBIGUITY_CHECK))
+		die(_("invalid new object ID: %s"), argv[1]);
+	if (argc == 3 &&
+	    repo_get_oid_with_flags(repo, argv[2], &oldoid,
+				    GET_OID_SKIP_AMBIGUITY_CHECK))
+		die(_("invalid old object ID: %s"), argv[2]);
+
+	return refs_update_ref(get_main_ref_store(repo), message, refname,
+			       &newoid, argc == 3 ? &oldoid : NULL, flags,
+			       UPDATE_REFS_DIE_ON_ERR);
+}
+
 int cmd_refs(int argc,
 	     const char **argv,
 	     const char *prefix,
@@ -231,6 +279,7 @@ int cmd_refs(int argc,
 		REFS_EXISTS_USAGE,
 		REFS_OPTIMIZE_USAGE,
 		REFS_DELETE_USAGE,
+		REFS_UPDATE_USAGE,
 		NULL,
 	};
 	parse_opt_subcommand_fn *fn = NULL;
@@ -241,6 +290,7 @@ int cmd_refs(int argc,
 		OPT_SUBCOMMAND("exists", &fn, cmd_refs_exists),
 		OPT_SUBCOMMAND("optimize", &fn, cmd_refs_optimize),
 		OPT_SUBCOMMAND("delete", &fn, cmd_refs_delete),
+		OPT_SUBCOMMAND("update", &fn, cmd_refs_update),
 		OPT_END(),
 	};
 
diff --git a/t/meson.build b/t/meson.build
index 1ccf08a3b5..2063962dab 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -224,6 +224,7 @@ integration_tests = [
   't1462-refs-exists.sh',
   't1463-refs-optimize.sh',
   't1464-refs-delete.sh',
+  't1465-refs-update.sh',
   't1500-rev-parse.sh',
   't1501-work-tree.sh',
   't1502-rev-parse-parseopt.sh',
diff --git a/t/t1465-refs-update.sh b/t/t1465-refs-update.sh
new file mode 100755
index 0000000000..e7582a6195
--- /dev/null
+++ b/t/t1465-refs-update.sh
@@ -0,0 +1,179 @@
+#!/bin/sh
+
+test_description='git refs update'
+
+. ./test-lib.sh
+
+setup_repo () {
+	git init "$1" &&
+	test_commit -C "$1" A &&
+	test_commit -C "$1" B
+}
+
+test_ref_matches () {
+	git rev-parse "$1" >expect &&
+	echo "$2" >actual &&
+	test_cmp expect actual
+}
+
+test_expect_success 'update creates a new reference' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update refs/heads/foo $A &&
+		test_ref_matches refs/heads/foo "$A"
+	)
+'
+
+test_expect_success 'update an existing reference without oldvalue' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		git refs update refs/heads/foo $B &&
+		test_ref_matches refs/heads/foo $B
+	)
+'
+
+test_expect_success 'update with matching oldvalue' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		git refs update refs/heads/foo $B $A &&
+		test_ref_matches refs/heads/foo $B
+	)
+'
+
+test_expect_success 'update with stale oldvalue fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		test_must_fail git refs update refs/heads/foo $B $B 2>err &&
+		test_grep " but expected " err &&
+		test_ref_matches refs/heads/foo $A
+	)
+'
+
+test_expect_success 'update with invalid new value fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		test_must_fail git refs update refs/heads/foo invalid-oid 2>err &&
+		test_grep "invalid new object ID" err &&
+		test_must_fail git refs exists refs/heads/foo
+	)
+'
+
+test_expect_success 'update with invalid old value fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		test_must_fail git refs update refs/heads/foo $B invalid-oid 2>err &&
+		test_grep "invalid old object ID" err &&
+		test_ref_matches refs/heads/foo $A
+	)
+'
+
+test_expect_success 'update --no-deref rewrites the symref itself' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		git symbolic-ref refs/heads/symref refs/heads/foo &&
+		git refs update --no-deref refs/heads/symref $B &&
+		test_must_fail git symbolic-ref refs/heads/symref &&
+		test_ref_matches refs/heads/symref $B &&
+		test_ref_matches refs/heads/foo $A
+	)
+'
+
+test_expect_success 'update does not create a reflog by default' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update refs/foo $A &&
+		test_must_fail git reflog exists refs/foo
+	)
+'
+
+test_expect_success 'update creates a reflog with --create-reflog' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update --create-reflog refs/foo $A &&
+		git reflog exists refs/foo
+	)
+'
+
+test_expect_success 'update with message records reason in reflog' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		git refs update --message=update-reason refs/heads/foo $B &&
+		git reflog show refs/heads/foo >actual &&
+		test_grep "update-reason$" actual
+	)
+'
+
+test_expect_success 'update with empty message fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		test_must_fail git refs update --message= refs/heads/foo $B 2>err &&
+		test_grep "empty message" err
+	)
+'
+
+test_expect_success 'update with too few arguments fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	test_must_fail git -C repo refs update refs/heads/foo 2>err &&
+	test_grep "requires reference name, new value" err
+'
+
+test_expect_success 'update with too many arguments fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		test_must_fail git refs update refs/heads/foo $A $B extra 2>err &&
+		test_grep "requires reference name, new value" err
+	)
+'
+
+test_done

-- 
2.55.0.rc0.786.g65d90a0328.dirty


^ permalink raw reply related	[flat|nested] 20+ messages in thread

* [PATCH 4/4] builtin/refs: add "rename" subcommand
  2026-06-16  8:44 [PATCH 0/4] builtin/refs: add ability to write references Patrick Steinhardt
                   ` (2 preceding siblings ...)
  2026-06-16  8:44 ` [PATCH 3/4] builtin/refs: add "update" subcommand Patrick Steinhardt
@ 2026-06-16  8:44 ` Patrick Steinhardt
  2026-06-16 14:53   ` Junio C Hamano
  2026-06-17 10:15 ` [PATCH v2 0/5] builtin/refs: add ability to write references Patrick Steinhardt
  4 siblings, 1 reply; 20+ messages in thread
From: Patrick Steinhardt @ 2026-06-16  8:44 UTC (permalink / raw)
  To: git; +Cc: Junio C Hamano

Add a "rename" subcommand to git-refs(1) with the syntax:

  $ git refs rename <oldref> <newref>

It renames <oldref> together with its reflog to <newref>; even when used
on a local branch ref, the current value and the reflog of the ref are
the only things that are renamed. Document it and redirect casual users
to "git branch -m" if that is what they wanted to do.

Co-authored-by: Junio C Hamano <gitster@pobox.com>
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-refs.adoc |  10 ++++
 builtin/refs.c              |  42 ++++++++++++++
 t/meson.build               |   1 +
 t/t1466-refs-rename.sh      | 131 ++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 184 insertions(+)

diff --git a/Documentation/git-refs.adoc b/Documentation/git-refs.adoc
index 0a887cf5e5..85eb100205 100644
--- a/Documentation/git-refs.adoc
+++ b/Documentation/git-refs.adoc
@@ -22,6 +22,7 @@ git refs exists <ref>
 git refs optimize [--all] [--no-prune] [--auto] [--include <pattern>] [--exclude <pattern>]
 git refs delete [--message=<reason>] [--no-deref] <ref> [<oldvalue>]
 git refs update [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value> [<old-value>]
+git refs rename [--message=<reason>] <oldref> <newref>
 
 DESCRIPTION
 -----------
@@ -65,6 +66,11 @@ update::
 	`<old-value>` is given, the reference is only updated after verifying
 	that it currently contains `<old-value>`.
 
+rename::
+	Rename the reference `<oldref>` to `<newref>`. The old reference must
+	exist and the new reference must not yet exist, and both must have a
+	well-formed name (see linkgit:git-check-ref-format[1]).
+
 OPTIONS
 -------
 
@@ -106,6 +112,10 @@ include::pack-refs-options.adoc[]
 
 The following options are specific to commands which write references:
 
+`--create-reflog`::
+	Create a reflog for the reference even if one would not ordinarily be
+	created.
+
 `--message=<reason>`::
 	Use the given <reason> string for the reflog entry associated with the
 	update. An empty message is rejected.
diff --git a/builtin/refs.c b/builtin/refs.c
index 3238ddf3f0..b90baf5633 100644
--- a/builtin/refs.c
+++ b/builtin/refs.c
@@ -27,6 +27,9 @@
 #define REFS_UPDATE_USAGE \
 	N_("git refs update [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value> [<old-value>]")
 
+#define REFS_RENAME_USAGE \
+	N_("git refs rename [--message=<reason>] <oldref> <newref>")
+
 static int cmd_refs_migrate(int argc, const char **argv, const char *prefix,
 			    struct repository *repo)
 {
@@ -267,6 +270,43 @@ static int cmd_refs_update(int argc, const char **argv, const char *prefix,
 			       UPDATE_REFS_DIE_ON_ERR);
 }
 
+static int cmd_refs_rename(int argc, const char **argv, const char *prefix,
+			   struct repository *repo)
+{
+	static char const * const refs_rename_usage[] = {
+		REFS_RENAME_USAGE,
+		NULL
+	};
+	const char *message = NULL;
+	struct option opts[] = {
+		OPT_STRING(0, "message", &message, N_("reason"),
+			   N_("reason of the update")),
+		OPT_END(),
+	};
+	const char *oldref, *newref;
+
+	argc = parse_options(argc, argv, prefix, opts, refs_rename_usage, 0);
+	if (argc != 2)
+		usage(_("rename requires old and new reference name"));
+	if (message && !*message)
+		die(_("refusing to perform update with empty message"));
+
+	oldref = argv[0];
+	newref = argv[1];
+
+	if (check_refname_format(oldref, 0))
+		die(_("invalid ref format: %s"), oldref);
+	if (check_refname_format(newref, 0))
+		die(_("invalid ref format: %s"), newref);
+
+	if (!refs_ref_exists(get_main_ref_store(repo), oldref))
+		die(_("reference does not exist: '%s'"), oldref);
+	if (refs_ref_exists(get_main_ref_store(repo), newref))
+		die(_("reference already exists: '%s'"), newref);
+
+	return refs_rename_ref(get_main_ref_store(repo), oldref, newref, message);
+}
+
 int cmd_refs(int argc,
 	     const char **argv,
 	     const char *prefix,
@@ -280,6 +320,7 @@ int cmd_refs(int argc,
 		REFS_OPTIMIZE_USAGE,
 		REFS_DELETE_USAGE,
 		REFS_UPDATE_USAGE,
+		REFS_RENAME_USAGE,
 		NULL,
 	};
 	parse_opt_subcommand_fn *fn = NULL;
@@ -291,6 +332,7 @@ int cmd_refs(int argc,
 		OPT_SUBCOMMAND("optimize", &fn, cmd_refs_optimize),
 		OPT_SUBCOMMAND("delete", &fn, cmd_refs_delete),
 		OPT_SUBCOMMAND("update", &fn, cmd_refs_update),
+		OPT_SUBCOMMAND("rename", &fn, cmd_refs_rename),
 		OPT_END(),
 	};
 
diff --git a/t/meson.build b/t/meson.build
index 2063962dab..a1a6880fe6 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -225,6 +225,7 @@ integration_tests = [
   't1463-refs-optimize.sh',
   't1464-refs-delete.sh',
   't1465-refs-update.sh',
+  't1466-refs-rename.sh',
   't1500-rev-parse.sh',
   't1501-work-tree.sh',
   't1502-rev-parse-parseopt.sh',
diff --git a/t/t1466-refs-rename.sh b/t/t1466-refs-rename.sh
new file mode 100755
index 0000000000..f80d58e0f4
--- /dev/null
+++ b/t/t1466-refs-rename.sh
@@ -0,0 +1,131 @@
+#!/bin/sh
+
+test_description='git refs rename'
+
+. ./test-lib.sh
+
+setup_repo () {
+	git init "$1" &&
+	test_commit -C "$1" A &&
+	test_commit -C "$1" B
+}
+
+test_ref_matches () {
+	git rev-parse "$1" >expect &&
+	echo "$2" >actual &&
+	test_cmp expect actual
+}
+
+test_expect_success 'rename an existing reference' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update refs/heads/foo $A &&
+		git refs rename refs/heads/foo refs/heads/bar &&
+		test_must_fail git refs exists refs/heads/foo &&
+		test_ref_matches refs/heads/bar $A
+	)
+'
+
+test_expect_success 'rename moves the reflog along with the reference' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update --message="rename me" refs/heads/foo $A &&
+		git refs rename refs/heads/foo refs/heads/bar &&
+		git reflog show refs/heads/bar >reflog &&
+		test_grep "rename me" reflog &&
+		test_must_fail git reflog exists refs/heads/foo
+	)
+'
+
+test_expect_success 'rename with message records reason in reflog' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update refs/heads/foo $A &&
+		git refs rename --message="rename reason" refs/heads/foo refs/heads/bar &&
+		git reflog show refs/heads/bar >actual &&
+		test_grep "rename reason" actual
+	)
+'
+
+test_expect_success 'rename a nonexistent reference fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		test_must_fail git refs rename refs/heads/foo refs/heads/bar 2>err &&
+		test_grep "reference does not exist" err
+	)
+'
+
+test_expect_success 'rename to an existing reference fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		git refs update refs/heads/bar $B &&
+		test_must_fail git refs rename refs/heads/foo refs/heads/bar 2>err &&
+		test_grep "reference already exists" err
+	)
+'
+
+test_expect_success 'rename with empty message fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update refs/heads/foo $A &&
+		test_must_fail git refs rename --message= refs/heads/foo refs/heads/bar 2>err &&
+		test_grep "empty message" err
+	)
+'
+
+test_expect_success 'rename with invalid old reference name fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		test_must_fail git refs rename "refs/heads/foo..bar" refs/heads/bar 2>err &&
+		test_grep "invalid ref format" err
+	)
+'
+
+test_expect_success 'rename with invalid new reference name fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update refs/heads/foo $A &&
+		test_must_fail git refs rename refs/heads/foo "refs/heads/bar..baz" 2>err &&
+		test_grep "invalid ref format" err
+	)
+'
+
+test_expect_success 'rename with too few arguments fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	test_must_fail git -C repo refs rename refs/heads/foo 2>err &&
+	test_grep "requires old and new reference name" err
+'
+
+test_expect_success 'rename with too many arguments fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	test_must_fail git -C repo refs rename refs/heads/foo refs/heads/bar refs/heads/baz 2>err &&
+	test_grep "requires old and new reference name" err
+'
+
+test_done

-- 
2.55.0.rc0.786.g65d90a0328.dirty


^ permalink raw reply related	[flat|nested] 20+ messages in thread

* Re: [PATCH 3/4] builtin/refs: add "update" subcommand
  2026-06-16  8:44 ` [PATCH 3/4] builtin/refs: add "update" subcommand Patrick Steinhardt
@ 2026-06-16 11:17   ` Junio C Hamano
  2026-06-17  7:28     ` Patrick Steinhardt
  2026-06-16 14:52   ` Junio C Hamano
  1 sibling, 1 reply; 20+ messages in thread
From: Junio C Hamano @ 2026-06-16 11:17 UTC (permalink / raw)
  To: Patrick Steinhardt; +Cc: git

Patrick Steinhardt <ps@pks.im> writes:

> Add a new "update" subcommand which mirrors `git update-ref <refname>
> <oldoid> <newoid>`. This follows the same reasoning as the preceding
> commit.
>
> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>  Documentation/git-refs.adoc |   7 ++
>  builtin/refs.c              |  50 +++++++++++++
>  t/meson.build               |   1 +
>  t/t1465-refs-update.sh      | 179 ++++++++++++++++++++++++++++++++++++++++++++
>  4 files changed, 237 insertions(+)

I do not offhand know (and I am still away by 2 hours from the time
I wake up and start functioning) if update-ref shares the same
issue, but with "delete, update, rename" combo, lack of "create"
feels a bit annoying.  Wouldn't we want to offer an option to users
who want to ensure that the refs they create are truly new and they
are not overwriting a ref somebody has created?  Either (1) drop
"delete" and take a special value (e.g. "") as <newvalue> to signal
deletion and make the same special value used as <oldvalue> signals
creation, or (2) add "create" and insist that "update" takes only an
existing ref, would make the annoyance go away, I guess.

> +test_expect_success 'update creates a new reference' '
> +	test_when_finished "rm -rf repo" &&
> +	setup_repo repo &&
> +	(
> +		cd repo &&
> +		A=$(git rev-parse A) &&
> +		git refs update refs/heads/foo $A &&
> +		test_ref_matches refs/heads/foo "$A"
> +	)
> +'

Here we cannot test (and I strongly suspect that "git refs update"
and "git update-ref" lack ability to do so) a case where a creation
is attempted on an existing ref and fails.

^ permalink raw reply	[flat|nested] 20+ messages in thread

* Re: [PATCH 3/4] builtin/refs: add "update" subcommand
  2026-06-16  8:44 ` [PATCH 3/4] builtin/refs: add "update" subcommand Patrick Steinhardt
  2026-06-16 11:17   ` Junio C Hamano
@ 2026-06-16 14:52   ` Junio C Hamano
  2026-06-17  7:28     ` Patrick Steinhardt
  1 sibling, 1 reply; 20+ messages in thread
From: Junio C Hamano @ 2026-06-16 14:52 UTC (permalink / raw)
  To: Patrick Steinhardt; +Cc: git

Patrick Steinhardt <ps@pks.im> writes:

>  git refs delete [--message=<reason>] [--no-deref] <ref> [<oldvalue>]
> +git refs update [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value> [<old-value>]

"<old-value> vs <new-value>" is good, we should update "delete" to
use "<old-value>" to match.


>  DESCRIPTION
>  -----------
> @@ -58,6 +59,12 @@ delete::
>  	reference is only deleted after verifying that it currently contains
>  	`<oldvalue>`.
>  
> +update::
> +	Update the given reference to point at `<new-value>`. This subcommand
> +	mirrors `git update-ref` (see linkgit:git-update-ref[1]). When
> +	`<old-value>` is given, the reference is only updated after verifying
> +	that it currently contains `<old-value>`.

As to the lack of "create", among the two potential changes, I have
a slight preference for adding "create" and failing "update" that
does not refer to an existing ref.  If we go that route, the
"--create-reflog" option should move to "create", as "update" will
never be used to create a new ref.


> +	if (repo_get_oid_with_flags(repo, argv[1], &newoid,
> +				    GET_OID_SKIP_AMBIGUITY_CHECK))
> +		die(_("invalid new object ID: %s"), argv[1]);
> +	if (argc == 3 &&
> +	    repo_get_oid_with_flags(repo, argv[2], &oldoid,
> +				    GET_OID_SKIP_AMBIGUITY_CHECK))
> +		die(_("invalid old object ID: %s"), argv[2]);

On the "delete" side, these messages quote the object name, i.e.,

			die(_("invalid old object ID: '%s'"), argv[1]);

We should be consistent.

^ permalink raw reply	[flat|nested] 20+ messages in thread

* Re: [PATCH 4/4] builtin/refs: add "rename" subcommand
  2026-06-16  8:44 ` [PATCH 4/4] builtin/refs: add "rename" subcommand Patrick Steinhardt
@ 2026-06-16 14:53   ` Junio C Hamano
  2026-06-17  7:28     ` Patrick Steinhardt
  0 siblings, 1 reply; 20+ messages in thread
From: Junio C Hamano @ 2026-06-16 14:53 UTC (permalink / raw)
  To: Patrick Steinhardt; +Cc: git

Patrick Steinhardt <ps@pks.im> writes:

> +static int cmd_refs_rename(int argc, const char **argv, const char *prefix,
> +			   struct repository *repo)
> +{
> +	static char const * const refs_rename_usage[] = {
> +		REFS_RENAME_USAGE,
> +		NULL
> +	};
> +	const char *message = NULL;
> +	struct option opts[] = {
> +		OPT_STRING(0, "message", &message, N_("reason"),
> +			   N_("reason of the update")),
> +		OPT_END(),
> +	};
> +	const char *oldref, *newref;
> +
> +	argc = parse_options(argc, argv, prefix, opts, refs_rename_usage, 0);
> +	if (argc != 2)
> +		usage(_("rename requires old and new reference name"));
> +	if (message && !*message)
> +		die(_("refusing to perform update with empty message"));
> +
> +	oldref = argv[0];
> +	newref = argv[1];
> +
> +	if (check_refname_format(oldref, 0))
> +		die(_("invalid ref format: %s"), oldref);
> +	if (check_refname_format(newref, 0))
> +		die(_("invalid ref format: %s"), newref);

Do we want to quote the value?  What other subcommands do in "git refs"?

> +	if (!refs_ref_exists(get_main_ref_store(repo), oldref))
> +		die(_("reference does not exist: '%s'"), oldref);
> +	if (refs_ref_exists(get_main_ref_store(repo), newref))
> +		die(_("reference already exists: '%s'"), newref);
> +
> +	return refs_rename_ref(get_main_ref_store(repo), oldref, newref, message);
> +}

I suspect that my version shared the same issue, but doesn't
refs_rename_ref() return -1 for failure, which we may want to turn
to positive 1 before returning?

This is a tangent but git.c:handle_builtin() that calls
git.c:run_builtin() may want to do the "negative return? flip the
polarity" conversion to make this worry go away.  I dunno what such
a change would break, though.

If we rename a ref that does not have a reflog, would it leave the
ref under the new name without reflog, or would we get a reflog with
a single entry that marks the fact the old ref was renamed into the
new ref?  Should that be controlled via --create-reflog option?

^ permalink raw reply	[flat|nested] 20+ messages in thread

* Re: [PATCH 3/4] builtin/refs: add "update" subcommand
  2026-06-16 11:17   ` Junio C Hamano
@ 2026-06-17  7:28     ` Patrick Steinhardt
  2026-06-17 12:11       ` Junio C Hamano
  0 siblings, 1 reply; 20+ messages in thread
From: Patrick Steinhardt @ 2026-06-17  7:28 UTC (permalink / raw)
  To: Junio C Hamano; +Cc: git

On Tue, Jun 16, 2026 at 04:17:27AM -0700, Junio C Hamano wrote:
> Patrick Steinhardt <ps@pks.im> writes:
> 
> > Add a new "update" subcommand which mirrors `git update-ref <refname>
> > <oldoid> <newoid>`. This follows the same reasoning as the preceding
> > commit.
> >
> > Signed-off-by: Patrick Steinhardt <ps@pks.im>
> > ---
> >  Documentation/git-refs.adoc |   7 ++
> >  builtin/refs.c              |  50 +++++++++++++
> >  t/meson.build               |   1 +
> >  t/t1465-refs-update.sh      | 179 ++++++++++++++++++++++++++++++++++++++++++++
> >  4 files changed, 237 insertions(+)
> 
> I do not offhand know (and I am still away by 2 hours from the time
> I wake up and start functioning) if update-ref shares the same
> issue, but with "delete, update, rename" combo, lack of "create"
> feels a bit annoying.  Wouldn't we want to offer an option to users
> who want to ensure that the refs they create are truly new and they
> are not overwriting a ref somebody has created?  Either (1) drop
> "delete" and take a special value (e.g. "") as <newvalue> to signal
> deletion and make the same special value used as <oldvalue> signals
> creation, or (2) add "create" and insist that "update" takes only an
> existing ref, would make the annoyance go away, I guess.

In theory "update" can handle both updating existing references,
deleting them and creating them race-free by providing NUL object IDs
for either old or new value. But I agree that this is cumbersome, and
adding another "create" subcommand as an easy-to-understand shortcut
feels sensible.

> > +test_expect_success 'update creates a new reference' '
> > +	test_when_finished "rm -rf repo" &&
> > +	setup_repo repo &&
> > +	(
> > +		cd repo &&
> > +		A=$(git rev-parse A) &&
> > +		git refs update refs/heads/foo $A &&
> > +		test_ref_matches refs/heads/foo "$A"
> > +	)
> > +'
> 
> Here we cannot test (and I strongly suspect that "git refs update"
> and "git update-ref" lack ability to do so) a case where a creation
> is attempted on an existing ref and fails.

We can:

    $ git update-ref $NEW_OID $NULL_OID
    $ git refs update $NEW_OID $NULL_OID

This will verify that the reference doesn't exist before actually
writing it. Will add a test.

Patrick

^ permalink raw reply	[flat|nested] 20+ messages in thread

* Re: [PATCH 3/4] builtin/refs: add "update" subcommand
  2026-06-16 14:52   ` Junio C Hamano
@ 2026-06-17  7:28     ` Patrick Steinhardt
  0 siblings, 0 replies; 20+ messages in thread
From: Patrick Steinhardt @ 2026-06-17  7:28 UTC (permalink / raw)
  To: Junio C Hamano; +Cc: git

On Tue, Jun 16, 2026 at 07:52:59AM -0700, Junio C Hamano wrote:
> Patrick Steinhardt <ps@pks.im> writes:
> 
> >  git refs delete [--message=<reason>] [--no-deref] <ref> [<oldvalue>]
> > +git refs update [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value> [<old-value>]
> 
> "<old-value> vs <new-value>" is good, we should update "delete" to
> use "<old-value>" to match.

Good catch.

> > @@ -58,6 +59,12 @@ delete::
> >  	reference is only deleted after verifying that it currently contains
> >  	`<oldvalue>`.
> >  
> > +update::
> > +	Update the given reference to point at `<new-value>`. This subcommand
> > +	mirrors `git update-ref` (see linkgit:git-update-ref[1]). When
> > +	`<old-value>` is given, the reference is only updated after verifying
> > +	that it currently contains `<old-value>`.
> 
> As to the lack of "create", among the two potential changes, I have
> a slight preference for adding "create" and failing "update" that
> does not refer to an existing ref.  If we go that route, the
> "--create-reflog" option should move to "create", as "update" will
> never be used to create a new ref.

I agree that we should add "create", but I also think that we should
keep the special syntax of:

  - "git refs update $NULL_OID [$OLD_OID]" to delete a branch.

  - "git refs update $NEW_OID $NULL_OID" to create a branch.

While the other commands are more ergonomic, I don't see a strong reason
to restrict the "update" subcommand. Also, this ensures that it's in
line with git-update-ref(1), which also allows for these use cases.

> > +	if (repo_get_oid_with_flags(repo, argv[1], &newoid,
> > +				    GET_OID_SKIP_AMBIGUITY_CHECK))
> > +		die(_("invalid new object ID: %s"), argv[1]);
> > +	if (argc == 3 &&
> > +	    repo_get_oid_with_flags(repo, argv[2], &oldoid,
> > +				    GET_OID_SKIP_AMBIGUITY_CHECK))
> > +		die(_("invalid old object ID: %s"), argv[2]);
> 
> On the "delete" side, these messages quote the object name, i.e.,
> 
> 			die(_("invalid old object ID: '%s'"), argv[1]);
> 
> We should be consistent.

Yup, fixed now.

Patrick

^ permalink raw reply	[flat|nested] 20+ messages in thread

* Re: [PATCH 4/4] builtin/refs: add "rename" subcommand
  2026-06-16 14:53   ` Junio C Hamano
@ 2026-06-17  7:28     ` Patrick Steinhardt
  2026-06-17 12:13       ` Junio C Hamano
  0 siblings, 1 reply; 20+ messages in thread
From: Patrick Steinhardt @ 2026-06-17  7:28 UTC (permalink / raw)
  To: Junio C Hamano; +Cc: git

On Tue, Jun 16, 2026 at 07:53:02AM -0700, Junio C Hamano wrote:
> Patrick Steinhardt <ps@pks.im> writes:
[snip]
> > +	if (!refs_ref_exists(get_main_ref_store(repo), oldref))
> > +		die(_("reference does not exist: '%s'"), oldref);
> > +	if (refs_ref_exists(get_main_ref_store(repo), newref))
> > +		die(_("reference already exists: '%s'"), newref);
> > +
> > +	return refs_rename_ref(get_main_ref_store(repo), oldref, newref, message);
> > +}
> 
> I suspect that my version shared the same issue, but doesn't
> refs_rename_ref() return -1 for failure, which we may want to turn
> to positive 1 before returning?
> 
> This is a tangent but git.c:handle_builtin() that calls
> git.c:run_builtin() may want to do the "negative return? flip the
> polarity" conversion to make this worry go away.  I dunno what such
> a change would break, though.

Fair. The other subcommands also suffer from the same problem, so I'll
update all of them to return 1 explicitly.

> If we rename a ref that does not have a reflog, would it leave the
> ref under the new name without reflog, or would we get a reflog with
> a single entry that marks the fact the old ref was renamed into the
> new ref?  Should that be controlled via --create-reflog option?

It would leave it without a reflog. In theory I agree that it might make
sense to introduce a "--create-reflog" option, but that would require
some new plumbing in `refs_rename_ref()`. So I'd say that we can add it
at a later point as needed.

Patrick

^ permalink raw reply	[flat|nested] 20+ messages in thread

* [PATCH v2 0/5] builtin/refs: add ability to write references
  2026-06-16  8:44 [PATCH 0/4] builtin/refs: add ability to write references Patrick Steinhardt
                   ` (3 preceding siblings ...)
  2026-06-16  8:44 ` [PATCH 4/4] builtin/refs: add "rename" subcommand Patrick Steinhardt
@ 2026-06-17 10:15 ` Patrick Steinhardt
  2026-06-17 10:15   ` [PATCH v2 1/5] builtin/refs: drop `the_repository` Patrick Steinhardt
                     ` (5 more replies)
  4 siblings, 6 replies; 20+ messages in thread
From: Patrick Steinhardt @ 2026-06-17 10:15 UTC (permalink / raw)
  To: git; +Cc: Junio C Hamano

Hi,

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).

This patch series backfills most of the functionality by introducing
three new commands:

  - `git refs delete` to delete references. This is the equivalent of
    `git update-ref -d`.

  - `git refs update` to update references. This is the equivalent of
    `git update-ref <refname> <oldvalue> <newvalue>`.

  - `git refs rename` to rename a reference, including its reflog. This
    does not have an equivalent in git-update-ref(1), but is inspired by
    and supersedes [1].

Changes in v2:
  - Add a new "create" subcommand.
  - Consistently quote in error messages.
  - Consistently use `<old-value>` in the synopsis.
  - Don't return negative exit codes.
  - Improve documentation of "update" subcommand to mention that you can
    create and delete branches.
  - Add tests to verify that we can use "update" to do this, both in
    racy and raceless ways.
  - Add missing calls to `repo_config()`.
  - Drop useless `GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME` variable.
  - Link to v1: https://patch.msgid.link/20260616-pks-refs-writing-subcommands-v1-0-9f5219b6109d@pks.im

Thanks!

Patrick

[1]: <xmqqv7brz9ba.fsf@gitster.g>

---
Patrick Steinhardt (5):
      builtin/refs: drop `the_repository`
      builtin/refs: add "delete" subcommand
      builtin/refs: add "update" subcommand
      builtin/refs: add "create" subcommand
      builtin/refs: add "rename" subcommand

 Documentation/git-refs.adoc |  40 +++++++
 builtin/refs.c              | 222 ++++++++++++++++++++++++++++++++++--
 t/meson.build               |   4 +
 t/t1464-refs-delete.sh      | 130 +++++++++++++++++++++
 t/t1465-refs-update.sh      | 268 ++++++++++++++++++++++++++++++++++++++++++++
 t/t1466-refs-create.sh      | 151 +++++++++++++++++++++++++
 t/t1467-refs-rename.sh      | 131 ++++++++++++++++++++++
 7 files changed, 938 insertions(+), 8 deletions(-)

Range-diff versus v1:

1:  cfbc247e81 = 1:  6d0c5bd06f builtin/refs: drop `the_repository`
2:  f5f33e5c5b ! 2:  db55d87116 builtin/refs: add "delete" subcommand
    @@ Documentation/git-refs.adoc: git refs list [--count=<count>] [--shell|--perl|--p
      		   [ --stdin | (<pattern>...)]
      git refs exists <ref>
      git refs optimize [--all] [--no-prune] [--auto] [--include <pattern>] [--exclude <pattern>]
    -+git refs delete [--message=<reason>] [--no-deref] <ref> [<oldvalue>]
    ++git refs delete [--message=<reason>] [--no-deref] <ref> [<old-value>]
      
      DESCRIPTION
      -----------
    @@ Documentation/git-refs.adoc: optimize::
      
     +delete::
     +	Delete the given reference. This subcommand mirrors `git update-ref -d`
    -+	(see linkgit:git-update-ref[1]). When `<oldvalue>` is given, the
    ++	(see linkgit:git-update-ref[1]). When `<old-value>` is given, the
     +	reference is only deleted after verifying that it currently contains
    -+	`<oldvalue>`.
    ++	`<old-value>`.
     +
      OPTIONS
      -------
    @@ builtin/refs.c
      	N_("git refs optimize " PACK_REFS_OPTS)
      
     +#define REFS_DELETE_USAGE \
    -+	N_("git refs delete [--message=<reason>] [--no-deref] <ref> [<oldvalue>]")
    ++	N_("git refs delete [--message=<reason>] [--no-deref] <ref> [<old-value>]")
     +
      static int cmd_refs_migrate(int argc, const char **argv, const char *prefix,
      			    struct repository *repo)
    @@ builtin/refs.c: static int cmd_refs_optimize(int argc, const char **argv, const
     +	};
     +	struct object_id oldoid;
     +	const char *refname;
    ++	int ret;
     +
     +	argc = parse_options(argc, argv, prefix, opts, refs_delete_usage, 0);
     +	if (argc < 1 || argc > 2)
    @@ builtin/refs.c: static int cmd_refs_optimize(int argc, const char **argv, const
     +		if (repo_get_oid_with_flags(repo, argv[1], &oldoid, GET_OID_SKIP_AMBIGUITY_CHECK))
     +			die(_("invalid old object ID: '%s'"), argv[1]);
     +		if (is_null_oid(&oldoid))
    -+			die(_("cannot delete object with null old object ID"));
    ++			die(_("cannot delete reference with null old object ID"));
     +	}
     +
    -+	return refs_delete_ref(get_main_ref_store(repo), message, refname,
    -+			       argc == 2 ? &oldoid : NULL, flags);
    ++	ret = refs_delete_ref(get_main_ref_store(repo), message, refname,
    ++			      argc == 2 ? &oldoid : NULL, flags);
    ++
    ++	if (ret < 0)
    ++		ret = 1;
    ++	return ret;
     +}
     +
      int cmd_refs(int argc,
    @@ t/t1464-refs-delete.sh (new)
     +
     +test_description='git refs delete'
     +
    -+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main
    -+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
    -+
     +. ./test-lib.sh
     +
     +setup_repo () {
3:  1fc1bed619 ! 3:  85f07a2cb0 builtin/refs: add "update" subcommand
    @@ Documentation/git-refs.adoc
     @@ Documentation/git-refs.adoc: git refs list [--count=<count>] [--shell|--perl|--python|--tcl]
      git refs exists <ref>
      git refs optimize [--all] [--no-prune] [--auto] [--include <pattern>] [--exclude <pattern>]
    - git refs delete [--message=<reason>] [--no-deref] <ref> [<oldvalue>]
    + git refs delete [--message=<reason>] [--no-deref] <ref> [<old-value>]
     +git refs update [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value> [<old-value>]
      
      DESCRIPTION
      -----------
     @@ Documentation/git-refs.adoc: delete::
      	reference is only deleted after verifying that it currently contains
    - 	`<oldvalue>`.
    + 	`<old-value>`.
      
     +update::
    -+	Update the given reference to point at `<new-value>`. This subcommand
    -+	mirrors `git update-ref` (see linkgit:git-update-ref[1]). When
    -+	`<old-value>` is given, the reference is only updated after verifying
    -+	that it currently contains `<old-value>`.
    ++	Update the given reference to point at `<new-value>`. If `<old-value>`
    ++	is given, the reference is only updated after verifying that it
    ++	currently contains `<old-value>`. As a special case, an all-zeroes
    ++	`<new-value>` deletes the branch, whereas an all-zeroes `<old-value>`
    ++	ensures that the branch does not yet exist.
     +
      OPTIONS
      -------
      
    +@@ Documentation/git-refs.adoc: include::pack-refs-options.adoc[]
    + 
    + The following options are specific to commands which write references:
    + 
    ++`--create-reflog`::
    ++	Create a reflog for the reference even if one would not ordinarily be
    ++	created.
    ++
    + `--message=<reason>`::
    + 	Use the given <reason> string for the reflog entry associated with the
    + 	update. An empty message is rejected.
     
      ## builtin/refs.c ##
     @@
      #define REFS_DELETE_USAGE \
    - 	N_("git refs delete [--message=<reason>] [--no-deref] <ref> [<oldvalue>]")
    + 	N_("git refs delete [--message=<reason>] [--no-deref] <ref> [<old-value>]")
      
     +#define REFS_UPDATE_USAGE \
     +	N_("git refs update [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value> [<old-value>]")
    @@ builtin/refs.c
      			    struct repository *repo)
      {
     @@ builtin/refs.c: static int cmd_refs_delete(int argc, const char **argv, const char *prefix,
    - 			       argc == 2 ? &oldoid : NULL, flags);
    + 	return ret;
      }
      
     +static int cmd_refs_update(int argc, const char **argv, const char *prefix,
    @@ builtin/refs.c: static int cmd_refs_delete(int argc, const char **argv, const ch
     +	};
     +	struct object_id newoid, oldoid;
     +	const char *refname;
    ++	int ret;
     +
     +	argc = parse_options(argc, argv, prefix, opts, refs_update_usage, 0);
     +	if (argc < 2 || argc > 3)
    @@ builtin/refs.c: static int cmd_refs_delete(int argc, const char **argv, const ch
     +	refname = argv[0];
     +	if (repo_get_oid_with_flags(repo, argv[1], &newoid,
     +				    GET_OID_SKIP_AMBIGUITY_CHECK))
    -+		die(_("invalid new object ID: %s"), argv[1]);
    ++		die(_("invalid new object ID: '%s'"), argv[1]);
     +	if (argc == 3 &&
     +	    repo_get_oid_with_flags(repo, argv[2], &oldoid,
     +				    GET_OID_SKIP_AMBIGUITY_CHECK))
    -+		die(_("invalid old object ID: %s"), argv[2]);
    ++		die(_("invalid old object ID: '%s'"), argv[2]);
     +
    -+	return refs_update_ref(get_main_ref_store(repo), message, refname,
    -+			       &newoid, argc == 3 ? &oldoid : NULL, flags,
    -+			       UPDATE_REFS_DIE_ON_ERR);
    ++	ret = refs_update_ref(get_main_ref_store(repo), message, refname,
    ++			      &newoid, argc == 3 ? &oldoid : NULL, flags,
    ++			      UPDATE_REFS_MSG_ON_ERR);
    ++
    ++	if (ret < 0)
    ++		ret = 1;
    ++	return ret;
     +}
     +
      int cmd_refs(int argc,
    @@ t/t1465-refs-update.sh (new)
     +	)
     +'
     +
    ++test_expect_success 'update can create a new branch with oldvalue' '
    ++	test_when_finished "rm -rf repo" &&
    ++	setup_repo repo &&
    ++	(
    ++		cd repo &&
    ++		A=$(git rev-parse A) &&
    ++		git refs update refs/heads/foo $A $ZERO_OID 2>err &&
    ++		test_ref_matches refs/heads/foo $A
    ++	)
    ++'
    ++
    ++test_expect_success 'update can create a new branch without oldvalue' '
    ++	test_when_finished "rm -rf repo" &&
    ++	setup_repo repo &&
    ++	(
    ++		cd repo &&
    ++		A=$(git rev-parse A) &&
    ++		git refs update refs/heads/foo $A 2>err &&
    ++		test_ref_matches refs/heads/foo $A
    ++	)
    ++'
    ++
    ++test_expect_success 'update refuses to create preexisting branch' '
    ++	test_when_finished "rm -rf repo" &&
    ++	setup_repo repo &&
    ++	(
    ++		cd repo &&
    ++		A=$(git rev-parse A) &&
    ++		B=$(git rev-parse B) &&
    ++		git refs update refs/heads/foo $A &&
    ++		test_must_fail git refs update refs/heads/foo $B $ZERO_OID 2>err &&
    ++		test_grep "reference already exists" err &&
    ++		test_ref_matches refs/heads/foo $A
    ++	)
    ++'
    ++
    ++test_expect_success 'update can delete a branch with oldvalue' '
    ++	test_when_finished "rm -rf repo" &&
    ++	setup_repo repo &&
    ++	(
    ++		cd repo &&
    ++		A=$(git rev-parse A) &&
    ++		git refs update refs/heads/foo $A 2>err &&
    ++		git refs update refs/heads/foo $ZERO_OID $A 2>err &&
    ++		test_must_fail git refs exists refs/heads/foo
    ++	)
    ++'
    ++
    ++test_expect_success 'update can delete a branch without oldvalue' '
    ++	test_when_finished "rm -rf repo" &&
    ++	setup_repo repo &&
    ++	(
    ++		cd repo &&
    ++		A=$(git rev-parse A) &&
    ++		git refs update refs/heads/foo $A 2>err &&
    ++		git refs update refs/heads/foo $ZERO_OID 2>err &&
    ++		test_must_fail git refs exists refs/heads/foo
    ++	)
    ++'
    ++
    ++test_expect_success 'update refuses to delete a branch with mismatching value' '
    ++	test_when_finished "rm -rf repo" &&
    ++	setup_repo repo &&
    ++	(
    ++		cd repo &&
    ++		A=$(git rev-parse A) &&
    ++		B=$(git rev-parse B) &&
    ++		git refs update refs/heads/foo $A 2>err &&
    ++		test_must_fail git refs update refs/heads/foo $ZERO_OID $B 2>err &&
    ++		test_grep " but expected " err &&
    ++		git refs exists refs/heads/foo
    ++	)
    ++'
    ++
    ++test_expect_success 'update refuses to create preexisting branch' '
    ++	test_when_finished "rm -rf repo" &&
    ++	setup_repo repo &&
    ++	(
    ++		cd repo &&
    ++		A=$(git rev-parse A) &&
    ++		B=$(git rev-parse B) &&
    ++		git refs update refs/heads/foo $A &&
    ++		test_must_fail git refs update refs/heads/foo $B $ZERO_OID 2>err &&
    ++		test_grep "reference already exists" err &&
    ++		test_ref_matches refs/heads/foo $A
    ++	)
    ++'
    ++
    ++
     +test_expect_success 'update with invalid new value fails' '
     +	test_when_finished "rm -rf repo" &&
     +	setup_repo repo &&
-:  ---------- > 4:  03036ef730 builtin/refs: add "create" subcommand
4:  aadedb14e1 ! 5:  65f0ee4f03 builtin/refs: add "rename" subcommand
    @@ Commit message
         Signed-off-by: Patrick Steinhardt <ps@pks.im>
     
      ## Documentation/git-refs.adoc ##
    -@@ Documentation/git-refs.adoc: git refs exists <ref>
    - git refs optimize [--all] [--no-prune] [--auto] [--include <pattern>] [--exclude <pattern>]
    - git refs delete [--message=<reason>] [--no-deref] <ref> [<oldvalue>]
    +@@ Documentation/git-refs.adoc: git refs optimize [--all] [--no-prune] [--auto] [--include <pattern>] [--exclude
    + git refs create [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value>
    + git refs delete [--message=<reason>] [--no-deref] <ref> [<old-value>]
      git refs update [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value> [<old-value>]
    -+git refs rename [--message=<reason>] <oldref> <newref>
    ++git refs rename [--message=<reason>] <old-ref> <new-ref>
      
      DESCRIPTION
      -----------
     @@ Documentation/git-refs.adoc: update::
    - 	`<old-value>` is given, the reference is only updated after verifying
    - 	that it currently contains `<old-value>`.
    + 	`<new-value>` deletes the branch, whereas an all-zeroes `<old-value>`
    + 	ensures that the branch does not yet exist.
      
     +rename::
     +	Rename the reference `<oldref>` to `<newref>`. The old reference must
    @@ Documentation/git-refs.adoc: update::
      OPTIONS
      -------
      
    -@@ Documentation/git-refs.adoc: include::pack-refs-options.adoc[]
    - 
    - The following options are specific to commands which write references:
    - 
    -+`--create-reflog`::
    -+	Create a reflog for the reference even if one would not ordinarily be
    -+	created.
    -+
    - `--message=<reason>`::
    - 	Use the given <reason> string for the reflog entry associated with the
    - 	update. An empty message is rejected.
     
      ## builtin/refs.c ##
     @@
    @@ builtin/refs.c
      	N_("git refs update [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value> [<old-value>]")
      
     +#define REFS_RENAME_USAGE \
    -+	N_("git refs rename [--message=<reason>] <oldref> <newref>")
    ++	N_("git refs rename [--message=<reason>] <old-ref> <new-ref>")
     +
      static int cmd_refs_migrate(int argc, const char **argv, const char *prefix,
      			    struct repository *repo)
      {
     @@ builtin/refs.c: static int cmd_refs_update(int argc, const char **argv, const char *prefix,
    - 			       UPDATE_REFS_DIE_ON_ERR);
    + 	return ret;
      }
      
     +static int cmd_refs_rename(int argc, const char **argv, const char *prefix,
    @@ builtin/refs.c: static int cmd_refs_update(int argc, const char **argv, const ch
     +		OPT_END(),
     +	};
     +	const char *oldref, *newref;
    ++	int ret;
     +
     +	argc = parse_options(argc, argv, prefix, opts, refs_rename_usage, 0);
     +	if (argc != 2)
    @@ builtin/refs.c: static int cmd_refs_update(int argc, const char **argv, const ch
     +	if (message && !*message)
     +		die(_("refusing to perform update with empty message"));
     +
    ++	repo_config(repo, git_default_config, NULL);
    ++
     +	oldref = argv[0];
     +	newref = argv[1];
     +
     +	if (check_refname_format(oldref, 0))
    -+		die(_("invalid ref format: %s"), oldref);
    ++		die(_("invalid ref format: '%s'"), oldref);
     +	if (check_refname_format(newref, 0))
    -+		die(_("invalid ref format: %s"), newref);
    ++		die(_("invalid ref format: '%s'"), newref);
     +
     +	if (!refs_ref_exists(get_main_ref_store(repo), oldref))
     +		die(_("reference does not exist: '%s'"), oldref);
     +	if (refs_ref_exists(get_main_ref_store(repo), newref))
     +		die(_("reference already exists: '%s'"), newref);
     +
    -+	return refs_rename_ref(get_main_ref_store(repo), oldref, newref, message);
    ++	ret = refs_rename_ref(get_main_ref_store(repo), oldref, newref, message);
    ++
    ++	if (ret < 0)
    ++		ret = 1;
    ++	return ret;
     +}
     +
      int cmd_refs(int argc,
      	     const char **argv,
      	     const char *prefix,
     @@ builtin/refs.c: int cmd_refs(int argc,
    - 		REFS_OPTIMIZE_USAGE,
    + 		REFS_CREATE_USAGE,
      		REFS_DELETE_USAGE,
      		REFS_UPDATE_USAGE,
     +		REFS_RENAME_USAGE,
    @@ builtin/refs.c: int cmd_refs(int argc,
      	};
      	parse_opt_subcommand_fn *fn = NULL;
     @@ builtin/refs.c: int cmd_refs(int argc,
    - 		OPT_SUBCOMMAND("optimize", &fn, cmd_refs_optimize),
    + 		OPT_SUBCOMMAND("create", &fn, cmd_refs_create),
      		OPT_SUBCOMMAND("delete", &fn, cmd_refs_delete),
      		OPT_SUBCOMMAND("update", &fn, cmd_refs_update),
     +		OPT_SUBCOMMAND("rename", &fn, cmd_refs_rename),
    @@ builtin/refs.c: int cmd_refs(int argc,
     
      ## t/meson.build ##
     @@ t/meson.build: integration_tests = [
    -   't1463-refs-optimize.sh',
        't1464-refs-delete.sh',
        't1465-refs-update.sh',
    -+  't1466-refs-rename.sh',
    +   't1466-refs-create.sh',
    ++  't1467-refs-rename.sh',
        't1500-rev-parse.sh',
        't1501-work-tree.sh',
        't1502-rev-parse-parseopt.sh',
     
    - ## t/t1466-refs-rename.sh (new) ##
    + ## t/t1467-refs-rename.sh (new) ##
     @@
     +#!/bin/sh
     +

---
base-commit: 700432b2ba22603a0bcb71475c9c333d17c9b0d1
change-id: 20260616-pks-refs-writing-subcommands-7a77be5bda9b


^ permalink raw reply	[flat|nested] 20+ messages in thread

* [PATCH v2 1/5] builtin/refs: drop `the_repository`
  2026-06-17 10:15 ` [PATCH v2 0/5] builtin/refs: add ability to write references Patrick Steinhardt
@ 2026-06-17 10:15   ` Patrick Steinhardt
  2026-06-17 10:15   ` [PATCH v2 2/5] builtin/refs: add "delete" subcommand Patrick Steinhardt
                     ` (4 subsequent siblings)
  5 siblings, 0 replies; 20+ messages in thread
From: Patrick Steinhardt @ 2026-06-17 10:15 UTC (permalink / raw)
  To: git; +Cc: Junio C Hamano

We still have a couple of uses of `the_repository` in "builtin/refs.c".
All of those are trivial to convert though as the command always
requires a repository to exist.

Convert them to use the passed-in repository and drop
`USE_THE_REPOSITORY_VARIABLE`.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 builtin/refs.c | 15 +++++++--------
 1 file changed, 7 insertions(+), 8 deletions(-)

diff --git a/builtin/refs.c b/builtin/refs.c
index e3125bc61b..f0faabf45a 100644
--- a/builtin/refs.c
+++ b/builtin/refs.c
@@ -1,4 +1,3 @@
-#define USE_THE_REPOSITORY_VARIABLE
 #include "builtin.h"
 #include "config.h"
 #include "fsck.h"
@@ -23,7 +22,7 @@
 	N_("git refs optimize " PACK_REFS_OPTS)
 
 static int cmd_refs_migrate(int argc, const char **argv, const char *prefix,
-			    struct repository *repo UNUSED)
+			    struct repository *repo)
 {
 	const char * const migrate_usage[] = {
 		REFS_MIGRATE_USAGE,
@@ -59,13 +58,13 @@ static int cmd_refs_migrate(int argc, const char **argv, const char *prefix,
 		goto out;
 	}
 
-	if (the_repository->ref_storage_format == format) {
+	if (repo->ref_storage_format == format) {
 		err = error(_("repository already uses '%s' format"),
 			    ref_storage_format_to_name(format));
 		goto out;
 	}
 
-	if (repo_migrate_ref_storage_format(the_repository, format, flags, &errbuf) < 0) {
+	if (repo_migrate_ref_storage_format(repo, format, flags, &errbuf) < 0) {
 		err = error("%s", errbuf.buf);
 		goto out;
 	}
@@ -99,8 +98,8 @@ static int cmd_refs_verify(int argc, const char **argv, const char *prefix,
 	if (argc)
 		usage(_("'git refs verify' takes no arguments"));
 
-	repo_config(the_repository, git_fsck_config, &fsck_refs_options);
-	prepare_repo_settings(the_repository);
+	repo_config(repo, git_fsck_config, &fsck_refs_options);
+	prepare_repo_settings(repo);
 
 	worktrees = get_worktrees_without_reading_head();
 	for (size_t i = 0; worktrees[i]; i++)
@@ -124,7 +123,7 @@ static int cmd_refs_list(int argc, const char **argv, const char *prefix,
 }
 
 static int cmd_refs_exists(int argc, const char **argv, const char *prefix,
-			   struct repository *repo UNUSED)
+			   struct repository *repo)
 {
 	struct strbuf unused_referent = STRBUF_INIT;
 	struct object_id unused_oid;
@@ -145,7 +144,7 @@ static int cmd_refs_exists(int argc, const char **argv, const char *prefix,
 		die(_("'git refs exists' requires a reference"));
 
 	ref = *argv++;
-	if (refs_read_raw_ref(get_main_ref_store(the_repository), ref,
+	if (refs_read_raw_ref(get_main_ref_store(repo), ref,
 			      &unused_oid, &unused_referent, &unused_type,
 			      &failure_errno)) {
 		if (failure_errno == ENOENT || failure_errno == EISDIR) {

-- 
2.55.0.rc0.786.g65d90a0328.dirty


^ permalink raw reply related	[flat|nested] 20+ messages in thread

* [PATCH v2 2/5] builtin/refs: add "delete" subcommand
  2026-06-17 10:15 ` [PATCH v2 0/5] builtin/refs: add ability to write references Patrick Steinhardt
  2026-06-17 10:15   ` [PATCH v2 1/5] builtin/refs: drop `the_repository` Patrick Steinhardt
@ 2026-06-17 10:15   ` Patrick Steinhardt
  2026-06-17 10:16   ` [PATCH v2 3/5] builtin/refs: add "update" subcommand Patrick Steinhardt
                     ` (3 subsequent siblings)
  5 siblings, 0 replies; 20+ messages in thread
From: Patrick Steinhardt @ 2026-06-17 10:15 UTC (permalink / raw)
  To: git; +Cc: Junio C Hamano

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>
---
 Documentation/git-refs.adoc |  17 ++++++
 builtin/refs.c              |  51 +++++++++++++++++
 t/meson.build               |   1 +
 t/t1464-refs-delete.sh      | 130 ++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 199 insertions(+)

diff --git a/Documentation/git-refs.adoc b/Documentation/git-refs.adoc
index fa33680cc7..2633934463 100644
--- a/Documentation/git-refs.adoc
+++ b/Documentation/git-refs.adoc
@@ -20,6 +20,7 @@ git refs list [--count=<count>] [--shell|--perl|--python|--tcl]
 		   [ --stdin | (<pattern>...)]
 git refs exists <ref>
 git refs optimize [--all] [--no-prune] [--auto] [--include <pattern>] [--exclude <pattern>]
+git refs delete [--message=<reason>] [--no-deref] <ref> [<old-value>]
 
 DESCRIPTION
 -----------
@@ -51,6 +52,12 @@ optimize::
 	usage. This subcommand is an alias for linkgit:git-pack-refs[1] and
 	offers identical functionality.
 
+delete::
+	Delete the given reference. This subcommand mirrors `git update-ref -d`
+	(see linkgit:git-update-ref[1]). When `<old-value>` is given, the
+	reference is only deleted after verifying that it currently contains
+	`<old-value>`.
+
 OPTIONS
 -------
 
@@ -90,6 +97,16 @@ The following options are specific to 'git refs optimize':
 
 include::pack-refs-options.adoc[]
 
+The following options are specific to commands which write references:
+
+`--message=<reason>`::
+	Use the given <reason> string for the reflog entry associated with the
+	update. An empty message is rejected.
+
+`--no-deref`::
+	Operate on <ref> itself rather than the reference it points to via a
+	symbolic ref.
+
 KNOWN LIMITATIONS
 -----------------
 
diff --git a/builtin/refs.c b/builtin/refs.c
index f0faabf45a..edb7d61663 100644
--- a/builtin/refs.c
+++ b/builtin/refs.c
@@ -21,6 +21,9 @@
 #define REFS_OPTIMIZE_USAGE \
 	N_("git refs optimize " PACK_REFS_OPTS)
 
+#define REFS_DELETE_USAGE \
+	N_("git refs delete [--message=<reason>] [--no-deref] <ref> [<old-value>]")
+
 static int cmd_refs_migrate(int argc, const char **argv, const char *prefix,
 			    struct repository *repo)
 {
@@ -175,6 +178,52 @@ static int cmd_refs_optimize(int argc, const char **argv, const char *prefix,
 	return pack_refs_core(argc, argv, prefix, repo, refs_optimize_usage);
 }
 
+static int cmd_refs_delete(int argc, const char **argv, const char *prefix,
+			   struct repository *repo)
+{
+	static char const * const refs_delete_usage[] = {
+		REFS_DELETE_USAGE,
+		NULL
+	};
+	const char *message = NULL;
+	unsigned flags = 0;
+	struct option opts[] = {
+		OPT_STRING(0, "message", &message, N_("reason"),
+			   N_("reason of the update")),
+		OPT_BIT(0 ,"no-deref", &flags,
+			N_("update <refname> not the one it points to"),
+			REF_NO_DEREF),
+		OPT_END(),
+	};
+	struct object_id oldoid;
+	const char *refname;
+	int ret;
+
+	argc = parse_options(argc, argv, prefix, opts, refs_delete_usage, 0);
+	if (argc < 1 || argc > 2)
+		usage(_("delete requires reference name and an optional old object ID"));
+
+	if (message && !*message)
+		die(_("refusing to perform update with empty message"));
+
+	repo_config(repo, git_default_config, NULL);
+
+	refname = argv[0];
+	if (argc == 2) {
+		if (repo_get_oid_with_flags(repo, argv[1], &oldoid, GET_OID_SKIP_AMBIGUITY_CHECK))
+			die(_("invalid old object ID: '%s'"), argv[1]);
+		if (is_null_oid(&oldoid))
+			die(_("cannot delete reference with null old object ID"));
+	}
+
+	ret = refs_delete_ref(get_main_ref_store(repo), message, refname,
+			      argc == 2 ? &oldoid : NULL, flags);
+
+	if (ret < 0)
+		ret = 1;
+	return ret;
+}
+
 int cmd_refs(int argc,
 	     const char **argv,
 	     const char *prefix,
@@ -186,6 +235,7 @@ int cmd_refs(int argc,
 		"git refs list " COMMON_USAGE_FOR_EACH_REF,
 		REFS_EXISTS_USAGE,
 		REFS_OPTIMIZE_USAGE,
+		REFS_DELETE_USAGE,
 		NULL,
 	};
 	parse_opt_subcommand_fn *fn = NULL;
@@ -195,6 +245,7 @@ int cmd_refs(int argc,
 		OPT_SUBCOMMAND("list", &fn, cmd_refs_list),
 		OPT_SUBCOMMAND("exists", &fn, cmd_refs_exists),
 		OPT_SUBCOMMAND("optimize", &fn, cmd_refs_optimize),
+		OPT_SUBCOMMAND("delete", &fn, cmd_refs_delete),
 		OPT_END(),
 	};
 
diff --git a/t/meson.build b/t/meson.build
index c5832fee05..1ccf08a3b5 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -223,6 +223,7 @@ integration_tests = [
   't1461-refs-list.sh',
   't1462-refs-exists.sh',
   't1463-refs-optimize.sh',
+  't1464-refs-delete.sh',
   't1500-rev-parse.sh',
   't1501-work-tree.sh',
   't1502-rev-parse-parseopt.sh',
diff --git a/t/t1464-refs-delete.sh b/t/t1464-refs-delete.sh
new file mode 100755
index 0000000000..efff7d0574
--- /dev/null
+++ b/t/t1464-refs-delete.sh
@@ -0,0 +1,130 @@
+#!/bin/sh
+
+test_description='git refs delete'
+
+. ./test-lib.sh
+
+setup_repo () {
+	git init "$1" &&
+	test_commit -C "$1" A &&
+	test_commit -C "$1" B
+}
+
+test_expect_success 'delete without oldvalue verification' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	A=$(git -C repo rev-parse A) &&
+	git -C repo update-ref refs/heads/foo $A &&
+	git -C repo refs delete refs/heads/foo &&
+	test_must_fail git -C repo show-ref --verify -q refs/heads/foo
+'
+
+test_expect_success 'delete with matching oldvalue' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git update-ref refs/heads/foo $A &&
+		git refs delete refs/heads/foo $A &&
+		test_must_fail git refs exists refs/heads/foo
+	)
+'
+
+test_expect_success 'delete with stale oldvalue fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git update-ref refs/heads/foo $A &&
+		test_must_fail git refs delete refs/heads/foo $B 2>err &&
+		test_grep " but expected " err &&
+		git refs exists refs/heads/foo
+	)
+'
+
+test_expect_success 'delete with null oldvalue fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git update-ref refs/heads/foo $A &&
+		test_must_fail git refs delete refs/heads/foo $ZERO_OID 2>err &&
+		test_grep "null old object ID" err &&
+		git refs exists refs/heads/foo
+	)
+'
+
+test_expect_success 'delete with invalid oldvalue fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git update-ref refs/heads/foo $A &&
+		test_must_fail git refs delete refs/heads/foo invalid-oid 2>err &&
+		test_grep "invalid old object ID" err &&
+		git refs exists refs/heads/foo
+	)
+'
+
+test_expect_success 'delete symref with --no-deref leaves target intact' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git update-ref refs/heads/foo $A &&
+		git symbolic-ref refs/heads/symref refs/heads/foo &&
+		git refs delete --no-deref refs/heads/symref &&
+		test_must_fail git refs exists refs/heads/symref &&
+		git refs exists refs/heads/foo
+	)
+'
+
+test_expect_success 'delete with message records reason in reflog' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git update-ref refs/heads/foo $A &&
+		git symbolic-ref HEAD refs/heads/foo &&
+		git refs delete --message=delete-reason refs/heads/foo &&
+		test_must_fail git refs exists refs/heads/foo &&
+		test-tool ref-store main for-each-reflog-ent HEAD >actual &&
+		test_grep "delete-reason$" actual
+	)
+'
+
+test_expect_success 'delete with empty message fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git update-ref refs/heads/foo $A &&
+		test_must_fail git refs delete --message= refs/heads/foo 2>err &&
+		test_grep "empty message" err &&
+		git refs exists refs/heads/foo
+	)
+'
+
+test_expect_success 'delete without arguments fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	test_must_fail git -C repo refs delete 2>err &&
+	test_grep "requires reference name" err
+'
+
+test_expect_success 'delete with too many arguments fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	test_must_fail git refs delete one two three 2>err &&
+	test_grep "requires reference name" err
+'
+
+test_done

-- 
2.55.0.rc0.786.g65d90a0328.dirty


^ permalink raw reply related	[flat|nested] 20+ messages in thread

* [PATCH v2 3/5] builtin/refs: add "update" subcommand
  2026-06-17 10:15 ` [PATCH v2 0/5] builtin/refs: add ability to write references Patrick Steinhardt
  2026-06-17 10:15   ` [PATCH v2 1/5] builtin/refs: drop `the_repository` Patrick Steinhardt
  2026-06-17 10:15   ` [PATCH v2 2/5] builtin/refs: add "delete" subcommand Patrick Steinhardt
@ 2026-06-17 10:16   ` Patrick Steinhardt
  2026-06-17 10:16   ` [PATCH v2 4/5] builtin/refs: add "create" subcommand Patrick Steinhardt
                     ` (2 subsequent siblings)
  5 siblings, 0 replies; 20+ messages in thread
From: Patrick Steinhardt @ 2026-06-17 10:16 UTC (permalink / raw)
  To: git; +Cc: Junio C Hamano

Add a new "update" subcommand which mirrors `git update-ref <refname>
<oldoid> <newoid>`. This follows the same reasoning as the preceding
commit.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-refs.adoc |  12 ++
 builtin/refs.c              |  55 +++++++++
 t/meson.build               |   1 +
 t/t1465-refs-update.sh      | 268 ++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 336 insertions(+)

diff --git a/Documentation/git-refs.adoc b/Documentation/git-refs.adoc
index 2633934463..6475bdcc62 100644
--- a/Documentation/git-refs.adoc
+++ b/Documentation/git-refs.adoc
@@ -21,6 +21,7 @@ git refs list [--count=<count>] [--shell|--perl|--python|--tcl]
 git refs exists <ref>
 git refs optimize [--all] [--no-prune] [--auto] [--include <pattern>] [--exclude <pattern>]
 git refs delete [--message=<reason>] [--no-deref] <ref> [<old-value>]
+git refs update [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value> [<old-value>]
 
 DESCRIPTION
 -----------
@@ -58,6 +59,13 @@ delete::
 	reference is only deleted after verifying that it currently contains
 	`<old-value>`.
 
+update::
+	Update the given reference to point at `<new-value>`. If `<old-value>`
+	is given, the reference is only updated after verifying that it
+	currently contains `<old-value>`. As a special case, an all-zeroes
+	`<new-value>` deletes the branch, whereas an all-zeroes `<old-value>`
+	ensures that the branch does not yet exist.
+
 OPTIONS
 -------
 
@@ -99,6 +107,10 @@ include::pack-refs-options.adoc[]
 
 The following options are specific to commands which write references:
 
+`--create-reflog`::
+	Create a reflog for the reference even if one would not ordinarily be
+	created.
+
 `--message=<reason>`::
 	Use the given <reason> string for the reflog entry associated with the
 	update. An empty message is rejected.
diff --git a/builtin/refs.c b/builtin/refs.c
index edb7d61663..08453ae1c8 100644
--- a/builtin/refs.c
+++ b/builtin/refs.c
@@ -24,6 +24,9 @@
 #define REFS_DELETE_USAGE \
 	N_("git refs delete [--message=<reason>] [--no-deref] <ref> [<old-value>]")
 
+#define REFS_UPDATE_USAGE \
+	N_("git refs update [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value> [<old-value>]")
+
 static int cmd_refs_migrate(int argc, const char **argv, const char *prefix,
 			    struct repository *repo)
 {
@@ -224,6 +227,56 @@ static int cmd_refs_delete(int argc, const char **argv, const char *prefix,
 	return ret;
 }
 
+static int cmd_refs_update(int argc, const char **argv, const char *prefix,
+			   struct repository *repo)
+{
+	static char const * const refs_update_usage[] = {
+		REFS_UPDATE_USAGE,
+		NULL
+	};
+	const char *message = NULL;
+	unsigned flags = 0;
+	struct option opts[] = {
+		OPT_STRING(0, "message", &message, N_("reason"),
+			   N_("reason of the update")),
+		OPT_BIT(0 ,"no-deref", &flags,
+			N_("update <refname> not the one it points to"),
+			REF_NO_DEREF),
+		OPT_BIT(0, "create-reflog", &flags, N_("create a reflog"),
+			REF_FORCE_CREATE_REFLOG),
+		OPT_END(),
+	};
+	struct object_id newoid, oldoid;
+	const char *refname;
+	int ret;
+
+	argc = parse_options(argc, argv, prefix, opts, refs_update_usage, 0);
+	if (argc < 2 || argc > 3)
+		usage(_("update requires reference name, new value and an optional old value"));
+
+	if (message && !*message)
+		die(_("refusing to perform update with empty message"));
+
+	repo_config(repo, git_default_config, NULL);
+
+	refname = argv[0];
+	if (repo_get_oid_with_flags(repo, argv[1], &newoid,
+				    GET_OID_SKIP_AMBIGUITY_CHECK))
+		die(_("invalid new object ID: '%s'"), argv[1]);
+	if (argc == 3 &&
+	    repo_get_oid_with_flags(repo, argv[2], &oldoid,
+				    GET_OID_SKIP_AMBIGUITY_CHECK))
+		die(_("invalid old object ID: '%s'"), argv[2]);
+
+	ret = refs_update_ref(get_main_ref_store(repo), message, refname,
+			      &newoid, argc == 3 ? &oldoid : NULL, flags,
+			      UPDATE_REFS_MSG_ON_ERR);
+
+	if (ret < 0)
+		ret = 1;
+	return ret;
+}
+
 int cmd_refs(int argc,
 	     const char **argv,
 	     const char *prefix,
@@ -236,6 +289,7 @@ int cmd_refs(int argc,
 		REFS_EXISTS_USAGE,
 		REFS_OPTIMIZE_USAGE,
 		REFS_DELETE_USAGE,
+		REFS_UPDATE_USAGE,
 		NULL,
 	};
 	parse_opt_subcommand_fn *fn = NULL;
@@ -246,6 +300,7 @@ int cmd_refs(int argc,
 		OPT_SUBCOMMAND("exists", &fn, cmd_refs_exists),
 		OPT_SUBCOMMAND("optimize", &fn, cmd_refs_optimize),
 		OPT_SUBCOMMAND("delete", &fn, cmd_refs_delete),
+		OPT_SUBCOMMAND("update", &fn, cmd_refs_update),
 		OPT_END(),
 	};
 
diff --git a/t/meson.build b/t/meson.build
index 1ccf08a3b5..2063962dab 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -224,6 +224,7 @@ integration_tests = [
   't1462-refs-exists.sh',
   't1463-refs-optimize.sh',
   't1464-refs-delete.sh',
+  't1465-refs-update.sh',
   't1500-rev-parse.sh',
   't1501-work-tree.sh',
   't1502-rev-parse-parseopt.sh',
diff --git a/t/t1465-refs-update.sh b/t/t1465-refs-update.sh
new file mode 100755
index 0000000000..a9becdda99
--- /dev/null
+++ b/t/t1465-refs-update.sh
@@ -0,0 +1,268 @@
+#!/bin/sh
+
+test_description='git refs update'
+
+. ./test-lib.sh
+
+setup_repo () {
+	git init "$1" &&
+	test_commit -C "$1" A &&
+	test_commit -C "$1" B
+}
+
+test_ref_matches () {
+	git rev-parse "$1" >expect &&
+	echo "$2" >actual &&
+	test_cmp expect actual
+}
+
+test_expect_success 'update creates a new reference' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update refs/heads/foo $A &&
+		test_ref_matches refs/heads/foo "$A"
+	)
+'
+
+test_expect_success 'update an existing reference without oldvalue' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		git refs update refs/heads/foo $B &&
+		test_ref_matches refs/heads/foo $B
+	)
+'
+
+test_expect_success 'update with matching oldvalue' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		git refs update refs/heads/foo $B $A &&
+		test_ref_matches refs/heads/foo $B
+	)
+'
+
+test_expect_success 'update with stale oldvalue fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		test_must_fail git refs update refs/heads/foo $B $B 2>err &&
+		test_grep " but expected " err &&
+		test_ref_matches refs/heads/foo $A
+	)
+'
+
+test_expect_success 'update can create a new branch with oldvalue' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update refs/heads/foo $A $ZERO_OID 2>err &&
+		test_ref_matches refs/heads/foo $A
+	)
+'
+
+test_expect_success 'update can create a new branch without oldvalue' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update refs/heads/foo $A 2>err &&
+		test_ref_matches refs/heads/foo $A
+	)
+'
+
+test_expect_success 'update refuses to create preexisting branch' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		test_must_fail git refs update refs/heads/foo $B $ZERO_OID 2>err &&
+		test_grep "reference already exists" err &&
+		test_ref_matches refs/heads/foo $A
+	)
+'
+
+test_expect_success 'update can delete a branch with oldvalue' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update refs/heads/foo $A 2>err &&
+		git refs update refs/heads/foo $ZERO_OID $A 2>err &&
+		test_must_fail git refs exists refs/heads/foo
+	)
+'
+
+test_expect_success 'update can delete a branch without oldvalue' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update refs/heads/foo $A 2>err &&
+		git refs update refs/heads/foo $ZERO_OID 2>err &&
+		test_must_fail git refs exists refs/heads/foo
+	)
+'
+
+test_expect_success 'update refuses to delete a branch with mismatching value' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A 2>err &&
+		test_must_fail git refs update refs/heads/foo $ZERO_OID $B 2>err &&
+		test_grep " but expected " err &&
+		git refs exists refs/heads/foo
+	)
+'
+
+test_expect_success 'update refuses to create preexisting branch' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		test_must_fail git refs update refs/heads/foo $B $ZERO_OID 2>err &&
+		test_grep "reference already exists" err &&
+		test_ref_matches refs/heads/foo $A
+	)
+'
+
+
+test_expect_success 'update with invalid new value fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		test_must_fail git refs update refs/heads/foo invalid-oid 2>err &&
+		test_grep "invalid new object ID" err &&
+		test_must_fail git refs exists refs/heads/foo
+	)
+'
+
+test_expect_success 'update with invalid old value fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		test_must_fail git refs update refs/heads/foo $B invalid-oid 2>err &&
+		test_grep "invalid old object ID" err &&
+		test_ref_matches refs/heads/foo $A
+	)
+'
+
+test_expect_success 'update --no-deref rewrites the symref itself' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		git symbolic-ref refs/heads/symref refs/heads/foo &&
+		git refs update --no-deref refs/heads/symref $B &&
+		test_must_fail git symbolic-ref refs/heads/symref &&
+		test_ref_matches refs/heads/symref $B &&
+		test_ref_matches refs/heads/foo $A
+	)
+'
+
+test_expect_success 'update does not create a reflog by default' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update refs/foo $A &&
+		test_must_fail git reflog exists refs/foo
+	)
+'
+
+test_expect_success 'update creates a reflog with --create-reflog' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update --create-reflog refs/foo $A &&
+		git reflog exists refs/foo
+	)
+'
+
+test_expect_success 'update with message records reason in reflog' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		git refs update --message=update-reason refs/heads/foo $B &&
+		git reflog show refs/heads/foo >actual &&
+		test_grep "update-reason$" actual
+	)
+'
+
+test_expect_success 'update with empty message fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		test_must_fail git refs update --message= refs/heads/foo $B 2>err &&
+		test_grep "empty message" err
+	)
+'
+
+test_expect_success 'update with too few arguments fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	test_must_fail git -C repo refs update refs/heads/foo 2>err &&
+	test_grep "requires reference name, new value" err
+'
+
+test_expect_success 'update with too many arguments fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		test_must_fail git refs update refs/heads/foo $A $B extra 2>err &&
+		test_grep "requires reference name, new value" err
+	)
+'
+
+test_done

-- 
2.55.0.rc0.786.g65d90a0328.dirty


^ permalink raw reply related	[flat|nested] 20+ messages in thread

* [PATCH v2 4/5] builtin/refs: add "create" subcommand
  2026-06-17 10:15 ` [PATCH v2 0/5] builtin/refs: add ability to write references Patrick Steinhardt
                     ` (2 preceding siblings ...)
  2026-06-17 10:16   ` [PATCH v2 3/5] builtin/refs: add "update" subcommand Patrick Steinhardt
@ 2026-06-17 10:16   ` Patrick Steinhardt
  2026-06-17 10:16   ` [PATCH v2 5/5] builtin/refs: add "rename" subcommand Patrick Steinhardt
  2026-06-17 12:26   ` [PATCH v2 0/5] builtin/refs: add ability to write references Junio C Hamano
  5 siblings, 0 replies; 20+ messages in thread
From: Patrick Steinhardt @ 2026-06-17 10:16 UTC (permalink / raw)
  To: git; +Cc: Junio C Hamano

The "update" subcommand cannot only update an existing reference, but it
can also create new branches and delete existing branches by specifying
the all-zeroes object ID as either old or new value. Despite that, we
already have the "delete" subcommand as a handy shortcut so that a user
can easily delete a branch. This relieves them of needing to understand
the more arcane uses of the "update" command, and of counting the number
of zeroes they need to pass.

But while we have a "delete" subcommand, we don't have an equivalent
that would allow the user to create a new branch, which creates a
certain asymmetry.

Add a new "create" subcommand to plug this gap.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-refs.adoc |   5 ++
 builtin/refs.c              |  52 +++++++++++++++
 t/meson.build               |   1 +
 t/t1466-refs-create.sh      | 151 ++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 209 insertions(+)

diff --git a/Documentation/git-refs.adoc b/Documentation/git-refs.adoc
index 6475bdcc62..e6a3528349 100644
--- a/Documentation/git-refs.adoc
+++ b/Documentation/git-refs.adoc
@@ -20,6 +20,7 @@ git refs list [--count=<count>] [--shell|--perl|--python|--tcl]
 		   [ --stdin | (<pattern>...)]
 git refs exists <ref>
 git refs optimize [--all] [--no-prune] [--auto] [--include <pattern>] [--exclude <pattern>]
+git refs create [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value>
 git refs delete [--message=<reason>] [--no-deref] <ref> [<old-value>]
 git refs update [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value> [<old-value>]
 
@@ -53,6 +54,10 @@ optimize::
 	usage. This subcommand is an alias for linkgit:git-pack-refs[1] and
 	offers identical functionality.
 
+create::
+	Create the given reference, which must not already exist, pointing at
+	`<new-value>`.
+
 delete::
 	Delete the given reference. This subcommand mirrors `git update-ref -d`
 	(see linkgit:git-update-ref[1]). When `<old-value>` is given, the
diff --git a/builtin/refs.c b/builtin/refs.c
index 08453ae1c8..92e62fd5df 100644
--- a/builtin/refs.c
+++ b/builtin/refs.c
@@ -21,6 +21,9 @@
 #define REFS_OPTIMIZE_USAGE \
 	N_("git refs optimize " PACK_REFS_OPTS)
 
+#define REFS_CREATE_USAGE \
+	N_("git refs create [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value>")
+
 #define REFS_DELETE_USAGE \
 	N_("git refs delete [--message=<reason>] [--no-deref] <ref> [<old-value>]")
 
@@ -181,6 +184,53 @@ static int cmd_refs_optimize(int argc, const char **argv, const char *prefix,
 	return pack_refs_core(argc, argv, prefix, repo, refs_optimize_usage);
 }
 
+static int cmd_refs_create(int argc, const char **argv, const char *prefix,
+			   struct repository *repo)
+{
+	static char const * const refs_create_usage[] = {
+		REFS_CREATE_USAGE,
+		NULL
+	};
+	const char *message = NULL;
+	unsigned flags = 0;
+	struct option opts[] = {
+		OPT_STRING(0, "message", &message, N_("reason"),
+			   N_("reason of the update")),
+		OPT_BIT(0 ,"no-deref", &flags,
+			N_("update <refname> not the one it points to"),
+			REF_NO_DEREF),
+		OPT_BIT(0, "create-reflog", &flags, N_("create a reflog"),
+			REF_FORCE_CREATE_REFLOG),
+		OPT_END(),
+	};
+	struct object_id newoid;
+	const char *refname;
+	int ret;
+
+	argc = parse_options(argc, argv, prefix, opts, refs_create_usage, 0);
+	if (argc != 2)
+		usage(_("create requires reference name and an object ID"));
+
+	if (message && !*message)
+		die(_("refusing to perform update with empty message"));
+
+	repo_config(repo, git_default_config, NULL);
+
+	refname = argv[0];
+	if (repo_get_oid_with_flags(repo, argv[1], &newoid, GET_OID_SKIP_AMBIGUITY_CHECK))
+		die(_("invalid object ID: '%s'"), argv[1]);
+	if (is_null_oid(&newoid))
+		die(_("cannot create reference with null old object ID"));
+
+	ret = refs_update_ref(get_main_ref_store(repo), message, refname,
+			      &newoid, null_oid(repo->hash_algo), flags,
+			      UPDATE_REFS_MSG_ON_ERR);
+
+	if (ret < 0)
+		ret = 1;
+	return ret;
+}
+
 static int cmd_refs_delete(int argc, const char **argv, const char *prefix,
 			   struct repository *repo)
 {
@@ -288,6 +338,7 @@ int cmd_refs(int argc,
 		"git refs list " COMMON_USAGE_FOR_EACH_REF,
 		REFS_EXISTS_USAGE,
 		REFS_OPTIMIZE_USAGE,
+		REFS_CREATE_USAGE,
 		REFS_DELETE_USAGE,
 		REFS_UPDATE_USAGE,
 		NULL,
@@ -299,6 +350,7 @@ int cmd_refs(int argc,
 		OPT_SUBCOMMAND("list", &fn, cmd_refs_list),
 		OPT_SUBCOMMAND("exists", &fn, cmd_refs_exists),
 		OPT_SUBCOMMAND("optimize", &fn, cmd_refs_optimize),
+		OPT_SUBCOMMAND("create", &fn, cmd_refs_create),
 		OPT_SUBCOMMAND("delete", &fn, cmd_refs_delete),
 		OPT_SUBCOMMAND("update", &fn, cmd_refs_update),
 		OPT_END(),
diff --git a/t/meson.build b/t/meson.build
index 2063962dab..541e6f919c 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -225,6 +225,7 @@ integration_tests = [
   't1463-refs-optimize.sh',
   't1464-refs-delete.sh',
   't1465-refs-update.sh',
+  't1466-refs-create.sh',
   't1500-rev-parse.sh',
   't1501-work-tree.sh',
   't1502-rev-parse-parseopt.sh',
diff --git a/t/t1466-refs-create.sh b/t/t1466-refs-create.sh
new file mode 100755
index 0000000000..85c8bd6ea2
--- /dev/null
+++ b/t/t1466-refs-create.sh
@@ -0,0 +1,151 @@
+#!/bin/sh
+
+test_description='git refs create'
+
+. ./test-lib.sh
+
+setup_repo () {
+	git init "$1" &&
+	test_commit -C "$1" A &&
+	test_commit -C "$1" B
+}
+
+test_ref_matches () {
+	git rev-parse "$1" >expect &&
+	echo "$2" >actual &&
+	test_cmp expect actual
+}
+
+test_expect_success 'create a new reference' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs create refs/heads/foo $A &&
+		test_ref_matches refs/heads/foo "$A"
+	)
+'
+
+test_expect_success 'create fails when the reference already exists' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs create refs/heads/foo $A &&
+		test_must_fail git refs create refs/heads/foo $B 2>err &&
+		test_grep "reference already exists" err &&
+		test_ref_matches refs/heads/foo "$A"
+	)
+'
+
+test_expect_success 'create with null new value fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		test_must_fail git refs create refs/heads/foo $ZERO_OID 2>err &&
+		test_grep "null old object ID" err &&
+		test_must_fail git refs exists refs/heads/foo
+	)
+'
+
+test_expect_success 'create with invalid new value fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		test_must_fail git refs create refs/heads/foo invalid-oid 2>err &&
+		test_grep "invalid object ID" err &&
+		test_must_fail git refs exists refs/heads/foo
+	)
+'
+
+test_expect_success 'create does not create a reflog by default' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs create refs/foo $A &&
+		test_must_fail git reflog exists refs/foo
+	)
+'
+
+test_expect_success 'create creates a reflog with --create-reflog' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs create --create-reflog refs/foo $A &&
+		git reflog exists refs/foo
+	)
+'
+
+test_expect_success 'create with message records reason in reflog' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs create --message="create reason" refs/heads/foo $A &&
+		git reflog show refs/heads/foo >actual &&
+		test_grep "create reason$" actual
+	)
+'
+
+test_expect_success 'create with symref target creates target reference' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git symbolic-ref refs/heads/symref refs/heads/target &&
+		git refs create refs/heads/symref $A &&
+		git reflog exists refs/heads/target
+	)
+'
+
+test_expect_success 'create with symref target and --no-deref refuses to create reference' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git symbolic-ref refs/heads/symref refs/heads/target &&
+		test_must_fail git refs create --no-deref refs/heads/symref $A 2>err &&
+		test_grep "dangling symref already exists" err &&
+		test_must_fail git reflog exists refs/heads/target
+	)
+'
+
+test_expect_success 'create with empty message fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		test_must_fail git refs create --message= refs/heads/foo $A 2>err &&
+		test_grep "empty message" err &&
+		test_must_fail git refs exists refs/heads/foo
+	)
+'
+
+test_expect_success 'create without arguments fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	test_must_fail git -C repo refs create 2>err &&
+	test_grep "requires reference name" err
+'
+
+test_expect_success 'create with too many arguments fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	test_must_fail git -C repo refs create refs/heads/foo a b 2>err &&
+	test_grep "requires reference name" err
+'
+
+test_done

-- 
2.55.0.rc0.786.g65d90a0328.dirty


^ permalink raw reply related	[flat|nested] 20+ messages in thread

* [PATCH v2 5/5] builtin/refs: add "rename" subcommand
  2026-06-17 10:15 ` [PATCH v2 0/5] builtin/refs: add ability to write references Patrick Steinhardt
                     ` (3 preceding siblings ...)
  2026-06-17 10:16   ` [PATCH v2 4/5] builtin/refs: add "create" subcommand Patrick Steinhardt
@ 2026-06-17 10:16   ` Patrick Steinhardt
  2026-06-17 12:26   ` [PATCH v2 0/5] builtin/refs: add ability to write references Junio C Hamano
  5 siblings, 0 replies; 20+ messages in thread
From: Patrick Steinhardt @ 2026-06-17 10:16 UTC (permalink / raw)
  To: git; +Cc: Junio C Hamano

Add a "rename" subcommand to git-refs(1) with the syntax:

  $ git refs rename <oldref> <newref>

It renames <oldref> together with its reflog to <newref>; even when used
on a local branch ref, the current value and the reflog of the ref are
the only things that are renamed. Document it and redirect casual users
to "git branch -m" if that is what they wanted to do.

Co-authored-by: Junio C Hamano <gitster@pobox.com>
Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 Documentation/git-refs.adoc |   6 ++
 builtin/refs.c              |  49 +++++++++++++++++
 t/meson.build               |   1 +
 t/t1467-refs-rename.sh      | 131 ++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 187 insertions(+)

diff --git a/Documentation/git-refs.adoc b/Documentation/git-refs.adoc
index e6a3528349..ce278c59bf 100644
--- a/Documentation/git-refs.adoc
+++ b/Documentation/git-refs.adoc
@@ -23,6 +23,7 @@ git refs optimize [--all] [--no-prune] [--auto] [--include <pattern>] [--exclude
 git refs create [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value>
 git refs delete [--message=<reason>] [--no-deref] <ref> [<old-value>]
 git refs update [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value> [<old-value>]
+git refs rename [--message=<reason>] <old-ref> <new-ref>
 
 DESCRIPTION
 -----------
@@ -71,6 +72,11 @@ update::
 	`<new-value>` deletes the branch, whereas an all-zeroes `<old-value>`
 	ensures that the branch does not yet exist.
 
+rename::
+	Rename the reference `<oldref>` to `<newref>`. The old reference must
+	exist and the new reference must not yet exist, and both must have a
+	well-formed name (see linkgit:git-check-ref-format[1]).
+
 OPTIONS
 -------
 
diff --git a/builtin/refs.c b/builtin/refs.c
index 92e62fd5df..c7aa1a327f 100644
--- a/builtin/refs.c
+++ b/builtin/refs.c
@@ -30,6 +30,9 @@
 #define REFS_UPDATE_USAGE \
 	N_("git refs update [--message=<reason>] [--no-deref] [--create-reflog] <ref> <new-value> [<old-value>]")
 
+#define REFS_RENAME_USAGE \
+	N_("git refs rename [--message=<reason>] <old-ref> <new-ref>")
+
 static int cmd_refs_migrate(int argc, const char **argv, const char *prefix,
 			    struct repository *repo)
 {
@@ -327,6 +330,50 @@ static int cmd_refs_update(int argc, const char **argv, const char *prefix,
 	return ret;
 }
 
+static int cmd_refs_rename(int argc, const char **argv, const char *prefix,
+			   struct repository *repo)
+{
+	static char const * const refs_rename_usage[] = {
+		REFS_RENAME_USAGE,
+		NULL
+	};
+	const char *message = NULL;
+	struct option opts[] = {
+		OPT_STRING(0, "message", &message, N_("reason"),
+			   N_("reason of the update")),
+		OPT_END(),
+	};
+	const char *oldref, *newref;
+	int ret;
+
+	argc = parse_options(argc, argv, prefix, opts, refs_rename_usage, 0);
+	if (argc != 2)
+		usage(_("rename requires old and new reference name"));
+	if (message && !*message)
+		die(_("refusing to perform update with empty message"));
+
+	repo_config(repo, git_default_config, NULL);
+
+	oldref = argv[0];
+	newref = argv[1];
+
+	if (check_refname_format(oldref, 0))
+		die(_("invalid ref format: '%s'"), oldref);
+	if (check_refname_format(newref, 0))
+		die(_("invalid ref format: '%s'"), newref);
+
+	if (!refs_ref_exists(get_main_ref_store(repo), oldref))
+		die(_("reference does not exist: '%s'"), oldref);
+	if (refs_ref_exists(get_main_ref_store(repo), newref))
+		die(_("reference already exists: '%s'"), newref);
+
+	ret = refs_rename_ref(get_main_ref_store(repo), oldref, newref, message);
+
+	if (ret < 0)
+		ret = 1;
+	return ret;
+}
+
 int cmd_refs(int argc,
 	     const char **argv,
 	     const char *prefix,
@@ -341,6 +388,7 @@ int cmd_refs(int argc,
 		REFS_CREATE_USAGE,
 		REFS_DELETE_USAGE,
 		REFS_UPDATE_USAGE,
+		REFS_RENAME_USAGE,
 		NULL,
 	};
 	parse_opt_subcommand_fn *fn = NULL;
@@ -353,6 +401,7 @@ int cmd_refs(int argc,
 		OPT_SUBCOMMAND("create", &fn, cmd_refs_create),
 		OPT_SUBCOMMAND("delete", &fn, cmd_refs_delete),
 		OPT_SUBCOMMAND("update", &fn, cmd_refs_update),
+		OPT_SUBCOMMAND("rename", &fn, cmd_refs_rename),
 		OPT_END(),
 	};
 
diff --git a/t/meson.build b/t/meson.build
index 541e6f919c..a39fd8c4c4 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -226,6 +226,7 @@ integration_tests = [
   't1464-refs-delete.sh',
   't1465-refs-update.sh',
   't1466-refs-create.sh',
+  't1467-refs-rename.sh',
   't1500-rev-parse.sh',
   't1501-work-tree.sh',
   't1502-rev-parse-parseopt.sh',
diff --git a/t/t1467-refs-rename.sh b/t/t1467-refs-rename.sh
new file mode 100755
index 0000000000..f80d58e0f4
--- /dev/null
+++ b/t/t1467-refs-rename.sh
@@ -0,0 +1,131 @@
+#!/bin/sh
+
+test_description='git refs rename'
+
+. ./test-lib.sh
+
+setup_repo () {
+	git init "$1" &&
+	test_commit -C "$1" A &&
+	test_commit -C "$1" B
+}
+
+test_ref_matches () {
+	git rev-parse "$1" >expect &&
+	echo "$2" >actual &&
+	test_cmp expect actual
+}
+
+test_expect_success 'rename an existing reference' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update refs/heads/foo $A &&
+		git refs rename refs/heads/foo refs/heads/bar &&
+		test_must_fail git refs exists refs/heads/foo &&
+		test_ref_matches refs/heads/bar $A
+	)
+'
+
+test_expect_success 'rename moves the reflog along with the reference' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update --message="rename me" refs/heads/foo $A &&
+		git refs rename refs/heads/foo refs/heads/bar &&
+		git reflog show refs/heads/bar >reflog &&
+		test_grep "rename me" reflog &&
+		test_must_fail git reflog exists refs/heads/foo
+	)
+'
+
+test_expect_success 'rename with message records reason in reflog' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update refs/heads/foo $A &&
+		git refs rename --message="rename reason" refs/heads/foo refs/heads/bar &&
+		git reflog show refs/heads/bar >actual &&
+		test_grep "rename reason" actual
+	)
+'
+
+test_expect_success 'rename a nonexistent reference fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		test_must_fail git refs rename refs/heads/foo refs/heads/bar 2>err &&
+		test_grep "reference does not exist" err
+	)
+'
+
+test_expect_success 'rename to an existing reference fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		B=$(git rev-parse B) &&
+		git refs update refs/heads/foo $A &&
+		git refs update refs/heads/bar $B &&
+		test_must_fail git refs rename refs/heads/foo refs/heads/bar 2>err &&
+		test_grep "reference already exists" err
+	)
+'
+
+test_expect_success 'rename with empty message fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update refs/heads/foo $A &&
+		test_must_fail git refs rename --message= refs/heads/foo refs/heads/bar 2>err &&
+		test_grep "empty message" err
+	)
+'
+
+test_expect_success 'rename with invalid old reference name fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		test_must_fail git refs rename "refs/heads/foo..bar" refs/heads/bar 2>err &&
+		test_grep "invalid ref format" err
+	)
+'
+
+test_expect_success 'rename with invalid new reference name fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	(
+		cd repo &&
+		A=$(git rev-parse A) &&
+		git refs update refs/heads/foo $A &&
+		test_must_fail git refs rename refs/heads/foo "refs/heads/bar..baz" 2>err &&
+		test_grep "invalid ref format" err
+	)
+'
+
+test_expect_success 'rename with too few arguments fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	test_must_fail git -C repo refs rename refs/heads/foo 2>err &&
+	test_grep "requires old and new reference name" err
+'
+
+test_expect_success 'rename with too many arguments fails' '
+	test_when_finished "rm -rf repo" &&
+	setup_repo repo &&
+	test_must_fail git -C repo refs rename refs/heads/foo refs/heads/bar refs/heads/baz 2>err &&
+	test_grep "requires old and new reference name" err
+'
+
+test_done

-- 
2.55.0.rc0.786.g65d90a0328.dirty


^ permalink raw reply related	[flat|nested] 20+ messages in thread

* Re: [PATCH 3/4] builtin/refs: add "update" subcommand
  2026-06-17  7:28     ` Patrick Steinhardt
@ 2026-06-17 12:11       ` Junio C Hamano
  0 siblings, 0 replies; 20+ messages in thread
From: Junio C Hamano @ 2026-06-17 12:11 UTC (permalink / raw)
  To: Patrick Steinhardt; +Cc: git

Patrick Steinhardt <ps@pks.im> writes:

> We can:
>
>     $ git update-ref $NEW_OID $NULL_OID
>     $ git refs update $NEW_OID $NULL_OID
>
> This will verify that the reference doesn't exist before actually
> writing it. Will add a test.

I think refname is missing from the command line, but the above is
good.  I forgot we had update-ref already doing that ;-)

Thanks.

^ permalink raw reply	[flat|nested] 20+ messages in thread

* Re: [PATCH 4/4] builtin/refs: add "rename" subcommand
  2026-06-17  7:28     ` Patrick Steinhardt
@ 2026-06-17 12:13       ` Junio C Hamano
  0 siblings, 0 replies; 20+ messages in thread
From: Junio C Hamano @ 2026-06-17 12:13 UTC (permalink / raw)
  To: Patrick Steinhardt; +Cc: git

Patrick Steinhardt <ps@pks.im> writes:

>> If we rename a ref that does not have a reflog, would it leave the
>> ref under the new name without reflog, or would we get a reflog with
>> a single entry that marks the fact the old ref was renamed into the
>> new ref?  Should that be controlled via --create-reflog option?
>
> It would leave it without a reflog. In theory I agree that it might make
> sense to introduce a "--create-reflog" option, but that would require
> some new plumbing in `refs_rename_ref()`. So I'd say that we can add it
> at a later point as needed.

OK.

^ permalink raw reply	[flat|nested] 20+ messages in thread

* Re: [PATCH v2 0/5] builtin/refs: add ability to write references
  2026-06-17 10:15 ` [PATCH v2 0/5] builtin/refs: add ability to write references Patrick Steinhardt
                     ` (4 preceding siblings ...)
  2026-06-17 10:16   ` [PATCH v2 5/5] builtin/refs: add "rename" subcommand Patrick Steinhardt
@ 2026-06-17 12:26   ` Junio C Hamano
  5 siblings, 0 replies; 20+ messages in thread
From: Junio C Hamano @ 2026-06-17 12:26 UTC (permalink / raw)
  To: Patrick Steinhardt; +Cc: git

Patrick Steinhardt <ps@pks.im> writes:

> Hi,
>
> 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).
>
> This patch series backfills most of the functionality by introducing
> three new commands:
>
>   - `git refs delete` to delete references. This is the equivalent of
>     `git update-ref -d`.
>
>   - `git refs update` to update references. This is the equivalent of
>     `git update-ref <refname> <oldvalue> <newvalue>`.
>
>   - `git refs rename` to rename a reference, including its reflog. This
>     does not have an equivalent in git-update-ref(1), but is inspired by
>     and supersedes [1].

... and `git refs create`, but we can guess what it would do ;-).

Will queue.  Thanks.

^ permalink raw reply	[flat|nested] 20+ messages in thread

end of thread, other threads:[~2026-06-17 12:26 UTC | newest]

Thread overview: 20+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-06-16  8:44 [PATCH 0/4] builtin/refs: add ability to write references Patrick Steinhardt
2026-06-16  8:44 ` [PATCH 1/4] builtin/refs: drop `the_repository` Patrick Steinhardt
2026-06-16  8:44 ` [PATCH 2/4] builtin/refs: add "delete" subcommand Patrick Steinhardt
2026-06-16  8:44 ` [PATCH 3/4] builtin/refs: add "update" subcommand Patrick Steinhardt
2026-06-16 11:17   ` Junio C Hamano
2026-06-17  7:28     ` Patrick Steinhardt
2026-06-17 12:11       ` Junio C Hamano
2026-06-16 14:52   ` Junio C Hamano
2026-06-17  7:28     ` Patrick Steinhardt
2026-06-16  8:44 ` [PATCH 4/4] builtin/refs: add "rename" subcommand Patrick Steinhardt
2026-06-16 14:53   ` Junio C Hamano
2026-06-17  7:28     ` Patrick Steinhardt
2026-06-17 12:13       ` Junio C Hamano
2026-06-17 10:15 ` [PATCH v2 0/5] builtin/refs: add ability to write references Patrick Steinhardt
2026-06-17 10:15   ` [PATCH v2 1/5] builtin/refs: drop `the_repository` Patrick Steinhardt
2026-06-17 10:15   ` [PATCH v2 2/5] builtin/refs: add "delete" subcommand Patrick Steinhardt
2026-06-17 10:16   ` [PATCH v2 3/5] builtin/refs: add "update" subcommand Patrick Steinhardt
2026-06-17 10:16   ` [PATCH v2 4/5] builtin/refs: add "create" subcommand Patrick Steinhardt
2026-06-17 10:16   ` [PATCH v2 5/5] builtin/refs: add "rename" subcommand Patrick Steinhardt
2026-06-17 12:26   ` [PATCH v2 0/5] builtin/refs: add ability to write references Junio C Hamano

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox