Git development
 help / color / mirror / Atom feed
* [PATCH v13 0/6] branch: prune-merged
From: Harald Nordgren via GitGitGadget @ 2026-06-05 18:35 UTC (permalink / raw)
  To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren
In-Reply-To: <pull.2285.v12.git.git.1780477479.gitgitgadget@gmail.com>

 * Reworked --forked into a real ref-filter applied in apply_ref_filter()
   instead of a post-pass, so non-matching branches are never allocated.
 * Match exact --forked patterns on full refnames (only globs use the
   abbreviated upstream), and dropped the old helper machinery, forward
   declaration, and string_list in favor of a strvec.
 * Replaced the boolean parameters of
   delete_branches()/check_branch_commit() with a single unsigned int flags.
 * --prune-merged now collects candidates via filter_refs() rather than its
   own branch walk.
 * --prune-merged now takes its <branch> patterns as positional arguments
   (e.g. git branch --prune-merged origin/main 'feature*') instead of
   repeating the option.

Harald Nordgren (6):
  branch: add --forked filter for --list mode
  branch: let delete_branches warn instead of error on bulk refusal
  branch: prepare delete_branches for a bulk caller
  branch: add --prune-merged <branch>
  branch: add branch.<name>.pruneMerged opt-out
  branch: add --dry-run for --prune-merged

 Documentation/config/branch.adoc |   7 +
 Documentation/git-branch.adoc    |  41 +++-
 builtin/branch.c                 | 182 ++++++++++++---
 ref-filter.c                     |  70 ++++++
 ref-filter.h                     |  10 +
 t/t3200-branch.sh                | 367 +++++++++++++++++++++++++++++++
 6 files changed, 650 insertions(+), 27 deletions(-)


base-commit: 9ac3f193c05c2237e2b14ebaa1149e9fc8a1abe0
Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2285%2FHaraldNordgren%2Ffetch-prune-local-branches-v13
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2285/HaraldNordgren/fetch-prune-local-branches-v13
Pull-Request: https://github.com/git/git/pull/2285

Range-diff vs v12:

 1:  8834c424fb ! 1:  ccd07cff25 branch: add --forked filter for --list mode
     @@ Metadata
       ## Commit message ##
          branch: add --forked filter for --list mode
      
     -    Add a --forked option to "git branch" list mode that keeps only
     +    Add a --forked option to "git branch" list mode that lists only
          branches whose configured upstream matches <branch>. The argument
     -    can be a ref (e.g. "origin/main", "master") or a shell-style
     -    glob (e.g. "origin/*"). The option can be repeated to widen the
     -    filter.
     +    can be a ref (e.g. "origin/main", "master") or a shell glob
     +    (e.g. "origin/*"), and may be repeated to widen the filter.
      
     -    Because it is a filter on list mode, --forked composes with the
     -    existing list-mode filters, so
     +    It is an ordinary list filter, so it combines with the others:
      
              git branch --merged origin/main --forked 'origin/*'
      
     -    lists branches forked from origin that have already been
     -    integrated into origin/main, and --no-merged inverts the question.
     +    lists branches forked from origin that are already merged into
     +    origin/main, and --no-merged inverts the question.
      
          This is the building block for --prune-merged, which deletes the
          listed branches once they have landed on their upstream.
     @@ Commit message
      
       ## Documentation/git-branch.adoc ##
      @@ Documentation/git-branch.adoc: git branch [--color[=<when>] | --no-color] [--show-current]
     + 	   [--column[=<options>] | --no-column] [--sort=<key>]
       	   [--merged [<commit>]] [--no-merged [<commit>]]
       	   [--contains [<commit>]] [--no-contains [<commit>]]
     - 	   [--points-at <object>] [--format=<format>]
      +	   [(--forked <branch>)...]
     + 	   [--points-at <object>] [--format=<format>]
       	   [(-r|--remotes) | (-a|--all)]
       	   [--list] [<pattern>...]
     - git branch [--track[=(direct|inherit)] | --no-track] [-f]
     -@@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mode.
     - 	Print the name of the current branch. In detached `HEAD` state,
     - 	nothing is printed.
     +@@ Documentation/git-branch.adoc: merged into the named commit (i.e. the branches whose tip commits are
     + reachable from the named commit) will be listed.  With `--no-merged` only
     + branches not merged into the named commit will be listed.  If the _<commit>_
     + argument is missing it defaults to `HEAD` (i.e. the tip of the current
     +-branch).
     ++branch).  With `--forked`, only branches whose configured upstream matches
     ++the given branch or pattern will be listed.
     + 
     + The command's second form creates a new branch head named _<branch-name>_
     + which points to the current `HEAD`, or _<start-point>_ if given. As a
     +@@ Documentation/git-branch.adoc: superproject's "origin/main", but tracks the submodule's "origin/main".
     + 	Only list branches whose tips are not reachable from
     + 	_<commit>_ (`HEAD` if not specified). Implies `--list`.
       
      +`--forked <branch>`::
     -+	List only branches whose configured upstream matches
     ++	Only list branches whose configured upstream matches
      +	_<branch>_. The argument can be a ref (e.g. `origin/main`,
      +	`master`) or a shell-style glob (e.g. `'origin/*'`). The
     -+	option can be repeated to widen the filter.
     ++	option can be repeated to widen the filter. Implies `--list`.
      +
     - `-v`::
     - `-vv`::
     - `--verbose`::
     + `--points-at <object>`::
     + 	Only list branches of _<object>_.
     + 
      
       ## builtin/branch.c ##
      @@
     - #include "help.h"
     - #include "advice.h"
       #include "commit-reach.h"
     -+#include "wildmatch.h"
       
       static const char * const builtin_branch_usage[] = {
      -	N_("git branch [<options>] [-r | -a] [--merged] [--no-merged]"),
     @@ builtin/branch.c
       	N_("git branch [<options>] [-f] [--recurse-submodules] <branch-name> [<start-point>]"),
       	N_("git branch [<options>] [-l] [<pattern>...]"),
       	N_("git branch [<options>] [-r] (-d | -D) <branch-name>..."),
     -@@ builtin/branch.c: static char *build_format(struct ref_filter *filter, int maxwidth, const char *r
     - 	return strbuf_detach(&fmt, NULL);
     +@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const char *newname, int
     + 	free_worktrees(worktrees);
       }
       
     -+static void filter_array_by_forked(struct ref_array *array,
     -+				   const struct string_list *upstreams);
     ++static int parse_opt_forked(const struct option *opt, const char *arg, int unset)
     ++{
     ++	struct ref_filter *filter = opt->value;
      +
     - static void print_ref_list(struct ref_filter *filter, struct ref_sorting *sorting,
     --			   struct ref_format *format, struct string_list *output)
     -+			   struct ref_format *format, struct string_list *output,
     -+			   const struct string_list *forked_upstreams)
     - {
     - 	int i;
     - 	struct ref_array array;
     -@@ builtin/branch.c: static void print_ref_list(struct ref_filter *filter, struct ref_sorting *sortin
     ++	BUG_ON_OPT_NEG(unset);
     ++	if (ref_filter_forked_add(filter, arg) < 0)
     ++		die(_("'%s' is not a valid branch or pattern"), arg);
     ++	return 0;
     ++}
     ++
     + static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
       
     - 	filter_refs(&array, filter, filter->kind);
     + static int edit_branch_description(const char *branch_name)
     +@@ builtin/branch.c: int cmd_branch(int argc,
     + 		OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
     + 		OPT_MERGED(&filter, N_("print only branches that are merged")),
     + 		OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
     ++		OPT_CALLBACK_F(0, "forked", &filter, N_("branch"),
     ++			N_("print only branches whose upstream matches <branch> (repeatable)"),
     ++			PARSE_OPT_NONEG, parse_opt_forked),
     + 		OPT_COLUMN(0, "column", &colopts, N_("list branches in columns")),
     + 		OPT_REF_SORT(&sorting_options),
     + 		OPT_CALLBACK(0, "points-at", &filter.points_at, N_("object"),
     +@@ builtin/branch.c: int cmd_branch(int argc,
     + 		list = 1;
       
     -+	if (forked_upstreams->nr)
     -+		filter_array_by_forked(&array, forked_upstreams);
     -+
     - 	if (filter->verbose)
     - 		maxwidth = calc_maxwidth(&array, strlen(remote_prefix));
     + 	if (filter.with_commit || filter.no_commit ||
     +-	    filter.reachable_from || filter.unreachable_from || filter.points_at.nr)
     ++	    filter.reachable_from || filter.unreachable_from ||
     ++	    filter.points_at.nr || filter.forked.nr)
     + 		list = 1;
       
     -@@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const char *newname, int
     - 	free_worktrees(worktrees);
     + 	noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
     +
     + ## ref-filter.c ##
     +@@ ref-filter.c: static int filter_exclude_match(struct ref_filter *filter, const char *refname)
     + 	return match_pattern(filter->exclude.v, refname, filter->ignore_case);
       }
       
     -+struct upstream_pattern {
     -+	char *name;
     -+	int is_wildcard;
     -+};
     -+
     -+static void upstream_pattern_list_clear(struct upstream_pattern *items,
     -+					size_t nr)
     -+{
     -+	size_t i;
     -+	for (i = 0; i < nr; i++)
     -+		free(items[i].name);
     -+	free(items);
     -+}
     -+
      +static const char *short_upstream_name(const char *full_ref)
      +{
      +	const char *short_name = full_ref;
     @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
      +	return short_name;
      +}
      +
     -+static int parse_one_forked_arg(const char *arg, struct upstream_pattern *out)
     -+{
     -+	struct object_id oid;
     -+	char *full_ref = NULL;
     -+
     -+	if (has_glob_specials(arg)) {
     -+		out->name = xstrdup(arg);
     -+		out->is_wildcard = 1;
     -+		return 0;
     -+	}
     -+
     -+	if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
     -+			  &full_ref, 0) == 1 &&
     -+	    (starts_with(full_ref, "refs/heads/") ||
     -+	     starts_with(full_ref, "refs/remotes/"))) {
     -+		out->name = xstrdup(short_upstream_name(full_ref));
     -+		out->is_wildcard = 0;
     -+		free(full_ref);
     -+		return 0;
     -+	}
     -+	free(full_ref);
     -+	return -1;
     -+}
     -+
     -+static void parse_forked_args(const struct string_list *args,
     -+			      struct upstream_pattern **patterns_out,
     -+			      size_t *nr_out)
     -+{
     -+	struct upstream_pattern *patterns;
     -+	size_t i;
     -+
     -+	ALLOC_ARRAY(patterns, args->nr);
     -+	for (i = 0; i < args->nr; i++) {
     -+		const char *arg = args->items[i].string;
     -+		if (parse_one_forked_arg(arg, &patterns[i]) < 0) {
     -+			upstream_pattern_list_clear(patterns, i);
     -+			die(_("'%s' is not a valid branch or pattern"), arg);
     -+		}
     -+	}
     -+	*patterns_out = patterns;
     -+	*nr_out = args->nr;
     -+}
     -+
     -+static int upstream_matches(const char *short_upstream,
     -+			    const struct upstream_pattern *patterns,
     -+			    size_t nr)
     -+{
     -+	size_t i;
     -+
     -+	for (i = 0; i < nr; i++) {
     -+		const struct upstream_pattern *p = &patterns[i];
     -+		if (p->is_wildcard) {
     -+			if (!wildmatch(p->name, short_upstream, WM_PATHNAME))
     -+				return 1;
     -+		} else if (!strcmp(p->name, short_upstream)) {
     -+			return 1;
     -+		}
     -+	}
     -+	return 0;
     -+}
     -+
     -+static int branch_upstream_matches(const char *full_refname,
     -+				   const struct upstream_pattern *patterns,
     -+				   size_t nr_patterns)
     ++/*
     ++ * Match the configured upstream of a branch against the registered
     ++ * --forked patterns. Exact patterns are compared against the full
     ++ * upstream refname so they are unambiguous; glob patterns are matched
     ++ * against the abbreviated upstream so that a glob such as origin/...
     ++ * works as typed.
     ++ */
     ++static int filter_forked_match(struct ref_filter *filter, const char *refname)
      +{
      +	const char *short_name;
      +	struct branch *branch;
      +	const char *upstream;
     ++	int i;
      +
     -+	if (!skip_prefix(full_refname, "refs/heads/", &short_name))
     ++	if (!skip_prefix(refname, "refs/heads/", &short_name))
      +		return 0;
      +	branch = branch_get(short_name);
      +	if (!branch)
     @@ builtin/branch.c: static void copy_or_rename_branch(const char *oldname, const c
      +	upstream = branch_get_upstream(branch, NULL);
      +	if (!upstream)
      +		return 0;
     -+	return upstream_matches(short_upstream_name(upstream),
     -+				patterns, nr_patterns);
     ++
     ++	for (i = 0; i < filter->forked.nr; i++) {
     ++		const char *pattern = filter->forked.v[i];
     ++		if (has_glob_specials(pattern)) {
     ++			if (!wildmatch(pattern, short_upstream_name(upstream),
     ++				       WM_PATHNAME))
     ++				return 1;
     ++		} else if (!strcmp(pattern, upstream)) {
     ++			return 1;
     ++		}
     ++	}
     ++	return 0;
      +}
      +
     -+static void filter_array_by_forked(struct ref_array *array,
     -+				   const struct string_list *upstreams)
     ++int ref_filter_forked_add(struct ref_filter *filter, const char *arg)
      +{
     -+	struct upstream_pattern *patterns = NULL;
     -+	size_t nr_patterns = 0;
     -+	int i, kept = 0;
     -+
     -+	parse_forked_args(upstreams, &patterns, &nr_patterns);
     ++	struct object_id oid;
     ++	char *full_ref = NULL;
      +
     -+	for (i = 0; i < array->nr; i++) {
     -+		struct ref_array_item *item = array->items[i];
     -+		if (branch_upstream_matches(item->refname,
     -+					    patterns, nr_patterns))
     -+			array->items[kept++] = item;
     -+		else
     -+			free_ref_array_item(item);
     ++	if (has_glob_specials(arg)) {
     ++		strvec_push(&filter->forked, arg);
     ++		return 0;
      +	}
     -+	array->nr = kept;
      +
     -+	upstream_pattern_list_clear(patterns, nr_patterns);
     ++	if (repo_dwim_ref(the_repository, arg, strlen(arg), &oid,
     ++			  &full_ref, 0) == 1 &&
     ++	    (starts_with(full_ref, "refs/heads/") ||
     ++	     starts_with(full_ref, "refs/remotes/"))) {
     ++		strvec_push(&filter->forked, full_ref);
     ++		free(full_ref);
     ++		return 0;
     ++	}
     ++	free(full_ref);
     ++	return -1;
      +}
      +
     - static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
     - 
     - static int edit_branch_description(const char *branch_name)
     -@@ builtin/branch.c: int cmd_branch(int argc,
     - 	/* possible actions */
     - 	int delete = 0, rename = 0, copy = 0, list = 0,
     - 	    unset_upstream = 0, show_current = 0, edit_description = 0;
     -+	struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
     - 	const char *new_upstream = NULL;
     - 	int noncreate_actions = 0;
     - 	/* possible options */
     -@@ builtin/branch.c: int cmd_branch(int argc,
     - 		OPT_BOOL(0, "create-reflog", &reflog, N_("create the branch's reflog")),
     - 		OPT_BOOL(0, "edit-description", &edit_description,
     - 			 N_("edit the description for the branch")),
     -+		OPT_STRING_LIST(0, "forked", &forked_upstreams, N_("branch"),
     -+			N_("list local branches whose upstream matches <branch> (repeatable)")),
     - 		OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
     - 		OPT_MERGED(&filter, N_("print only branches that are merged")),
     - 		OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
     -@@ builtin/branch.c: int cmd_branch(int argc,
     - 		list = 1;
     - 
     - 	if (filter.with_commit || filter.no_commit ||
     --	    filter.reachable_from || filter.unreachable_from || filter.points_at.nr)
     -+	    filter.reachable_from || filter.unreachable_from ||
     -+	    filter.points_at.nr || forked_upstreams.nr)
     - 		list = 1;
     - 
     - 	noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
     -@@ builtin/branch.c: int cmd_branch(int argc,
     - 		ref_sorting_set_sort_flags_all(sorting, REF_SORTING_ICASE, icase);
     - 		ref_sorting_set_sort_flags_all(
     - 			sorting, REF_SORTING_DETACHED_HEAD_FIRST, 1);
     --		print_ref_list(&filter, sorting, &format, &output);
     -+		print_ref_list(&filter, sorting, &format, &output,
     -+			       &forked_upstreams);
     - 		print_columns(&output, colopts, NULL);
     - 		string_list_clear(&output, 0);
     - 		ref_sorting_release(sorting);
     -@@ builtin/branch.c: int cmd_branch(int argc,
     - 
     - out:
     - 	string_list_clear(&sorting_options, 0);
     -+	string_list_clear(&forked_upstreams, 0);
     - 	return ret;
     - }
     -
     - ## ref-filter.c ##
     -@@ ref-filter.c: static int filter_one(const struct reference *ref, void *cb_data)
     - }
     - 
     - /*  Free memory allocated for a ref_array_item */
     --static void free_array_item(struct ref_array_item *item)
     -+void free_ref_array_item(struct ref_array_item *item)
     - {
     - 	free((char *)item->symref);
     - 	if (item->value) {
     -@@ ref-filter.c: static int filter_and_format_one(const struct reference *ref, void *cb_data)
     - 
     - 	strbuf_release(&output);
     - 	strbuf_release(&err);
     --	free_array_item(item);
     -+	free_ref_array_item(item);
     + /*
     +  * We need to seek to the reference right after a given marker but excluding any
     +  * matching references. So we seek to the lexicographically next reference.
     +@@ ref-filter.c: static struct ref_array_item *apply_ref_filter(const struct reference *ref,
     + 	if (filter->points_at.nr && !match_points_at(&filter->points_at, ref->oid, ref->name))
     + 		return NULL;
       
     ++	if (filter->forked.nr && !filter_forked_match(filter, ref->name))
     ++		return NULL;
     ++
       	/*
     - 	 * Increment the running count of refs that match the filter. If
     -@@ ref-filter.c: void ref_array_clear(struct ref_array *array)
     - 	int i;
     - 
     - 	for (i = 0; i < array->nr; i++)
     --		free_array_item(array->items[i]);
     -+		free_ref_array_item(array->items[i]);
     - 	FREE_AND_NULL(array->items);
     - 	array->nr = array->alloc = 0;
     - 
     -@@ ref-filter.c: static void reach_filter(struct ref_array *array,
     - 		if (is_merged == include_reached)
     - 			array->items[array->nr++] = array->items[i];
     - 		else
     --			free_array_item(item);
     -+			free_ref_array_item(item);
     - 	}
     - 
     - 	clear_commit_marks_many(old_nr, to_clear, ALL_REV_FLAGS);
     -@@ ref-filter.c: void pretty_print_ref(const char *name, const struct object_id *oid,
     - 
     - 	strbuf_release(&err);
     - 	strbuf_release(&output);
     --	free_array_item(ref_item);
     -+	free_ref_array_item(ref_item);
     - }
     - 
     - static int parse_sorting_atom(const char *atom)
     + 	 * A merge filter is applied on refs pointing to commits. Hence
     + 	 * obtain the commit using the 'oid' available and discard all
     +@@ ref-filter.c: void ref_filter_init(struct ref_filter *filter)
     + void ref_filter_clear(struct ref_filter *filter)
     + {
     + 	strvec_clear(&filter->exclude);
     ++	strvec_clear(&filter->forked);
     + 	oid_array_clear(&filter->points_at);
     + 	commit_list_free(filter->with_commit);
     + 	commit_list_free(filter->no_commit);
      
       ## ref-filter.h ##
     -@@ ref-filter.h: void filter_and_format_refs(struct ref_filter *filter, unsigned int type,
     - 			    struct ref_format *format);
     - /*  Clear all memory allocated to ref_array */
     - void ref_array_clear(struct ref_array *array);
     -+/*  Free a single item from a ref_array */
     -+void free_ref_array_item(struct ref_array_item *item);
     - /*  Used to verify if the given format is correct and to parse out the used atoms */
     - int verify_ref_format(struct ref_format *format);
     - /*  Sort the given ref_array as per the ref_sorting provided */
     +@@ ref-filter.h: struct ref_filter {
     + 	const char **name_patterns;
     + 	const char *start_after;
     + 	struct strvec exclude;
     ++	struct strvec forked;
     + 	struct oid_array points_at;
     + 	struct commit_list *with_commit;
     + 	struct commit_list *no_commit;
     +@@ ref-filter.h: struct ref_format {
     + #define REF_FILTER_INIT { \
     + 	.points_at = OID_ARRAY_INIT, \
     + 	.exclude = STRVEC_INIT, \
     ++	.forked = STRVEC_INIT, \
     + }
     + #define REF_FORMAT_INIT {             \
     + 	.use_color = GIT_COLOR_UNKNOWN, \
     +@@ ref-filter.h: void ref_sorting_release(struct ref_sorting *);
     + struct ref_sorting *ref_sorting_options(struct string_list *);
     + /*  Function to parse --merged and --no-merged options */
     + int parse_opt_merge_filter(const struct option *opt, const char *arg, int unset);
     ++/*
     ++ * Register a --forked <branch> pattern on the filter. The argument is
     ++ * either a ref, which is resolved to its full refname, or a shell-style
     ++ * glob. Branches are kept only when their configured upstream matches
     ++ * one of the registered patterns. Returns -1 if the argument is not a
     ++ * valid ref or pattern.
     ++ */
     ++int ref_filter_forked_add(struct ref_filter *filter, const char *arg);
     + /*  Get the current HEAD's description */
     + char *get_head_description(void);
     + /*  Set up translated strings in the output. */
      
       ## t/t3200-branch.sh ##
      @@ t/t3200-branch.sh: test_expect_success 'errors if given a bad branch name' '
 2:  6c95e4e77c ! 2:  a7672713f6 branch: let delete_branches warn instead of error on bulk refusal
     @@ Metadata
       ## Commit message ##
          branch: let delete_branches warn instead of error on bulk refusal
      
     -    Add a warn_only flag to delete_branches() and check_branch_commit()
     -    so a bulk caller can report not-fully-merged branches as one-line
     -    warnings and continue, instead of erroring with the four-line "use
     -    'git branch -D'" advice that the standalone "git branch -d" path
     -    emits. Default callers pass 0 and are unaffected.
     +    Add a warn-only mode to delete_branches() and check_branch_commit()
     +    so a bulk caller can report branches that are not fully merged as a
     +    short warning and carry on, rather than erroring with the longer
     +    "use 'git branch -D'" advice that the plain "git branch -d" path
     +    emits. Existing callers are unaffected.
      
          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
      
       ## builtin/branch.c ##
      @@ builtin/branch.c: static int branch_merged(int kind, const char *name,
     + 	return merged;
     + }
       
     ++enum delete_branch_flags {
     ++	DELETE_BRANCH_FORCE = (1 << 0),
     ++	DELETE_BRANCH_QUIET = (1 << 1),
     ++	DELETE_BRANCH_WARN_ONLY = (1 << 2),
     ++};
     ++
       static int check_branch_commit(const char *branchname, const char *refname,
       			       const struct object_id *oid, struct commit *head_rev,
      -			       int kinds, int force)
     -+			       int kinds, int force, int warn_only)
     ++			       int kinds, unsigned int flags)
       {
     ++	int force = flags & DELETE_BRANCH_FORCE;
       	struct commit *rev = lookup_commit_reference(the_repository, oid);
       	if (!force && !rev) {
     -@@ builtin/branch.c: static int check_branch_commit(const char *branchname, const char *refname,
     + 		error(_("couldn't look up commit object for '%s'"), refname);
       		return -1;
       	}
       	if (!force && !branch_merged(kinds, branchname, rev, head_rev)) {
     @@ builtin/branch.c: static int check_branch_commit(const char *branchname, const c
      -		advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
      -				  _("If you are sure you want to delete it, "
      -				  "run 'git branch -D %s'"), branchname);
     -+		if (warn_only) {
     ++		if (flags & DELETE_BRANCH_WARN_ONLY) {
      +			warning(_("the branch '%s' is not fully merged"),
      +				branchname);
      +		} else {
     @@ builtin/branch.c: static int check_branch_commit(const char *branchname, const c
       	}
       	return 0;
      @@ builtin/branch.c: static void delete_branch_config(const char *branchname)
     + 	strbuf_release(&buf);
       }
       
     - static int delete_branches(int argc, const char **argv, int force, int kinds,
     +-static int delete_branches(int argc, const char **argv, int force, int kinds,
      -			   int quiet)
     -+			   int quiet, int warn_only)
     ++static int delete_branches(int argc, const char **argv, int kinds,
     ++			   unsigned int flags)
       {
       	struct commit *head_rev = NULL;
       	struct object_id oid;
      @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
     + 	int i;
     + 	int ret = 0;
     + 	int remote_branch = 0;
     ++	int force = flags & DELETE_BRANCH_FORCE;
     ++	int quiet = flags & DELETE_BRANCH_QUIET;
     + 	struct strbuf bname = STRBUF_INIT;
     + 	enum interpret_branch_kind allowed_interpret;
     + 	struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
     + 
     + 	for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
     + 		char *target = NULL;
     +-		int flags = 0;
     ++		int ref_flags = 0;
     + 
     + 		copy_branchname(&bname, argv[i], allowed_interpret);
     + 		free(name);
     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
     + 					     RESOLVE_REF_READING
     + 					     | RESOLVE_REF_NO_RECURSE
     + 					     | RESOLVE_REF_ALLOW_BAD_NAME,
     +-					     &oid, &flags);
     ++					     &oid, &ref_flags);
     + 		if (!target) {
     + 			if (remote_branch) {
     + 				error(_("remote-tracking branch '%s' not found"), bname.buf);
     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
     + 									   | RESOLVE_REF_NO_RECURSE
     + 									   | RESOLVE_REF_ALLOW_BAD_NAME,
     + 									   &oid,
     +-									   &flags);
     ++									   &ref_flags);
     + 				FREE_AND_NULL(virtual_name);
       
     - 		if (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
     + 				if (virtual_target)
     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
     + 			continue;
     + 		}
     + 
     +-		if (!(flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
     ++		if (!(ref_flags & (REF_ISSYMREF|REF_ISBROKEN)) &&
       		    check_branch_commit(bname.buf, name, &oid, head_rev, kinds,
      -					force)) {
      -			ret = 1;
     -+					force, warn_only)) {
     -+			if (!warn_only)
     ++					flags)) {
     ++			if (!(flags & DELETE_BRANCH_WARN_ONLY))
      +				ret = 1;
       			goto next;
       		}
       
     + 		item = string_list_append(&refs_to_delete, name);
     +-		item->util = xstrdup((flags & REF_ISBROKEN) ? "broken"
     +-				    : (flags & REF_ISSYMREF) ? target
     ++		item->util = xstrdup((ref_flags & REF_ISBROKEN) ? "broken"
     ++				    : (ref_flags & REF_ISSYMREF) ? target
     + 				    : repo_find_unique_abbrev(the_repository, &oid, DEFAULT_ABBREV));
     + 
     + 	next:
      @@ builtin/branch.c: int cmd_branch(int argc,
       	if (delete) {
       		if (!argc)
       			die(_("branch name required"));
      -		ret = delete_branches(argc, argv, delete > 1, filter.kind, quiet);
     -+		ret = delete_branches(argc, argv, delete > 1, filter.kind,
     -+				      quiet, 0);
     ++		ret = delete_branches(argc, argv, filter.kind,
     ++				      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |
     ++				      (quiet ? DELETE_BRANCH_QUIET : 0));
       		goto out;
       	} else if (show_current) {
       		print_current_branch_name();
 3:  004a96f7a4 ! 3:  5ee7643d3a branch: prepare delete_branches for a bulk caller
     @@ Metadata
       ## Commit message ##
          branch: prepare delete_branches for a bulk caller
      
     -    Add no_head_fallback and dry_run flags to delete_branches() so a
     -    bulk caller (the upcoming --prune-merged) can ask strictly about
     -    merged-into-upstream without a silent fallback to HEAD, and
     -    rehearse deletions with the same "Would delete branch ..." wording
     -    as the live run. Existing callers pass 0 for both and keep current
     -    behavior.
     -
     -    When no_head_fallback is set, head_rev stays NULL through to
     -    branch_merged(), whose "merged to X but not yet merged to HEAD"
     -    reminder otherwise compares against HEAD. For the bulk caller
     -    every candidate is known to have an upstream, so HEAD is
     -    irrelevant. Guard the block on head_rev so the NULL case skips
     -    it instead of treating "NULL != reference_rev" as "diverges from
     -    HEAD" and emitting a spurious warning.
     +    Teach delete_branches() two new modes for the upcoming
     +    --prune-merged: one that asks only whether a branch is merged into
     +    its upstream, without falling back to HEAD when there is no
     +    upstream, and one that rehearses the deletions without removing any
     +    ref. Existing callers keep their current behavior.
      
          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
      
     @@ builtin/branch.c: static int branch_merged(int kind, const char *name,
       		if (expect < 0)
       			exit(128);
       		if (expect == merged)
     -@@ builtin/branch.c: static void delete_branch_config(const char *branchname)
     - }
     +@@ builtin/branch.c: enum delete_branch_flags {
     + 	DELETE_BRANCH_FORCE = (1 << 0),
     + 	DELETE_BRANCH_QUIET = (1 << 1),
     + 	DELETE_BRANCH_WARN_ONLY = (1 << 2),
     ++	DELETE_BRANCH_NO_HEAD_FALLBACK = (1 << 3),
     ++	DELETE_BRANCH_DRY_RUN = (1 << 4),
     + };
       
     - static int delete_branches(int argc, const char **argv, int force, int kinds,
     --			   int quiet, int warn_only)
     -+			   int quiet, int warn_only, int no_head_fallback,
     -+			   int dry_run)
     - {
     - 	struct commit *head_rev = NULL;
     - 	struct object_id oid;
     -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
     + static int check_branch_commit(const char *branchname, const char *refname,
     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
     + 	int remote_branch = 0;
     + 	int force = flags & DELETE_BRANCH_FORCE;
     + 	int quiet = flags & DELETE_BRANCH_QUIET;
     ++	int dry_run = flags & DELETE_BRANCH_DRY_RUN;
     ++	int no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;
     + 	struct strbuf bname = STRBUF_INIT;
     + 	enum interpret_branch_kind allowed_interpret;
     + 	struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
       	}
       	branch_name_pos = strcspn(fmt, "%");
       
     @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo
       		head_rev = lookup_commit_reference(the_repository, &head_oid);
       
       	for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
     -@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int kinds,
       		free(target);
       	}
       
     @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo
       			char *refname = name + branch_name_pos;
       			if (!quiet)
       				printf(remote_branch
     -@@ builtin/branch.c: int cmd_branch(int argc,
     - 		if (!argc)
     - 			die(_("branch name required"));
     - 		ret = delete_branches(argc, argv, delete > 1, filter.kind,
     --				      quiet, 0);
     -+				      quiet, 0, 0, 0);
     - 		goto out;
     - 	} else if (show_current) {
     - 		print_current_branch_name();
 4:  cccfdb831c ! 4:  5f913c445c branch: add --prune-merged <branch>
     @@ Commit message
                  git branch --prune-merged <branch>...
      
          deletes the local branches that "--forked <branch>" would list,
     -    restricted to those whose tip is reachable from their configured
     +    keeping only those whose tip is reachable from their configured
          upstream: the work has already landed on the upstream they track,
          so the local copy is no longer needed.
      
     -    Reachability is read from local refs; nothing is fetched. Users
     -    who want fresh upstream refs run "git fetch" first.
     +    Reachability is read from local refs; nothing is fetched. Run
     +    "git fetch" first if you want fresh upstream refs.
      
     -    Three classes of branches are spared:
     +    Three kinds of branches are spared:
      
            * any branch checked out in any worktree;
     -      * any branch whose upstream no longer resolves locally (its
     -        disappearance is not, on its own, evidence of integration);
     +      * any branch whose upstream no longer resolves locally, since a
     +        missing upstream is not by itself a sign of integration;
            * any branch whose push destination equals its upstream
     -        (<branch>@{push} == <branch>@{upstream}). Such a branch
     -        cannot be distinguished from a freshly pulled trunk that
     -        just looks "fully merged", e.g. local "main" tracking and
     -        pushing to "origin/main" right after a pull. Only branches
     -        that push somewhere other than their upstream (typically
     -        topics in a fork-based workflow) are treated as candidates.
     +        (<branch>@{push} is the same as <branch>@{upstream}), such as
     +        a local "main" that tracks and pushes to "origin/main". Right
     +        after a pull it just looks "fully merged", so it is left
     +        alone. Only branches that push somewhere other than their
     +        upstream, typically topics in a fork workflow, are candidates.
      
     -    Deletion goes through the existing delete_branches() in warn-only
     -    mode and with the HEAD-fallback disabled: a branch that is not
     -    yet fully merged to its upstream is reported as a one-line warning
     -    and skipped, so a single un-mergeable topic does not abort the
     -    whole sweep. We only act on upstream-merged status.
     +    Branches that are not yet merged into their upstream are reported
     +    as a short warning and skipped, so one unmerged topic does not
     +    abort the whole sweep.
      
          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
      
     @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
       git branch (-c|-C) [<old-branch>] <new-branch>
       git branch (-d|-D) [-r] <branch-name>...
       git branch --edit-description [<branch-name>]
     -+git branch (--prune-merged <branch>)...
     ++git branch --prune-merged <branch>...
       
       DESCRIPTION
       -----------
      @@ Documentation/git-branch.adoc: This option is only applicable in non-verbose mode.
     - 	`master`) or a shell-style glob (e.g. `'origin/*'`). The
     - 	option can be repeated to widen the filter.
     + 	Print the name of the current branch. In detached `HEAD` state,
     + 	nothing is printed.
       
     -+`--prune-merged <branch>`::
     ++`--prune-merged <branch>...`::
      +	Delete the local branches that `--forked` would list for the
     -+	same _<branch>_, but only those whose tip is reachable from
     -+	their configured upstream. In other words, the work on the
     -+	branch has already landed on the upstream it tracks, so the
     -+	local copy is no longer needed. May be given more than once to
     -+	union the matches; positional arguments are not accepted.
     ++	given _<branch>_ arguments, but only those whose tip is
     ++	reachable from their configured upstream. In other words, the
     ++	work on the branch has already landed on the upstream it
     ++	tracks, so the local copy is no longer needed. Several
     ++	_<branch>_ patterns may be given, e.g. `git branch
     ++	--prune-merged origin/main 'feature*'`.
      ++
      +Reachability is checked against whatever the upstream refs say
      +locally; nothing is fetched. Run `git fetch` first if you want
     @@ builtin/branch.c: static const char * const builtin_branch_usage[] = {
       	N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
       	N_("git branch [<options>] [-r | -a] [--points-at]"),
       	N_("git branch [<options>] [-r | -a] [--format]"),
     -+	N_("git branch [<options>] (--prune-merged <branch>)..."),
     ++	N_("git branch [<options>] --prune-merged <branch>..."),
       	NULL
       };
       
     -@@ builtin/branch.c: static int upstream_matches(const char *short_upstream,
     +@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const char *arg, int unset
       	return 0;
       }
       
     --static int branch_upstream_matches(const char *full_refname,
     -+static int branch_upstream_matches(const char *short_branch_name,
     - 				   const struct upstream_pattern *patterns,
     - 				   size_t nr_patterns)
     - {
     --	const char *short_name;
     --	struct branch *branch;
     -+	struct branch *branch = branch_get(short_branch_name);
     - 	const char *upstream;
     - 
     --	if (!skip_prefix(full_refname, "refs/heads/", &short_name))
     --		return 0;
     --	branch = branch_get(short_name);
     - 	if (!branch)
     - 		return 0;
     - 	upstream = branch_get_upstream(branch, NULL);
     -@@ builtin/branch.c: static void filter_array_by_forked(struct ref_array *array,
     - 
     - 	for (i = 0; i < array->nr; i++) {
     - 		struct ref_array_item *item = array->items[i];
     --		if (branch_upstream_matches(item->refname,
     --					    patterns, nr_patterns))
     -+		const char *short_name;
     -+		if (skip_prefix(item->refname, "refs/heads/", &short_name) &&
     -+		    branch_upstream_matches(short_name, patterns, nr_patterns))
     - 			array->items[kept++] = item;
     - 		else
     - 			free_ref_array_item(item);
     -@@ builtin/branch.c: static void filter_array_by_forked(struct ref_array *array,
     - 	upstream_pattern_list_clear(patterns, nr_patterns);
     - }
     - 
     -+struct forked_cb {
     -+	const struct upstream_pattern *patterns;
     -+	size_t nr_patterns;
     -+	struct string_list *out;
     -+};
     -+
     -+static int collect_forked_branch(const struct reference *ref, void *cb_data)
     -+{
     -+	struct forked_cb *cb = cb_data;
     -+
     -+	if (ref->flags & REF_ISSYMREF)
     -+		return 0;
     -+	if (branch_upstream_matches(ref->name, cb->patterns, cb->nr_patterns))
     -+		string_list_append(cb->out, ref->name);
     -+	return 0;
     -+}
     -+
     -+static void collect_forked_set(const struct string_list *upstreams,
     -+			       struct string_list *out)
     -+{
     -+	struct upstream_pattern *patterns = NULL;
     -+	size_t nr_patterns = 0;
     -+	struct forked_cb cb;
     -+
     -+	parse_forked_args(upstreams, &patterns, &nr_patterns);
     -+	cb.patterns = patterns;
     -+	cb.nr_patterns = nr_patterns;
     -+	cb.out = out;
     -+
     -+	refs_for_each_branch_ref(get_main_ref_store(the_repository),
     -+				 collect_forked_branch, &cb);
     -+
     -+	string_list_sort(out);
     -+
     -+	upstream_pattern_list_clear(patterns, nr_patterns);
     -+}
     -+
     -+static int prune_merged_branches(const struct string_list *upstreams,
     ++static int prune_merged_branches(int argc, const char **argv,
      +				 int quiet)
      +{
      +	struct ref_store *refs = get_main_ref_store(the_repository);
     -+	struct string_list candidates = STRING_LIST_INIT_DUP;
     ++	struct ref_filter filter = REF_FILTER_INIT;
     ++	struct ref_array candidates;
      +	struct strvec deletable = STRVEC_INIT;
     -+	struct string_list_item *item;
     -+	int ret = 0;
     ++	int i, ret = 0;
      +
     -+	if (!upstreams->nr)
     ++	if (!argc)
      +		die(_("--prune-merged requires at least one <branch>"));
      +
     -+	collect_forked_set(upstreams, &candidates);
     ++	for (i = 0; i < argc; i++)
     ++		if (ref_filter_forked_add(&filter, argv[i]) < 0)
     ++			die(_("'%s' is not a valid branch or pattern"), argv[i]);
      +
     -+	for_each_string_list_item(item, &candidates) {
     -+		const char *short_name = item->string;
     -+		struct branch *branch = branch_get(short_name);
     ++	filter.kind = FILTER_REFS_BRANCHES;
     ++	memset(&candidates, 0, sizeof(candidates));
     ++	filter_refs(&candidates, &filter, filter.kind);
     ++
     ++	for (i = 0; i < candidates.nr; i++) {
     ++		const char *full_name = candidates.items[i]->refname;
     ++		const char *short_name;
     ++		struct branch *branch;
      +		const char *upstream, *push;
     -+		struct strbuf full = STRBUF_INIT;
     -+		int skip;
      +
     -+		strbuf_addf(&full, "refs/heads/%s", short_name);
     -+		skip = !!branch_checked_out(full.buf);
     -+		strbuf_release(&full);
     -+		if (skip)
     ++		if (!skip_prefix(full_name, "refs/heads/", &short_name))
     ++			continue;
     ++		if (branch_checked_out(full_name))
      +			continue;
      +
     ++		branch = branch_get(short_name);
      +		upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
      +		if (!upstream || !refs_ref_exists(refs, upstream))
      +			continue;
     @@ builtin/branch.c: static void filter_array_by_forked(struct ref_array *array,
      +
      +	if (deletable.nr)
      +		ret = delete_branches(deletable.nr, deletable.v,
     -+				      0, /* force */
      +				      FILTER_REFS_BRANCHES,
     -+				      quiet,
     -+				      1, /* warn_only */
     -+				      1, /* no_head_fallback */
     -+				      0  /* dry_run */);
     ++				      DELETE_BRANCH_WARN_ONLY |
     ++				      DELETE_BRANCH_NO_HEAD_FALLBACK |
     ++				      (quiet ? DELETE_BRANCH_QUIET : 0));
      +
      +	strvec_clear(&deletable);
     -+	string_list_clear(&candidates, 0);
     ++	ref_array_clear(&candidates);
     ++	ref_filter_clear(&filter);
      +	return ret;
      +}
      +
     @@ builtin/branch.c: static void filter_array_by_forked(struct ref_array *array,
       
       static int edit_branch_description(const char *branch_name)
      @@ builtin/branch.c: int cmd_branch(int argc,
     + 	/* possible actions */
       	int delete = 0, rename = 0, copy = 0, list = 0,
       	    unset_upstream = 0, show_current = 0, edit_description = 0;
     - 	struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
     -+	struct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;
     ++	int prune_merged = 0;
       	const char *new_upstream = NULL;
       	int noncreate_actions = 0;
       	/* possible options */
      @@ builtin/branch.c: int cmd_branch(int argc,
     + 		OPT_BOOL(0, "create-reflog", &reflog, N_("create the branch's reflog")),
     + 		OPT_BOOL(0, "edit-description", &edit_description,
       			 N_("edit the description for the branch")),
     - 		OPT_STRING_LIST(0, "forked", &forked_upstreams, N_("branch"),
     - 			N_("list local branches whose upstream matches <branch> (repeatable)")),
     -+		OPT_STRING_LIST(0, "prune-merged", &prune_merged_upstreams, N_("branch"),
     -+			N_("delete local branches whose upstream matches <branch> and is merged (repeatable)")),
     ++		OPT_BOOL(0, "prune-merged", &prune_merged,
     ++			N_("delete local branches whose upstream matches <branch> and is merged")),
       		OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
       		OPT_MERGED(&filter, N_("print only branches that are merged")),
       		OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
     @@ builtin/branch.c: int cmd_branch(int argc,
       
       	if (!delete && !rename && !copy && !edit_description && !new_upstream &&
      -	    !show_current && !unset_upstream && argc == 0)
     -+	    !show_current && !unset_upstream && !prune_merged_upstreams.nr &&
     ++	    !show_current && !unset_upstream && !prune_merged &&
      +	    argc == 0)
       		list = 1;
       
     @@ builtin/branch.c: int cmd_branch(int argc,
       	noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
       			    !!show_current + !!list + !!edit_description +
      -			    !!unset_upstream;
     -+			    !!unset_upstream + !!prune_merged_upstreams.nr;
     ++			    !!unset_upstream + !!prune_merged;
       	if (noncreate_actions > 1)
       		usage_with_options(builtin_branch_usage, options);
       
      @@ builtin/branch.c: int cmd_branch(int argc,
     - 		ret = delete_branches(argc, argv, delete > 1, filter.kind,
     - 				      quiet, 0, 0, 0);
     + 				      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |
     + 				      (quiet ? DELETE_BRANCH_QUIET : 0));
       		goto out;
     -+	} else if (prune_merged_upstreams.nr) {
     -+		if (argc)
     -+			die(_("--prune-merged does not take positional arguments; "
     -+			      "repeat --prune-merged for each <branch>"));
     -+		ret = prune_merged_branches(&prune_merged_upstreams, quiet);
     ++	} else if (prune_merged) {
     ++		ret = prune_merged_branches(argc, argv, quiet);
      +		goto out;
       	} else if (show_current) {
       		print_current_branch_name();
       		ret = 0;
     -@@ builtin/branch.c: int cmd_branch(int argc,
     - out:
     - 	string_list_clear(&sorting_options, 0);
     - 	string_list_clear(&forked_upstreams, 0);
     -+	string_list_clear(&prune_merged_upstreams, 0);
     - 	return ret;
     - }
      
       ## t/t3200-branch.sh ##
      @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
      +	git -C pm-union branch --set-upstream-to=origin/main two &&
      +	git -C pm-union checkout --detach &&
      +
     -+	git -C pm-union branch --prune-merged origin/next --prune-merged origin/main &&
     ++	git -C pm-union branch --prune-merged origin/next origin/main &&
      +
      +	test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
      +	test_must_fail git -C pm-union rev-parse --verify refs/heads/two
     @@ t/t3200-branch.sh: test_expect_success '--forked requires a value' '
      +	test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/topic
      +'
      +
     -+test_expect_success '--prune-merged requires a value' '
     ++test_expect_success '--prune-merged requires at least one <branch>' '
      +	test_must_fail git -C forked branch --prune-merged 2>err &&
     -+	test_grep "requires a value" err
     ++	test_grep "requires at least one <branch>" err
      +'
      +
     -+test_expect_success '--prune-merged rejects positional arguments' '
     -+	test_must_fail git -C forked branch --prune-merged origin/one other/foreign 2>err &&
     -+	test_grep "does not take positional arguments" err
     ++test_expect_success '--prune-merged takes positional <branch> arguments' '
     ++	test_when_finished "rm -rf pm-positional" &&
     ++	git clone pm-upstream pm-positional &&
     ++	git -C pm-positional remote add fork ../pm-fork &&
     ++	test_config -C pm-positional remote.pushDefault fork &&
     ++	test_config -C pm-positional push.default current &&
     ++	git -C pm-positional branch one one-commit &&
     ++	git -C pm-positional branch --set-upstream-to=origin/next one &&
     ++	git -C pm-positional branch two base &&
     ++	git -C pm-positional branch --set-upstream-to=origin/main two &&
     ++	git -C pm-positional checkout --detach &&
     ++
     ++	git -C pm-positional branch --prune-merged origin/next origin/main &&
     ++
     ++	test_must_fail git -C pm-positional rev-parse --verify refs/heads/one &&
     ++	test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
      +'
      +
       test_done
 5:  5f793f8d0d ! 5:  8e9a735ffe branch: add branch.<name>.pruneMerged opt-out
     @@ Commit message
          branch: add branch.<name>.pruneMerged opt-out
      
          Setting branch.<name>.pruneMerged=false exempts that branch from
     -    "git branch --prune-merged". Useful for a topic branch you want
     -    to develop further after an initial round has been merged
     -    upstream.
     +    "git branch --prune-merged", which is useful for a topic you want
     +    to keep developing after an early round of it has been merged
     +    upstream. Unless --quiet is given, each skip is reported so the
     +    user knows why their topic was kept.
      
     -    Unless --quiet is given, the skip is reported per branch so the
     -    user knows why their topic was preserved.
     -
     -    Explicit deletion via "git branch -d" continues to consult the
     -    normal merge check and is not affected by this setting.
     +    Explicit deletion with "git branch -d" still uses the normal merge
     +    check and ignores this setting.
      
          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
      
     @@ Documentation/git-branch.adoc: the upstream refs refreshed.
       warnings and skipped; pass them to `git branch -D` explicitly if
      
       ## builtin/branch.c ##
     -@@ builtin/branch.c: static int prune_merged_branches(const struct string_list *upstreams,
     - 		struct branch *branch = branch_get(short_name);
     +@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
     + 		const char *short_name;
     + 		struct branch *branch;
       		const char *upstream, *push;
     - 		struct strbuf full = STRBUF_INIT;
      +		struct strbuf key = STRBUF_INIT;
     - 		int skip;
      +		int opt_out;
       
     - 		strbuf_addf(&full, "refs/heads/%s", short_name);
     - 		skip = !!branch_checked_out(full.buf);
     -@@ builtin/branch.c: static int prune_merged_branches(const struct string_list *upstreams,
     + 		if (!skip_prefix(full_name, "refs/heads/", &short_name))
     + 			continue;
     +@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
       		if (!push || !strcmp(push, upstream))
       			continue;
       
     @@ builtin/branch.c: static int prune_merged_branches(const struct string_list *ups
       
      
       ## t/t3200-branch.sh ##
     -@@ t/t3200-branch.sh: test_expect_success '--prune-merged rejects positional arguments' '
     - 	test_grep "does not take positional arguments" err
     +@@ t/t3200-branch.sh: test_expect_success '--prune-merged takes positional <branch> arguments' '
     + 	test_must_fail git -C pm-positional rev-parse --verify refs/heads/two
       '
       
      +test_expect_success '--prune-merged honours branch.<name>.pruneMerged=false' '
 6:  1a0d5eab15 ! 6:  511de4788e branch: add --dry-run for --prune-merged
     @@ Commit message
          branch: add --dry-run for --prune-merged
      
          With --dry-run, --prune-merged prints the local branches it would
     -    delete, one "Would delete branch <name>" line per candidate, and
     -    exits without touching any ref.
     +    delete, one "Would delete branch <name>" line each, and exits
     +    without touching any ref. The same filtering applies, so the output
     +    is exactly the set that the real run would delete.
      
     -    The @{push}-vs-@{upstream} and unmerged filtering still applies,
     -    so the dry-run output is exactly the set that the live run would
     -    delete.
     -
     -    --dry-run is only meaningful in combination with --prune-merged
     -    and is rejected otherwise.
     +    --dry-run is only meaningful together with --prune-merged and is
     +    rejected otherwise.
      
          Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
      
     @@ Documentation/git-branch.adoc: git branch (-m|-M) [<old-branch>] <new-branch>
       git branch (-c|-C) [<old-branch>] <new-branch>
       git branch (-d|-D) [-r] <branch-name>...
       git branch --edit-description [<branch-name>]
     --git branch (--prune-merged <branch>)...
     -+git branch [--dry-run] (--prune-merged <branch>)...
     +-git branch --prune-merged <branch>...
     ++git branch [--dry-run] --prune-merged <branch>...
       
       DESCRIPTION
       -----------
     @@ Documentation/git-branch.adoc: Branches refused by the "fully merged" safety che
       `--verbose`::
      
       ## builtin/branch.c ##
     -@@ builtin/branch.c: static void collect_forked_set(const struct string_list *upstreams,
     +@@ builtin/branch.c: static int parse_opt_forked(const struct option *opt, const char *arg, int unset
       }
       
     - static int prune_merged_branches(const struct string_list *upstreams,
     + static int prune_merged_branches(int argc, const char **argv,
      -				 int quiet)
      +				 int quiet, int dry_run)
       {
       	struct ref_store *refs = get_main_ref_store(the_repository);
     - 	struct string_list candidates = STRING_LIST_INIT_DUP;
     -@@ builtin/branch.c: static int prune_merged_branches(const struct string_list *upstreams,
     - 				      quiet,
     - 				      1, /* warn_only */
     - 				      1, /* no_head_fallback */
     --				      0  /* dry_run */);
     -+				      dry_run);
     + 	struct ref_filter filter = REF_FILTER_INIT;
     +@@ builtin/branch.c: static int prune_merged_branches(int argc, const char **argv,
     + 				      FILTER_REFS_BRANCHES,
     + 				      DELETE_BRANCH_WARN_ONLY |
     + 				      DELETE_BRANCH_NO_HEAD_FALLBACK |
     +-				      (quiet ? DELETE_BRANCH_QUIET : 0));
     ++				      (quiet ? DELETE_BRANCH_QUIET : 0) |
     ++				      (dry_run ? DELETE_BRANCH_DRY_RUN : 0));
       
       	strvec_clear(&deletable);
     - 	string_list_clear(&candidates, 0);
     + 	ref_array_clear(&candidates);
      @@ builtin/branch.c: int cmd_branch(int argc,
     + 	int delete = 0, rename = 0, copy = 0, list = 0,
       	    unset_upstream = 0, show_current = 0, edit_description = 0;
     - 	struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
     - 	struct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;
     + 	int prune_merged = 0;
      +	int dry_run = 0;
       	const char *new_upstream = NULL;
       	int noncreate_actions = 0;
       	/* possible options */
      @@ builtin/branch.c: int cmd_branch(int argc,
     - 			N_("list local branches whose upstream matches <branch> (repeatable)")),
     - 		OPT_STRING_LIST(0, "prune-merged", &prune_merged_upstreams, N_("branch"),
     - 			N_("delete local branches whose upstream matches <branch> and is merged (repeatable)")),
     + 			 N_("edit the description for the branch")),
     + 		OPT_BOOL(0, "prune-merged", &prune_merged,
     + 			N_("delete local branches whose upstream matches <branch> and is merged")),
      +		OPT_BOOL(0, "dry-run", &dry_run,
      +			N_("with --prune-merged, only print which branches would be deleted")),
       		OPT__FORCE(&force, N_("force creation, move/rename, deletion"), PARSE_OPT_NOCOMPLETE),
     @@ builtin/branch.c: int cmd_branch(int argc,
       	if (noncreate_actions > 1)
       		usage_with_options(builtin_branch_usage, options);
       
     -+	if (dry_run && !prune_merged_upstreams.nr)
     ++	if (dry_run && !prune_merged)
      +		die(_("--dry-run requires --prune-merged"));
      +
       	if (recurse_submodules_explicit) {
       		if (!submodule_propagate_branches)
       			die(_("branch with --recurse-submodules can only be used if submodule.propagateBranches is enabled"));
      @@ builtin/branch.c: int cmd_branch(int argc,
     - 		if (argc)
     - 			die(_("--prune-merged does not take positional arguments; "
     - 			      "repeat --prune-merged for each <branch>"));
     --		ret = prune_merged_branches(&prune_merged_upstreams, quiet);
     -+		ret = prune_merged_branches(&prune_merged_upstreams, quiet, dry_run);
     + 				      (quiet ? DELETE_BRANCH_QUIET : 0));
     + 		goto out;
     + 	} else if (prune_merged) {
     +-		ret = prune_merged_branches(argc, argv, quiet);
     ++		ret = prune_merged_branches(argc, argv, quiet, dry_run);
       		goto out;
       	} else if (show_current) {
       		print_current_branch_name();

-- 
gitgitgadget

^ permalink raw reply

* Re: [PATCH] docs: fix typos
From: Weijie Yuan @ 2026-06-05 18:26 UTC (permalink / raw)
  To: Tuomas Ahola, Kristoffer Haugsbakk; +Cc: git
In-Reply-To: <20260604165557.MWQuu%taahol@utu.fi>

On Thu, Jun 04, 2026 at 07:55:57PM +0300, Tuomas Ahola wrote:
> "Kristoffer Haugsbakk" <kristofferhaugsbakk@fastmail.com> wrote:
> 
> > On Thu, Jun 4, 2026, at 15:14, Tuomas Ahola wrote:
> > > [PATCH] docs: fix typos
> > 
> > The area `docs` isn´t correct since you are also changing comments in
> > source files.
> > 
> > `*` could be used (as in a wildcard). Other people have used other
> > things for "treewide" changes.
> > 
> 
> Hmm, I took that from the other typofix patches we have currently in `seen`.

Hi all,

Yup, my patch 8570d9ba31 (Merge branch 'wy/docs-typofixes' into seen, 2026-06-04)
used "docs" as the scope of the commit message.

I thought for a while what would be the preferred scope for a typofixes
commit that touches a quite wide range of the tree. And I didn't know we
could use "*".

On the one hand, I would rather let the typofix thing be simple and easy
in one commit, since the actual changes were sort of logically unified.
On the other hand, splitting them apart makes it easier to name the
scope of each commit, while it would make much "meaningless" noise. So I
was torn at that time.

"Luckily", Junio didn't say something about that, and my topic is
waiting for more comments. So it just happens when I am struggling with
re-roll it or not, I saw this thread. Therefore, could you please also
raise it again in my topic in order to  give it a final decision? (Or
start a new thread as my patch is actually in Andrew's thread, really
sorry Andrew)

Message-ID of my previous patch:
<7b502e20e9495cd4720496bd6738a1fbeb453410.1780041658.git.wy@wyuan.org>

Thanks!

^ permalink raw reply

* Re: [PATCH v12 1/6] branch: add --forked filter for --list mode
From: Harald Nordgren @ 2026-06-05 17:50 UTC (permalink / raw)
  To: phillip.wood
  Cc: Harald Nordgren via GitGitGadget, git, Kristoffer Haugsbakk,
	Johannes Sixt
In-Reply-To: <9f5c36a9-a3b8-403d-9c59-40367eb895bd@gmail.com>

Hi Phillip!

Great points all around, I will take a look at implementing them. I'll
respond here instead of for each specific message, and then include
comments as part of the next version.

> > Add a --forked option to "git branch" list mode that keeps only
> > branches whose configured upstream matches <branch>. The argument
> > can be a ref (e.g. "origin/main", "master") or a shell-style
> > glob (e.g. "origin/*"). The option can be repeated to widen the
> > filter.
>
> Do we want to support a remote name as an alias for $remote/HEAD to
> match "git checkout -b $remote"?

I have been going back and forth on this, and while I like the bare
remote, it made the implementation a lot easier after it was removed,
as the arguments from some of the others made sense to me.


Harald

^ permalink raw reply

* Re: [GSoC PATCH v2 0/4] teach git repo info to handle path keys
From: Lucas Seiki Oshiro @ 2026-06-05 17:35 UTC (permalink / raw)
  To: K Jayatheerth
  Cc: git, a3205153416, gitster, jltobler, kumarayushjha123,
	phillip.wood, sandals
In-Reply-To: <20260605163012.181089-1-jayatheerthkulkarni2005@gmail.com>


> * About lexicographical order: "Breaking" wasn't the right term
>  before, but I do believe keeping .absolute and .relative as
>  suffixes is a better choice. I prefer having the two choices
>  side-by-side grouped by entity, rather than a cluster of absolute
>  keys followed by relative ones. Open to hearing if the latter is
>  preferred!

I prefer `.(absolute|relative)` at the end. `path.gitdir.relative`
means that we have a collection of paths, in those collections we
have gitdir that can be relative or absolute, and we want the
relative. `path.relative.gitdir` means that we have a collection
of relative paths and from those we're picking gitdir. The first
feels more natural.

> Thanks for this round of feedback guys, this has been fruitful!

Thanks again for your interest in improving `git repo info`!
I'll review your patchset with more attention later.

PS: this is a nitpick, but it would be really helpful if you provide
a range-diff in the cover letter. Check the usage of `--range-diff`
in git-format-patch documentation (this flag also works for
git-send-email). Or, if you prefer, you can generate it by running
`git range-diff` and copying the output.

^ permalink raw reply

* [PATCH] doc: fix typo in GIT_ALTERNATE_OBJECT_DIRECTORIES
From: Alexander Monakov @ 2026-06-05 17:26 UTC (permalink / raw)
  To: git

One file accidentally spelled GIT_ALTERNATE_OBJECT_DIRECTORIES with
REPOSITORIES instead of DIRECTORIES. Fix the typo.

Signed-off-by: Alexander Monakov <amonakov@ispras.ru>
---
 Documentation/technical/hash-function-transition.adoc | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/Documentation/technical/hash-function-transition.adoc b/Documentation/technical/hash-function-transition.adoc
index 2359d7d106..241d2f763d 100644
--- a/Documentation/technical/hash-function-transition.adoc
+++ b/Documentation/technical/hash-function-transition.adoc
@@ -545,7 +545,7 @@ Alternates
 ~~~~~~~~~~
 For the same reason, a SHA-256 repository cannot borrow objects from a
 SHA-1 repository using objects/info/alternates or
-$GIT_ALTERNATE_OBJECT_REPOSITORIES.
+$GIT_ALTERNATE_OBJECT_DIRECTORIES.
 
 git notes
 ~~~~~~~~~
-- 
2.49.1


^ permalink raw reply related

* Re: [PATCH] worktree: record creation time and free-form note
From: Chris Torek @ 2026-06-05 16:57 UTC (permalink / raw)
  To: phillip.wood; +Cc: Kiesel, Norbert, git
In-Reply-To: <b1b15a47-0842-4a26-9a95-bfdae12799e0@gmail.com>

On Fri, Jun 5, 2026 at 8:31 AM Phillip Wood <phillip.wood123@gmail.com> wrote:
> Isn't "what is the worktree for" a property of the branch that's checked
> out, not the worktree itself?

I don't think it is.

A lot of things within Git have, shall way say, "less than optimal"
names, with "branch" (with at least three different meanings),
"HEAD", and "index" being examples of this. (This is just an
observation, not a complaint: we know from studies that
oddities in names don't matter that much after a bit of usage
of some system. They're just minor stumbling blocks when
getting started.)

Work-tree or working tree is not one of them, though. It's
concise and pointed: a working tree is where you do work.

As such, the *purpose* of a working tree is exactly as general
as the purpose of doing work! That's a wide-open set.

Git's internal constraint, of requiring each working tree that
is using a branch name to have a unique-to-that-tree branch
name, is a property specific to branch names, not to branching
in general (an example of the ambiguity of "branch" here).
And of course, as you note, any working tree can be on
a detached HEAD.

Exactly what properties any given working tree should
have, and the weird entanglement Git has between the
"primary" working tree (the one created by any non-bare
clone) and all "secondary" working trees, is a mere (ahem)
matter of implementation. Descriptions, creation times,
modification times, etc., are all potentially useful.

I think, had Git initially made all repositories effectively
bare, with separate working trees added later, this might
all be a little clearer, but of course that ship sailed,
crossed *all* the oceans, sank, was refloated and refitted,
and sailed for another decade already. :-)

Chris

^ permalink raw reply

* Re: [GSoC PATCH v2 1/4] path: introduce format_path() for centralized path formatting
From: Kristoffer Haugsbakk @ 2026-06-05 16:55 UTC (permalink / raw)
  To: JAYATHEERTH K, git
  Cc: a3205153416, Junio C Hamano, Justin Tobler, kumarayushjha123,
	Lucas Seiki Oshiro, Phillip Wood, brian m. carlson
In-Reply-To: <20260605163012.181089-2-jayatheerthkulkarni2005@gmail.com>

On Fri, Jun 5, 2026, at 18:30, K Jayatheerth wrote:
> The path-formatting logic inside `builtin/rev-parse.c` handles absolute,
> canonical, and relative formatting rules based on user-supplied options.
> However, this logic is tightly coupled to `rev-parse` and writes directly
> to stdout.
>
> To allow other builtins (such as the upcoming `git repo` path keys) to
> re-use this logic, extract the core path-formatting algorithm into a centralized
> helper function, `format_path()`, in `path.c`.
>
> Expose a single, streamlined `path_format` enum in `path.h` to let callers
> explicitly declare their formatting strategy (UNMODIFIED, RELATIVE,
> RELATIVE_IF_SHARED, or CANONICAL). This decouples the core algorithm from
> the localized fallback mechanics specific to `rev-parse`.

This looks very well explained to my naive eyes.

>
> Signed-off-by: K Jayatheerth <jayatheerthkulkarni2005@gmail.com>
> Mentored-by: Justin Tobler <jltobler@gmail.com>
> Mentored-by: Lucas Seiki Oshiro <lucasseikioshiro@gmail.com>

Nitpick. You are supposed to add your `Signed-off-by` at the end. You
are saying with that line that you are signing off on the changes and
the commit message, including the trailers (mentors) you’ve decided to
add. Imagine if the maintainer applies this patch and fixes a typo and
the commit becomes:

    Mentored-by: Justin Tobler <jltobler@gmail.com>
    Mentored-by: Lucas Seiki Oshiro <lucasseikioshiro@gmail.com>
    Signed-off-by: K Jayatheerth <jayatheerthkulkarni2005@gmail.com>
    [jc: typo fix]
    Signed-off-by: Junio ...

The chain of custody is then very clear.

> ---
>[snip]

^ permalink raw reply

* Re: [PATCH] worktree: record creation time and free-form note
From: Kristoffer Haugsbakk @ 2026-06-05 16:50 UTC (permalink / raw)
  To: Phillip Wood, Kiesel, Norbert, git
In-Reply-To: <b1b15a47-0842-4a26-9a95-bfdae12799e0@gmail.com>

On Fri, Jun 5, 2026, at 17:17, Phillip Wood wrote:
>>[snip]
>> Add per-worktree metadata so users can answer "what is this worktree
>> for, and when did I make it?" without resorting to external notes.
>
> A couple of thoughts related to this
>
> Isn't "what is the worktree for" a property of the branch that's checked
> out, not the worktree itself? We already have
> branch.<branch>.description to add a descritpion to a branch. If you
> have a detached HEAD it is trickier though.

Some worktrees that I have that are not about one branch:

• For building and deploying the code
• For spelunking old versions
• For testing building the code/app with all untracked files deleted
  (“cleanroom”) to compare with my own setup or my coworkers when
  the regular working trees get apparently gummed up
• (for Git) with leak checker
• (for Git) with breaking changes (when testing git-whatchanged(1) I
  think?)

I would also like to (some time) set up a worktree or multiple of them
to run tests on each commit.

***

If I would feel like needing a note about these worktrees and it wasn’t
supported by git(1)? I guess I would use a per-worktree ref. That would
even feel more “Git” than an administrative file, but I guess there are
technical reasons for why they are not used for these things.

>[snip]

^ permalink raw reply

* [PATCH] ref-filter: restore prefix-scoped iteration
From: Tamir Duberstein @ 2026-06-05 16:43 UTC (permalink / raw)
  To: git
  Cc: Karthik Nayak, Patrick Steinhardt, Junio C Hamano, Victoria Dye,
	ZheNing Hu, Tamir Duberstein

Commit dabecb9db2 (for-each-ref: introduce a '--start-after' option,
2025-07-15) changed single-kind branch, remote-tracking branch, and tag
enumeration in do_filter_refs() from constructing an iterator with the
namespace prefix to constructing an unscoped iterator and applying the
prefix with ref_iterator_seek().

Before that change, refs_for_each_fullref_in() passed the namespace
prefix during iterator construction. That helper has since been
replaced by refs_for_each_ref_ext().

The files backend primes its loose-ref cache for the construction
prefix before it opens packed refs. An empty construction prefix
therefore reads every loose ref, and a later seek cannot undo that I/O.
Consequently, git branch, git branch --remotes, and git tag scale with
unrelated loose refs.

Patrick Steinhardt observed during review that iterator construction
and seeking accepted similar strings but assigned them different state
semantics. Junio C Hamano then pointed out that no current command can
combine start_after with this single-kind path, but future branch or
tag support would need to keep the namespace while moving the cursor.

Keep the existing start_after path unchanged. The iterator API cannot
currently seek to one string while retaining another as its prefix:
an unflagged seek clears the prefix, while REF_ITERATOR_SEEK_SET_PREFIX
replaces it with the seek string.

For the commands affected by this regression, which do not set
start_after, pass the namespace prefix during iterator construction so
that loose refs are scoped before the packed-refs snapshot is opened.
This fixes the current regression without deleting the ref-filter state
discussed during review or changing its dormant behavior.

Add REFFILES-gated performance cases with one branch, one
remote-tracking branch, one tag, and 10,000 unrelated loose refs. The
benchmarks were run with:

    GIT_PERF_REPEAT_COUNT=5 GIT_PERF_MAKE_OPTS=-j8 \
        t/perf/run a89346e34a . -- p6300-for-each-ref.sh

The following are the best of five runs, with each run invoking the
command ten times. Times are elapsed seconds with user and system CPU
seconds in parentheses:

                                  a89346e34a       this commit
  branch                       2.74(0.13+2.56)   0.11(0.04+0.04)
  branch --remotes             2.81(0.13+2.62)   0.12(0.04+0.04)
  tag                          3.01(0.14+2.82)   0.11(0.04+0.04)

Both revisions used the default -O2 build flags and a config.mak
containing only "NO_REGEX = NeedsStartEnd". They were built with Apple
clang 21.0.0 on macOS 26.5. The machine was a MacBook Pro (Mac16,6)
with a 16-core Apple M4 Max (12 performance and four efficiency cores)
and 128 GB RAM.

Link: https://lore.kernel.org/git/aGZidwwlToWThkn8@pks.im/
Link: https://lore.kernel.org/git/xmqqikjq7s16.fsf@gitster.g/
Fixes: dabecb9db2b2 ("for-each-ref: introduce a '--start-after' option")
Assisted-by: Codex gpt-5.5
Signed-off-by: Tamir Duberstein <tamird@gmail.com>
---
The series is based on a89346e34a (maint) because the regression has
been present in released versions since Git 2.51.0.
---
 ref-filter.c                 | 30 +++++++++++++++++++++---------
 t/perf/p6300-for-each-ref.sh | 39 ++++++++++++++++++++++++++++++++++++++-
 2 files changed, 59 insertions(+), 10 deletions(-)

diff --git a/ref-filter.c b/ref-filter.c
index 1da4c0e60d..2388a57b39 100644
--- a/ref-filter.c
+++ b/ref-filter.c
@@ -3315,19 +3315,31 @@ static int do_filter_refs(struct ref_filter *filter, unsigned int type, refs_for
 		prefix = "refs/tags/";
 
 	if (prefix) {
-		struct ref_iterator *iter;
+		if (filter->start_after) {
+			struct ref_iterator *iter;
 
-		iter = refs_ref_iterator_begin(get_main_ref_store(the_repository),
-					       "", NULL, 0, 0);
+			iter = refs_ref_iterator_begin(
+				get_main_ref_store(the_repository), "", NULL, 0,
+				0);
 
-		if (filter->start_after)
 			ret = start_ref_iterator_after(iter, filter->start_after);
-		else
-			ret = ref_iterator_seek(iter, prefix,
-						REF_ITERATOR_SEEK_SET_PREFIX);
+			if (!ret)
+				ret = do_for_each_ref_iterator(iter, fn,
+							       cb_data);
+		} else {
+			/*
+			 * Pass the prefix during construction because the files
+			 * backend primes loose refs before a later seek can
+			 * narrow the iterator.
+			 */
+			struct refs_for_each_ref_options opts = {
+				.prefix = prefix,
+			};
 
-		if (!ret)
-			ret = do_for_each_ref_iterator(iter, fn, cb_data);
+			ret = refs_for_each_ref_ext(
+				get_main_ref_store(the_repository), fn, cb_data,
+				&opts);
+		}
 	} else if (filter->kind & FILTER_REFS_REGULAR) {
 		ret = for_each_fullref_in_pattern(filter, fn, cb_data);
 	}
diff --git a/t/perf/p6300-for-each-ref.sh b/t/perf/p6300-for-each-ref.sh
index fa7289c752..ed9c1c6a19 100755
--- a/t/perf/p6300-for-each-ref.sh
+++ b/t/perf/p6300-for-each-ref.sh
@@ -1,6 +1,6 @@
 #!/bin/sh
 
-test_description='performance of for-each-ref'
+test_description='performance of ref-filter users'
 . ./perf-lib.sh
 
 test_perf_fresh_repo
@@ -84,4 +84,41 @@ test_expect_success 'pack refs' '
 '
 run_tests "packed"
 
+test_expect_success REFFILES 'setup many unrelated loose refs' '
+	git init scoped &&
+	test_commit -C scoped --no-tag base &&
+	test_seq $ref_count_per_type |
+		sed "s,.*,update refs/custom/unrelated_& HEAD," |
+		git -C scoped update-ref --stdin &&
+	git -C scoped update-ref refs/remotes/origin/main HEAD &&
+	git -C scoped update-ref refs/tags/only HEAD
+'
+
+test_perf "branch (many unrelated loose refs)" --prereq REFFILES "
+	(
+		cd scoped &&
+		for i in \$(test_seq $test_iteration_count); do
+			git branch --format='%(refname)' >/dev/null
+		done
+	)
+"
+
+test_perf "branch --remotes (many unrelated loose refs)" --prereq REFFILES "
+	(
+		cd scoped &&
+		for i in \$(test_seq $test_iteration_count); do
+			git branch --remotes --format='%(refname)' >/dev/null
+		done
+	)
+"
+
+test_perf "tag (many unrelated loose refs)" --prereq REFFILES "
+	(
+		cd scoped &&
+		for i in \$(test_seq $test_iteration_count); do
+			git tag --format='%(refname)' >/dev/null
+		done
+	)
+"
+
 test_done

---
base-commit: a89346e34a937f001e5d397ee62224e3e9852040
change-id: 20260605-fix-git-branch-regression-9e4236f18091

Best regards,
--  
Tamir Duberstein <tamird@gmail.com>


^ permalink raw reply related

* [GSoC PATCH v2 4/4] repo: add path.commondir with absolute and relative suffix formatting
From: K Jayatheerth @ 2026-06-05 16:30 UTC (permalink / raw)
  To: git
  Cc: jayatheerthkulkarni2005, a3205153416, gitster, jltobler,
	kumarayushjha123, lucasseikioshiro, phillip.wood, sandals
In-Reply-To: <20260605163012.181089-1-jayatheerthkulkarni2005@gmail.com>

In standard Git repositories, the Git directory and the common directory
are identical. However, in environments utilizing multiple worktrees, the
local working state ($GIT_DIR) is separated from the shared central data
($GIT_COMMON_DIR). Scripts require a reliable way to discover this shared
path.

Introduce `path.commondir.absolute` and `path.commondir.relative` keys
to `git repo info`. Similar to the `path.gitdir` keys, exposing explicit
format variants removes the ambiguity of default fallbacks. Both keys are
evaluated via the `format_path()` engine.

Insert the new keys into the `repo_info_field` array in lexicographical
order to maintain the integrity of binary search lookups.

Utilize the parameterized `test_repo_info_path` helper to validate the
worktree edge cases. This ensures that path resolution correctly respects
$GIT_COMMON_DIR when defined and safely falls back to $GIT_DIR otherwise.

Signed-off-by: K Jayatheerth <jayatheerthkulkarni2005@gmail.com>
Mentored-by: Justin Tobler <jltobler@gmail.com>
Mentored-by: Lucas Seiki Oshiro <lucasseikioshiro@gmail.com>
---
 Documentation/git-repo.adoc |  9 +++++++++
 builtin/repo.c              | 24 ++++++++++++++++++++++++
 t/t1900-repo-info.sh        |  7 +++++++
 3 files changed, 40 insertions(+)

diff --git a/Documentation/git-repo.adoc b/Documentation/git-repo.adoc
index a0dca7ce88..ed7d80c690 100644
--- a/Documentation/git-repo.adoc
+++ b/Documentation/git-repo.adoc
@@ -104,6 +104,15 @@ values that they return:
 `object.format`::
 	The object format (hash algorithm) used in the repository.
 
+`path.commondir.absolute`::
+	The canonical absolute path to the Git repository's common
+	directory (the shared `.git` directory containing objects,
+	refs, and global configuration).
+
+`path.commondir.relative`::
+	The path to the Git repository's common directory relative to
+	the current working directory.
+
 `path.gitdir.absolute`::
 	The canonical absolute path to the Git repository directory (the `.git` directory).
 
diff --git a/builtin/repo.c b/builtin/repo.c
index 6e97f6a0e4..27c8caff38 100644
--- a/builtin/repo.c
+++ b/builtin/repo.c
@@ -77,6 +77,28 @@ static int get_object_format(struct repository *repo, struct strbuf *buf)
 	return 0;
 }
 
+static int get_path_commondir_absolute(struct repository *repo, struct strbuf *buf)
+{
+	const char *common_dir = repo_get_common_dir(repo);
+
+	if (!common_dir)
+		return error(_("unable to get common directory"));
+
+	format_path(buf, common_dir, startup_info->prefix, PATH_FORMAT_CANONICAL);
+	return 0;
+}
+
+static int get_path_commondir_relative(struct repository *repo, struct strbuf *buf)
+{
+	const char *common_dir = repo_get_common_dir(repo);
+
+	if (!common_dir)
+		return error(_("unable to get common directory"));
+
+	format_path(buf, common_dir, startup_info->prefix, PATH_FORMAT_RELATIVE);
+	return 0;
+}
+
 static int get_path_gitdir_absolute(struct repository *repo, struct strbuf *buf)
 {
 	const char *git_dir = repo_get_git_dir(repo);
@@ -111,6 +133,8 @@ static const struct repo_info_field repo_info_field[] = {
 	{ "layout.bare", get_layout_bare },
 	{ "layout.shallow", get_layout_shallow },
 	{ "object.format", get_object_format },
+	{ "path.commondir.absolute", get_path_commondir_absolute },
+	{ "path.commondir.relative", get_path_commondir_relative },
 	{ "path.gitdir.absolute", get_path_gitdir_absolute },
 	{ "path.gitdir.relative", get_path_gitdir_relative },
 	{ "references.format", get_references_format },
diff --git a/t/t1900-repo-info.sh b/t/t1900-repo-info.sh
index 0660b00bbc..21755d9d14 100755
--- a/t/t1900-repo-info.sh
+++ b/t/t1900-repo-info.sh
@@ -186,6 +186,13 @@ test_expect_success 'setup test repository layout for path fields' '
 	mkdir -p test-repo/sub
 '
 
+test_expect_success 'setup custom-common for commondir tests' '
+	git init --bare test-repo/custom-common
+'
+
+test_repo_info_path 'commondir' 'echo "$(cd .. && pwd)/.git"' '../.git'
+test_repo_info_path 'commondir' 'echo "$(cd .. && pwd)/custom-common"' '../custom-common' 'GIT_COMMON_DIR="$(cd .. && pwd)/custom-common" GIT_DIR=../.git'
+test_repo_info_path 'commondir' 'echo "$(cd .. && pwd)/.git"' '../.git' 'GIT_DIR=../.git'
 test_repo_info_path 'gitdir' 'echo "$(cd .. && pwd)/.git"' '../.git'
 
 test_done
-- 
2.54.0


^ permalink raw reply related

* [GSoC PATCH v2 3/4] repo: add path.gitdir with absolute and relative suffix formatting
From: K Jayatheerth @ 2026-06-05 16:30 UTC (permalink / raw)
  To: git
  Cc: jayatheerthkulkarni2005, a3205153416, gitster, jltobler,
	kumarayushjha123, lucasseikioshiro, phillip.wood, sandals
In-Reply-To: <20260605163012.181089-1-jayatheerthkulkarni2005@gmail.com>

Scripts often need to locate the `.git` directory. While `git rev-parse`
provides this, it relies on command-line flags to dictate path formatting.

Introduce `path.gitdir.absolute` and `path.gitdir.relative` keys to
`git repo info`. Exposing separate format-specific keys instead of a base
`path.gitdir` key avoids default fallbacks and requires callers to state
their format requirements explicitly. Both keys use `format_path()` to
resolve paths.

To test these keys, introduce the `test_repo_info_path` helper in
`t/t1900-repo-info.sh`. The helper evaluates paths dynamically and accepts
environment variable prefixes. This prepares the test suite for future path
keys that depend on environment overrides, such as `commondir`.

Signed-off-by: K Jayatheerth <jayatheerthkulkarni2005@gmail.com>
Mentored-by: Justin Tobler <jltobler@gmail.com>
Mentored-by: Lucas Seiki Oshiro <lucasseikioshiro@gmail.com>
---
 Documentation/git-repo.adoc |  6 ++++++
 builtin/repo.c              | 26 ++++++++++++++++++++++++++
 t/t1900-repo-info.sh        | 33 +++++++++++++++++++++++++++++++++
 3 files changed, 65 insertions(+)

diff --git a/Documentation/git-repo.adoc b/Documentation/git-repo.adoc
index 42262c1983..a0dca7ce88 100644
--- a/Documentation/git-repo.adoc
+++ b/Documentation/git-repo.adoc
@@ -104,6 +104,12 @@ values that they return:
 `object.format`::
 	The object format (hash algorithm) used in the repository.
 
+`path.gitdir.absolute`::
+	The canonical absolute path to the Git repository directory (the `.git` directory).
+
+`path.gitdir.relative`::
+	The path to the Git repository directory relative to the current working directory.
+
 `references.format`::
 	The reference storage format. The valid values are:
 +
diff --git a/builtin/repo.c b/builtin/repo.c
index 71a5c1c29c..6e97f6a0e4 100644
--- a/builtin/repo.c
+++ b/builtin/repo.c
@@ -7,12 +7,14 @@
 #include "hex.h"
 #include "odb.h"
 #include "parse-options.h"
+#include "path.h"
 #include "path-walk.h"
 #include "progress.h"
 #include "quote.h"
 #include "ref-filter.h"
 #include "refs.h"
 #include "revision.h"
+#include "setup.h"
 #include "strbuf.h"
 #include "string-list.h"
 #include "shallow.h"
@@ -75,6 +77,28 @@ static int get_object_format(struct repository *repo, struct strbuf *buf)
 	return 0;
 }
 
+static int get_path_gitdir_absolute(struct repository *repo, struct strbuf *buf)
+{
+	const char *git_dir = repo_get_git_dir(repo);
+
+	if (!git_dir)
+		return error(_("unable to get git directory"));
+
+	format_path(buf, git_dir, startup_info->prefix, PATH_FORMAT_CANONICAL);
+	return 0;
+}
+
+static int get_path_gitdir_relative(struct repository *repo, struct strbuf *buf)
+{
+	const char *git_dir = repo_get_git_dir(repo);
+
+	if (!git_dir)
+		return error(_("unable to get git directory"));
+
+	format_path(buf, git_dir, startup_info->prefix, PATH_FORMAT_RELATIVE);
+	return 0;
+}
+
 static int get_references_format(struct repository *repo, struct strbuf *buf)
 {
 	strbuf_addstr(buf,
@@ -87,6 +111,8 @@ static const struct repo_info_field repo_info_field[] = {
 	{ "layout.bare", get_layout_bare },
 	{ "layout.shallow", get_layout_shallow },
 	{ "object.format", get_object_format },
+	{ "path.gitdir.absolute", get_path_gitdir_absolute },
+	{ "path.gitdir.relative", get_path_gitdir_relative },
 	{ "references.format", get_references_format },
 };
 
diff --git a/t/t1900-repo-info.sh b/t/t1900-repo-info.sh
index 39bb77dda0..0660b00bbc 100755
--- a/t/t1900-repo-info.sh
+++ b/t/t1900-repo-info.sh
@@ -155,4 +155,37 @@ test_expect_success 'git repo info -h shows only repo info usage' '
 	test_grep ! "git repo structure" actual
 '
 
+test_repo_info_path () {
+	field_name=$1
+	expect_absolute_eval=$2
+	expect_relative=$3
+	env_prefix=$4
+
+	test_expect_success "query individual key: path.$field_name.absolute${env_prefix:+ ($env_prefix)}" '
+		(
+			cd test-repo/sub &&
+			expect_absolute=$(eval "$expect_absolute_eval") &&
+			echo "path.$field_name.absolute=$expect_absolute" >expect &&
+			eval "${env_prefix:+$env_prefix }git repo info \"path.$field_name.absolute\"" >actual &&
+			test_cmp expect actual
+		)
+	'
+
+	test_expect_success "query individual key: path.$field_name.relative${env_prefix:+ ($env_prefix)}" '
+		(
+			cd test-repo/sub &&
+			echo "path.$field_name.relative=$expect_relative" >expect &&
+			eval "${env_prefix:+$env_prefix }git repo info \"path.$field_name.relative\"" >actual &&
+			test_cmp expect actual
+		)
+	'
+}
+
+test_expect_success 'setup test repository layout for path fields' '
+	git init test-repo &&
+	mkdir -p test-repo/sub
+'
+
+test_repo_info_path 'gitdir' 'echo "$(cd .. && pwd)/.git"' '../.git'
+
 test_done
-- 
2.54.0


^ permalink raw reply related

* followRemoteHEAD management question
From: Matt Hunter @ 2026-06-05 16:31 UTC (permalink / raw)
  To: git; +Cc: Bence Ferdinandy

Hello git list,

In the past, I've preferred to run 'git remote set-head <name> -d' when
setting up a new repository, since I generally have an awareness of what
the remote default branch is, and I don't like seeing them in branch
listings or git-log annotations.  They are especially noisy to me if I
have multiple remotes.  It's possible this config is ill-advised - I
would love to be educated if so...

However, since b7f7d16562c3 (fetch: add configuration for set_head
behaviour), these changes are undone by every 'git fetch'.

The topic mentioned above (merged in a1f34d595503) adds a new
configuration key 'remote.<name>.followRemoteHEAD'.  I'm assuming that
the intended use for followRemoteHEAD is really only in local /
per-repository config, since trying to apply it to my personal
.gitconfig has some odd behavior.

The <name> in the key template does not accept a wildcard, so I must
list out each of the common remote names I use across different
repositories.  Since many of my repos don't actually have remotes
established for all of these names, they pick up a kind of half-baked
definition for each of them as git performs its config parsing.  For
instance, a name will appear under 'git remote -v', but it won't
have any actual properties configured.

I'd like to add a line to my config somewhere that can globally restore
the old behavior in this context, eg:

    git config --global remote.*.followRemoteHEAD never

instead of adding individual entries to each project's .git/config.

Is there another solution in place I've missed?  If not, would there be
any opposition to a new key like 'remote.followRemoteHEAD' which serves
to provide a default value for any remote that doesn't have its own
'remote.<name>.followRemoteHEAD' key?

I've started scouting out changes to make for such a patch.  It's not
ready yet, but I figured I would throw this question out in case an easy
answer can save the effort.

Thanks

^ permalink raw reply

* [GSoC PATCH v2 2/4] rev-parse: use format_path for path formatting
From: K Jayatheerth @ 2026-06-05 16:30 UTC (permalink / raw)
  To: git
  Cc: jayatheerthkulkarni2005, a3205153416, gitster, jltobler,
	kumarayushjha123, lucasseikioshiro, phillip.wood, sandals
In-Reply-To: <20260605163012.181089-1-jayatheerthkulkarni2005@gmail.com>

Now that the core path-formatting logic has been abstracted into
format_path() inside path.c, remove the localized duplicate formatting
mechanics from builtin/rev-parse.c.

Drop the usage of the old local format_type and default_type enums,
and update print_path() to act as a light wrapper around the new shared
engine. Resolve user-provided formatting flags directly within rev-parse
to pass the final determined path_format to format_path().

Signed-off-by: K Jayatheerth <jayatheerthkulkarni2005@gmail.com>
Mentored-by: Justin Tobler <jltobler@gmail.com>
Mentored-by: Lucas Seiki Oshiro <lucasseikioshiro@gmail.com>
---
 builtin/rev-parse.c | 103 ++++++++++----------------------------------
 1 file changed, 23 insertions(+), 80 deletions(-)

diff --git a/builtin/rev-parse.c b/builtin/rev-parse.c
index 218b5f34d6..c78bdc04c1 100644
--- a/builtin/rev-parse.c
+++ b/builtin/rev-parse.c
@@ -632,73 +632,16 @@ static void handle_ref_opt(const char *pattern, const char *prefix)
 	clear_ref_exclusions(&ref_excludes);
 }
 
-enum format_type {
-	/* We would like a relative path. */
-	FORMAT_RELATIVE,
-	/* We would like a canonical absolute path. */
-	FORMAT_CANONICAL,
-	/* We would like the default behavior. */
-	FORMAT_DEFAULT,
-};
-
-enum default_type {
-	/* Our default is a relative path. */
-	DEFAULT_RELATIVE,
-	/* Our default is a relative path if there's a shared root. */
-	DEFAULT_RELATIVE_IF_SHARED,
-	/* Our default is a canonical absolute path. */
-	DEFAULT_CANONICAL,
-	/* Our default is not to modify the item. */
-	DEFAULT_UNMODIFIED,
-};
-
-static void print_path(const char *path, const char *prefix, enum format_type format, enum default_type def)
+static void print_path(const char *path, const char *prefix,
+		       int arg_path_format, enum path_format def_format)
 {
-	char *cwd = NULL;
-	/*
-	 * We don't ever produce a relative path if prefix is NULL, so set the
-	 * prefix to the current directory so that we can produce a relative
-	 * path whenever possible.  If we're using RELATIVE_IF_SHARED mode, then
-	 * we want an absolute path unless the two share a common prefix, so don't
-	 * set it in that case, since doing so causes a relative path to always
-	 * be produced if possible.
-	 */
-	if (!prefix && (format != FORMAT_DEFAULT || def != DEFAULT_RELATIVE_IF_SHARED))
-		prefix = cwd = xgetcwd();
-	if (format == FORMAT_DEFAULT && def == DEFAULT_UNMODIFIED) {
-		puts(path);
-	} else if (format == FORMAT_RELATIVE ||
-		  (format == FORMAT_DEFAULT && def == DEFAULT_RELATIVE)) {
-		/*
-		 * In order for relative_path to work as expected, we need to
-		 * make sure that both paths are absolute paths.  If we don't,
-		 * we can end up with an unexpected absolute path that the user
-		 * didn't want.
-		 */
-		struct strbuf buf = STRBUF_INIT, realbuf = STRBUF_INIT, prefixbuf = STRBUF_INIT;
-		if (!is_absolute_path(path)) {
-			strbuf_realpath_forgiving(&realbuf, path,  1);
-			path = realbuf.buf;
-		}
-		if (!is_absolute_path(prefix)) {
-			strbuf_realpath_forgiving(&prefixbuf, prefix, 1);
-			prefix = prefixbuf.buf;
-		}
-		puts(relative_path(path, prefix, &buf));
-		strbuf_release(&buf);
-		strbuf_release(&realbuf);
-		strbuf_release(&prefixbuf);
-	} else if (format == FORMAT_DEFAULT && def == DEFAULT_RELATIVE_IF_SHARED) {
-		struct strbuf buf = STRBUF_INIT;
-		puts(relative_path(path, prefix, &buf));
-		strbuf_release(&buf);
-	} else {
-		struct strbuf buf = STRBUF_INIT;
-		strbuf_realpath_forgiving(&buf, path, 1);
-		puts(buf.buf);
-		strbuf_release(&buf);
-	}
-	free(cwd);
+	struct strbuf sb = STRBUF_INIT;
+	enum path_format fmt = (arg_path_format != -1) ? arg_path_format : def_format;
+
+	format_path(&sb, path, prefix, fmt);
+	puts(sb.buf);
+
+	strbuf_release(&sb);
 }
 
 int cmd_rev_parse(int argc,
@@ -717,7 +660,7 @@ int cmd_rev_parse(int argc,
 	const char *name = NULL;
 	struct strbuf buf = STRBUF_INIT;
 	int seen_end_of_options = 0;
-	enum format_type format = FORMAT_DEFAULT;
+	int arg_path_format = -1;
 
 	show_usage_if_asked(argc, argv, builtin_rev_parse_usage);
 
@@ -797,8 +740,8 @@ int cmd_rev_parse(int argc,
 					die(_("--git-path requires an argument"));
 				print_path(repo_git_path_replace(the_repository, &buf,
 								 "%s", argv[i + 1]), prefix,
-						format,
-						DEFAULT_RELATIVE_IF_SHARED);
+						arg_path_format,
+						PATH_FORMAT_RELATIVE_IF_SHARED);
 				i++;
 				continue;
 			}
@@ -820,9 +763,9 @@ int cmd_rev_parse(int argc,
 				if (!arg)
 					die(_("--path-format requires an argument"));
 				if (!strcmp(arg, "absolute")) {
-					format = FORMAT_CANONICAL;
+					arg_path_format = PATH_FORMAT_CANONICAL;
 				} else if (!strcmp(arg, "relative")) {
-					format = FORMAT_RELATIVE;
+					arg_path_format = PATH_FORMAT_RELATIVE;
 				} else {
 					die(_("unknown argument to --path-format: %s"), arg);
 				}
@@ -985,7 +928,7 @@ int cmd_rev_parse(int argc,
 			if (!strcmp(arg, "--show-toplevel")) {
 				const char *work_tree = repo_get_work_tree(the_repository);
 				if (work_tree)
-					print_path(work_tree, prefix, format, DEFAULT_UNMODIFIED);
+					print_path(work_tree, prefix, arg_path_format, PATH_FORMAT_UNMODIFIED);
 				else
 					die(_("this operation must be run in a work tree"));
 				continue;
@@ -993,7 +936,7 @@ int cmd_rev_parse(int argc,
 			if (!strcmp(arg, "--show-superproject-working-tree")) {
 				struct strbuf superproject = STRBUF_INIT;
 				if (get_superproject_working_tree(&superproject))
-					print_path(superproject.buf, prefix, format, DEFAULT_UNMODIFIED);
+					print_path(superproject.buf, prefix, arg_path_format, PATH_FORMAT_UNMODIFIED);
 				strbuf_release(&superproject);
 				continue;
 			}
@@ -1028,18 +971,18 @@ int cmd_rev_parse(int argc,
 				const char *gitdir = getenv(GIT_DIR_ENVIRONMENT);
 				char *cwd;
 				int len;
-				enum format_type wanted = format;
+				int wanted = arg_path_format;
 				if (arg[2] == 'g') {	/* --git-dir */
 					if (gitdir) {
-						print_path(gitdir, prefix, format, DEFAULT_UNMODIFIED);
+						print_path(gitdir, prefix, arg_path_format, PATH_FORMAT_UNMODIFIED);
 						continue;
 					}
 					if (!prefix) {
-						print_path(".git", prefix, format, DEFAULT_UNMODIFIED);
+						print_path(".git", prefix, arg_path_format, PATH_FORMAT_UNMODIFIED);
 						continue;
 					}
 				} else {		/* --absolute-git-dir */
-					wanted = FORMAT_CANONICAL;
+					wanted = PATH_FORMAT_CANONICAL;
 					if (!gitdir && !prefix)
 						gitdir = ".git";
 					if (gitdir) {
@@ -1055,11 +998,11 @@ int cmd_rev_parse(int argc,
 				strbuf_reset(&buf);
 				strbuf_addf(&buf, "%s%s.git", cwd, len && cwd[len-1] != '/' ? "/" : "");
 				free(cwd);
-				print_path(buf.buf, prefix, wanted, DEFAULT_CANONICAL);
+				print_path(buf.buf, prefix, wanted, PATH_FORMAT_CANONICAL);
 				continue;
 			}
 			if (!strcmp(arg, "--git-common-dir")) {
-				print_path(repo_get_common_dir(the_repository), prefix, format, DEFAULT_RELATIVE_IF_SHARED);
+				print_path(repo_get_common_dir(the_repository), prefix, arg_path_format, PATH_FORMAT_RELATIVE_IF_SHARED);
 				continue;
 			}
 			if (!strcmp(arg, "--is-inside-git-dir")) {
@@ -1089,7 +1032,7 @@ int cmd_rev_parse(int argc,
 				if (the_repository->index->split_index) {
 					const struct object_id *oid = &the_repository->index->split_index->base_oid;
 					const char *path = repo_git_path_replace(the_repository, &buf, "sharedindex.%s", oid_to_hex(oid));
-					print_path(path, prefix, format, DEFAULT_RELATIVE);
+					print_path(path, prefix, arg_path_format, PATH_FORMAT_RELATIVE);
 				}
 				continue;
 			}
-- 
2.54.0


^ permalink raw reply related

* [GSoC PATCH v2 1/4] path: introduce format_path() for centralized path formatting
From: K Jayatheerth @ 2026-06-05 16:30 UTC (permalink / raw)
  To: git
  Cc: jayatheerthkulkarni2005, a3205153416, gitster, jltobler,
	kumarayushjha123, lucasseikioshiro, phillip.wood, sandals
In-Reply-To: <20260605163012.181089-1-jayatheerthkulkarni2005@gmail.com>

The path-formatting logic inside `builtin/rev-parse.c` handles absolute,
canonical, and relative formatting rules based on user-supplied options.
However, this logic is tightly coupled to `rev-parse` and writes directly
to stdout.

To allow other builtins (such as the upcoming `git repo` path keys) to
re-use this logic, extract the core path-formatting algorithm into a centralized
helper function, `format_path()`, in `path.c`.

Expose a single, streamlined `path_format` enum in `path.h` to let callers
explicitly declare their formatting strategy (UNMODIFIED, RELATIVE,
RELATIVE_IF_SHARED, or CANONICAL). This decouples the core algorithm from
the localized fallback mechanics specific to `rev-parse`.

Signed-off-by: K Jayatheerth <jayatheerthkulkarni2005@gmail.com>
Mentored-by: Justin Tobler <jltobler@gmail.com>
Mentored-by: Lucas Seiki Oshiro <lucasseikioshiro@gmail.com>
---
 path.c | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 path.h | 30 ++++++++++++++++++++++++++++++
 2 files changed, 88 insertions(+)

diff --git a/path.c b/path.c
index d7e17bf174..2fcd24c5eb 100644
--- a/path.c
+++ b/path.c
@@ -1579,6 +1579,64 @@ char *xdg_cache_home(const char *filename)
 	return NULL;
 }
 
+void format_path(struct strbuf *buf, const char *path,
+		 const char *prefix, enum path_format format)
+{
+	if (format == PATH_FORMAT_UNMODIFIED) {
+		strbuf_addstr(buf, path);
+		return;
+	}
+
+	if (format == PATH_FORMAT_RELATIVE) {
+		struct strbuf relative_buf = STRBUF_INIT;
+		struct strbuf real_path = STRBUF_INIT;
+		struct strbuf real_prefix = STRBUF_INIT;
+		char *cwd = NULL;
+
+		/*
+		 * We don't ever produce a relative path if prefix is NULL,
+		 * so set the prefix to the current directory so that we can
+		 * produce a relative path whenever possible.
+		 */
+		if (!prefix)
+			prefix = cwd = xgetcwd();
+
+		if (!is_absolute_path(path)) {
+			strbuf_realpath_forgiving(&real_path, path, 1);
+			path = real_path.buf;
+		}
+		if (!is_absolute_path(prefix)) {
+			strbuf_realpath_forgiving(&real_prefix, prefix, 1);
+			prefix = real_prefix.buf;
+		}
+
+		strbuf_addstr(buf, relative_path(path, prefix, &relative_buf));
+
+		strbuf_release(&relative_buf);
+		strbuf_release(&real_path);
+		strbuf_release(&real_prefix);
+		free(cwd);
+	} else if (format == PATH_FORMAT_RELATIVE_IF_SHARED) {
+		struct strbuf relative_buf = STRBUF_INIT;
+
+		/*
+		 * If we're using RELATIVE_IF_SHARED mode, then we want an
+		 * absolute path unless the two share a common prefix, so don't
+		 * default the prefix to the current working directory. Doing so
+		 * would cause a relative path to always be produced if possible.
+		 */
+		strbuf_addstr(buf, relative_path(path, prefix, &relative_buf));
+		strbuf_release(&relative_buf);
+	} else if (format == PATH_FORMAT_CANONICAL) {
+		struct strbuf canonical_buf = STRBUF_INIT;
+
+		strbuf_realpath_forgiving(&canonical_buf, path, 1);
+		strbuf_addbuf(buf, &canonical_buf);
+
+		strbuf_release(&canonical_buf);
+	}
+}
+
 REPO_GIT_PATH_FUNC(squash_msg, "SQUASH_MSG")
 REPO_GIT_PATH_FUNC(merge_msg, "MERGE_MSG")
 REPO_GIT_PATH_FUNC(merge_rr, "MERGE_RR")
diff --git a/path.h b/path.h
index 0434ba5e07..a78e0fc141 100644
--- a/path.h
+++ b/path.h
@@ -262,6 +262,36 @@ enum scld_error safe_create_leading_directories_no_share(char *path);
 int safe_create_file_with_leading_directories(struct repository *repo,
 					      const char *path);
 
+/**
+ * The formatting strategy to apply when writing a path into a buffer.
+ */
+enum path_format {
+	/* Output the path exactly as-is without any modifications. */
+	PATH_FORMAT_UNMODIFIED,
+
+	/* Output a path relative to the provided directory prefix. */
+	PATH_FORMAT_RELATIVE,
+
+	/* Output a relative path only if the path shares a root with the prefix. */
+	PATH_FORMAT_RELATIVE_IF_SHARED,
+
+	/* Output a fully resolved, absolute canonical path. */
+	PATH_FORMAT_CANONICAL
+};
+
+/**
+ * Format a path according to the specified formatting strategy and append
+ * the result to the given strbuf.
+ *
+ * `buf`    : The string buffer to append the formatted path to.
+ * `path`   : The path string that needs to be formatted.
+ * `prefix` : The directory prefix to calculate relative offsets against.
+ * Pass NULL to default to the current working directory where applicable.
+ * `format` : The formatting behavior rule to execute.
+ */
+void format_path(struct strbuf *buf, const char *path,
+		 const char *prefix, enum path_format format);
+
 # ifdef USE_THE_REPOSITORY_VARIABLE
 #  include "strbuf.h"
 #  include "repository.h"
-- 
2.54.0


^ permalink raw reply related

* [GSoC PATCH v2 0/4] teach git repo info to handle path keys
From: K Jayatheerth @ 2026-06-05 16:30 UTC (permalink / raw)
  To: git
  Cc: jayatheerthkulkarni2005, a3205153416, gitster, jltobler,
	kumarayushjha123, lucasseikioshiro, phillip.wood, sandals
In-Reply-To: <20260601151950.30686-1-jayatheerthkulkarni2005@gmail.com>

Hi everyone,

This series teaches `git repo info` to handle `path.*` keys, so
scripts can easily discover repository paths.

The commits are divided into 4 parts:

  1. path: extract the path-formatting logic from rev-parse and
     expose it via path.h with a better naming convention.
  2. rev-parse: refactor the command to use the exported function
     and enum.
  3. repo: introduce path.gitdir with standardized tests and docs.
  4. repo: introduce path.commondir.

About patches 3 and 4:
In our last discussion [1], we didn't reach a definitive conclusion
about paths in repo info, but based on the feedback, explicitly
offering both relative and absolute options made the most sense. So,
patches 3 and 4 add both `path.<field>.absolute` and
`path.<field>.relative` for `gitdir` and `commondir`.

There are still a few open questions. Tagging Justin, Lucas, Junio,
Phillip, brian, and Ayush.

Questions:
1. Should there still be a --path-format flag?
2. Should we consider a default option?
   Currently we have path.gitdir.absolute. Should we consider an
   option where a plain `path.gitdir` returns some default?
   If yes:
     2.1 Should we keep the default the same as rev-parse? Or should
         either relative or absolute be the default?
     2.2 When printing using --all, should the default be printed,
         or should we print both absolute and relative?
3. Is printing both absolute and relative in a single call using
   --all acceptable? If no, what's a better approach?

I have discussed these changes with both Justin and Lucas internally
and wanted to gather opinions from the wider community before moving
forward.

Changes since v1:

* Lucas's feedback: Added corner cases covering GIT_COMMON_DIR and
  GIT_DIR. Parameterized the test helper fields instead of hardcoding
  them. Also fixed the subject prefix to [GSoC PATCH v2].

* Junio's feedback: Added a clearer description of what the series
  does up front. I also realized the commit messages for patches 3
  and 4 explained the "what" and not the "why", so I have (hopefully)
  improved them :)

* Phillip's feedback: Changed the helper function name and combined
  the two enums into one, which made a lot of sense.

  I have also added comments within the path.h files to document
  the API.

* About lexicographical order: "Breaking" wasn't the right term
  before, but I do believe keeping .absolute and .relative as
  suffixes is a better choice. I prefer having the two choices
  side-by-side grouped by entity, rather than a cluster of absolute
  keys followed by relative ones. Open to hearing if the latter is
  preferred!

Thanks for this round of feedback guys, this has been fruitful!

P.S - I realized that I didn't add the link to Lucas's patch thread
last time :) sorry bout that!

[1] https://public-inbox.org/git/041DCF2E-75FB-4B0A-9128-FDBB1A6DAC3C@gmail.com/T/#t

K Jayatheerth (4):
  path: introduce format_path() for centralized path formatting
  rev-parse: use format_path for path formatting
  repo: add path.gitdir with absolute and relative suffix formatting
  repo: add path.commondir with absolute and relative suffix formatting

 Documentation/git-repo.adoc |  15 ++++++
 builtin/repo.c              |  50 +++++++++++++++++
 builtin/rev-parse.c         | 103 ++++++++----------------------------
 path.c                      |  58 ++++++++++++++++++++
 path.h                      |  30 +++++++++++
 t/t1900-repo-info.sh        |  40 ++++++++++++++
 6 files changed, 216 insertions(+), 80 deletions(-)

-- 
2.54.0

^ permalink raw reply

* Re: [PATCH] worktree: record creation time and free-form note
From: Kiesel, Norbert @ 2026-06-05 16:13 UTC (permalink / raw)
  To: phillip.wood; +Cc: git
In-Reply-To: <b1b15a47-0842-4a26-9a95-bfdae12799e0@gmail.com>

Yes, we could look at the branch description instead of adding a worktree
note/description. However, when I talked to some others, some of them
told me that they use some worktrees more like a "group changes"
where they then switch between some branches within the same
worktree.  And therefore, they wanted a separate worktree note.

The "sort by last updated" sounds like a very nice idea.  Again, similar
to "ls -l --sort=time --time=modifiction" vs "ls -l --sort=time --time=creation"
for GNU ls.  So would you prefer to only support sorting by "last updated"
or would you support multiple sort options: time created, time last
updated, in addition to name?

Best,
  Norbert


On Fri, Jun 5, 2026 at 8:17 AM Phillip Wood <phillip.wood123@gmail.com> wrote:
>
> Hi Norbert
>
> On 02/06/2026 22:40, Kiesel, Norbert wrote:
> >
> > Add per-worktree metadata so users can answer "what is this worktree
> > for, and when did I make it?" without resorting to external notes.
>
> A couple of thoughts related to this
>
> Isn't "what is the worktree for" a property of the branch that's checked
> out, not the worktree itself? We already have
> branch.<branch>.description to add a descritpion to a branch. If you
> have a detached HEAD it is trickier though.
>
> I don't think I've ever wanted to know when a worktree was created. I
> would find it useful to be able to sort worktrees by when they were last
> updated (i.e. the reflog date of HEAD in each worktree) to see which
> ones are stale though.
>
> Thanks
>
> Phillip
>
> > When `git worktree add` creates a linked worktree, it now writes a
> > `created` file containing the unix timestamp. A new `--note <string>`
> > option to `add`, and a new `git worktree annotate <worktree> [<note>]`
> > subcommand, store an optional free-form description in a `note` file
> > next to the other administrative files. Passing `annotate` without a
> > note clears it. The main worktree carries no metadata and cannot be
> > annotated.
> >
> > `git worktree list` learns `--show-created` and `--show-note` for
> > human-readable output, and `--sort=<key>` (path or created, optionally
> > prefixed with `-` to reverse) for ordering linked worktrees; the main
> > worktree always stays first. Worktrees without a recorded timestamp
> > (those created before this change) display as `created: unknown` and
> > sort after timestamped ones. Porcelain output unconditionally emits
> > `created` and `note` lines when the corresponding metadata is present.
> >
> > Tests cover add/annotate/list behaviour and the legacy-worktree case.
> > The two existing porcelain assertions in t2402 are taught to strip the
> > new `created` line so they continue to pass.
> >
> > Signed-off-by: Norbert Kiesel <norbert.kiesel@creditkarma.com>
> > ---
> >   Documentation/git-worktree.adoc |  61 ++++++++++++-
> >   builtin/worktree.c              | 152 +++++++++++++++++++++++++++++++-
> >   t/meson.build                   |   1 +
> >   t/t2402-worktree-list.sh        |  10 ++-
> >   t/t2410-worktree-metadata.sh    | 143 ++++++++++++++++++++++++++++++
> >   worktree.c                      |  78 ++++++++++++++++
> >   worktree.h                      |  23 +++++
> >   7 files changed, 459 insertions(+), 9 deletions(-)
> >   create mode 100755 t/t2410-worktree-metadata.sh
> >
> > diff --git a/Documentation/git-worktree.adoc b/Documentation/git-worktree.adoc
> > index fbf8426cd9..200f3d7772 100644
> > --- a/Documentation/git-worktree.adoc
> > +++ b/Documentation/git-worktree.adoc
> > @@ -10,8 +10,11 @@ SYNOPSIS
> >   --------
> >   [synopsis]
> >   git worktree add [-f] [--detach] [--checkout] [--lock [--reason <string>]]
> > + [--note <string>]
> >    [--orphan] [(-b | -B) <new-branch>] <path> [<commit-ish>]
> > -git worktree list [-v | --porcelain [-z]]
> > +git worktree annotate <worktree> [<note>]
> > +git worktree list [-v | --porcelain [-z]] [--show-created] [--show-note]
> > + [--sort=<key>]
> >   git worktree lock [--reason <string>] <worktree>
> >   git worktree move <worktree> <new-path>
> >   git worktree prune [-n] [-v] [--expire <expire>]
> > @@ -106,6 +109,15 @@ passed to the command. In the event the
> > repository has a remote and
> >   command fails with a warning reminding the user to fetch from their remote
> >   first (or override by using `-f`/`--force`).
> >
> > +`annotate <worktree> [<note>]`::
> > +
> > +Set, replace, or clear a free-form note (description) on a linked worktree.
> > +Useful for recording what a worktree was created for so it can be identified
> > +later. With _<note>_, the worktree's note is set or replaced; without a note
> > +argument, the existing note is cleared. The note for a worktree may also be
> > +set at creation time with `git worktree add --note <note>`. The main
> > +worktree cannot be annotated.
> > +
> >   `list`::
> >
> >   List details of each worktree.  The main worktree is listed first,
> > @@ -114,6 +126,20 @@ whether the worktree is bare, the revision
> > currently checked out, the
> >   branch currently checked out (or "detached HEAD" if none), "locked" if
> >   the worktree is locked, "prunable" if the worktree can be pruned by the
> >   `prune` command.
> > ++
> > +Each worktree's creation timestamp is recorded when it is created with
> > +`git worktree add`. Worktrees created before this feature existed have no
> > +recorded creation timestamp; for them, `list` reports `created: unknown`
> > +in human output and omits the `created` line in `--porcelain` output. Pass
> > +`--show-created` to include creation timestamps in human output. Worktrees
> > +without a recorded timestamp sort last (or first when reversed) with
> > +`--sort=created`.
> > ++
> > +Pass `--show-note` to include any user-provided note in human output. In
> > +`--porcelain` output, both `created` and `note` lines are emitted whenever
> > +present. Use `--sort=<key>` (where _<key>_ is `path` or `created`,
> > +optionally prefixed with `-` to reverse) to order the linked worktrees;
> > +the main worktree always remains first.
> >
> >   `lock`::
> >
> > @@ -286,6 +312,32 @@ _<time>_.
> >    With `lock` or with `add --lock`, an explanation why the worktree
> >    is locked.
> >
> > +`--note <string>`::
> > + With `add`, attach a free-form note (description) to the new worktree.
> > + The note is stored alongside the worktree's administrative files and
> > + can be displayed with `git worktree list --show-note` or in
> > + `--porcelain` output. It can be changed later with
> > + `git worktree annotate`.
> > +
> > +`--show-created`::
> > + With `list`, include each worktree's creation timestamp in the
> > + human-readable output. Worktrees with no recorded creation time are
> > + shown as `created: unknown`. In `--porcelain` output, the creation
> > + timestamp is always included (when available) on a `created` line.
> > +
> > +`--show-note`::
> > + With `list`, include each worktree's note (if set) in the
> > + human-readable output. In `--porcelain` output, the note is always
> > + included (when set) on a `note` line.
> > +
> > +`--sort=<key>`::
> > + With `list`, sort linked worktrees by _<key>_, which is one of
> > + `path` or `created`. Prefix with `-` to reverse the order, e.g.
> > + `--sort=-created` lists newest first. The main worktree is always
> > + listed first regardless of sort order. Worktrees with no recorded
> > + creation timestamp sort after those that have one (or before, when
> > + reversed).
> > +
> >   _<worktree>_::
> >    Worktrees can be identified by path, either relative or absolute.
> >   +
> > @@ -462,7 +514,9 @@ are terminated with NUL rather than a newline.
> > Attributes are listed with a
> >   label and value separated by a single space.  Boolean attributes (like `bare`
> >   and `detached`) are listed as a label only, and are present only
> >   if the value is true.  Some attributes (like `locked`) can be listed as a label
> > -only or with a value depending upon whether a reason is available.  The first
> > +only or with a value depending upon whether a reason is available.  Optional
> > +valued attributes (like `created` and `note`) appear only when the
> > +corresponding metadata has been recorded for that worktree.  The first
> >   attribute of a worktree is always `worktree`, an empty line indicates the
> >   end of the record.  For example:
> >
> > @@ -474,10 +528,13 @@ bare
> >   worktree /path/to/linked-worktree
> >   HEAD abcd1234abcd1234abcd1234abcd1234abcd1234
> >   branch refs/heads/master
> > +created 2026-06-01T12:34:56Z
> > +note investigating login bug
> >
> >   worktree /path/to/other-linked-worktree
> >   HEAD 1234abc1234abc1234abc1234abc1234abc1234a
> >   detached
> > +created 2026-05-28T08:15:00Z
> >
> >   worktree /path/to/linked-worktree-locked-no-reason
> >   HEAD 5678abc5678abc5678abc5678abc5678abc5678c
> > diff --git a/builtin/worktree.c b/builtin/worktree.c
> > index d21c43fde3..ac22277d6c 100644
> > --- a/builtin/worktree.c
> > +++ b/builtin/worktree.c
> > @@ -27,13 +27,16 @@
> >   #include "utf8.h"
> >   #include "worktree.h"
> >   #include "quote.h"
> > +#include "date.h"
> >
> >   #define BUILTIN_WORKTREE_ADD_USAGE \
> >    N_("git worktree add [-f] [--detach] [--checkout] [--lock [--reason
> > <string>]]\n" \
> > +    "                 [--note <string>]\n" \
> >       "                 [--orphan] [(-b | -B) <new-branch>] <path>
> > [<commit-ish>]")
> >
> >   #define BUILTIN_WORKTREE_LIST_USAGE \
> > - N_("git worktree list [-v | --porcelain [-z]]")
> > + N_("git worktree list [-v | --porcelain [-z]] [--show-created]
> > [--show-note]\n" \
> > +    "                  [--sort=<key>]")
> >   #define BUILTIN_WORKTREE_LOCK_USAGE \
> >    N_("git worktree lock [--reason <string>] <worktree>")
> >   #define BUILTIN_WORKTREE_MOVE_USAGE \
> > @@ -46,6 +49,8 @@
> >    N_("git worktree repair [<path>...]")
> >   #define BUILTIN_WORKTREE_UNLOCK_USAGE \
> >    N_("git worktree unlock <worktree>")
> > +#define BUILTIN_WORKTREE_ANNOTATE_USAGE \
> > + N_("git worktree annotate <worktree> [<note>]")
> >
> >   #define WORKTREE_ADD_DWIM_ORPHAN_INFER_TEXT \
> >    _("No possible source branch, inferring '--orphan'")
> > @@ -66,6 +71,7 @@
> >
> >   static const char * const git_worktree_usage[] = {
> >    BUILTIN_WORKTREE_ADD_USAGE,
> > + BUILTIN_WORKTREE_ANNOTATE_USAGE,
> >    BUILTIN_WORKTREE_LIST_USAGE,
> >    BUILTIN_WORKTREE_LOCK_USAGE,
> >    BUILTIN_WORKTREE_MOVE_USAGE,
> > @@ -116,6 +122,11 @@ static const char * const git_worktree_unlock_usage[] = {
> >    NULL
> >   };
> >
> > +static const char * const git_worktree_annotate_usage[] = {
> > + BUILTIN_WORKTREE_ANNOTATE_USAGE,
> > + NULL
> > +};
> > +
> >   struct add_opts {
> >    int force;
> >    int detach;
> > @@ -124,6 +135,7 @@ struct add_opts {
> >    int orphan;
> >    int relative_paths;
> >    const char *keep_locked;
> > + const char *note;
> >   };
> >
> >   static int show_only;
> > @@ -131,6 +143,8 @@ static int verbose;
> >   static int guess_remote;
> >   static int use_relative_paths;
> >   static timestamp_t expire;
> > +static int show_created;
> > +static int show_note;
> >
> >   static int git_worktree_config(const char *var, const char *value,
> >           const struct config_context *ctx, void *cb)
> > @@ -544,6 +558,16 @@ static int add_worktree(const char *path, const
> > char *refname,
> >    strbuf_addf(&sb, "%s/commondir", sb_repo.buf);
> >    write_file(sb.buf, "../..");
> >
> > + strbuf_reset(&sb);
> > + strbuf_addf(&sb, "%s/created", sb_repo.buf);
> > + write_file(sb.buf, "%"PRItime, (timestamp_t) time(NULL));
> > +
> > + if (opts->note && *opts->note) {
> > + strbuf_reset(&sb);
> > + strbuf_addf(&sb, "%s/note", sb_repo.buf);
> > + write_file(sb.buf, "%s", opts->note);
> > + }
> > +
> >    /*
> >    * Set up the ref store of the worktree and create the HEAD reference.
> >    */
> > @@ -815,6 +839,8 @@ static int add(int ac, const char **av, const char *prefix,
> >    OPT_BOOL(0, "lock", &keep_locked, N_("keep the new working tree locked")),
> >    OPT_STRING(0, "reason", &lock_reason, N_("string"),
> >       N_("reason for locking")),
> > + OPT_STRING(0, "note", &opts.note, N_("string"),
> > +    N_("attach a free-form note/description to the worktree")),
> >    OPT__QUIET(&opts.quiet, N_("suppress progress reporting")),
> >    OPT_PASSTHRU(0, "track", &opt_track, NULL,
> >         N_("set up tracking mode (see git-branch(1))"),
> > @@ -963,6 +989,8 @@ static int add(int ac, const char **av, const char *prefix,
> >   static void show_worktree_porcelain(struct worktree *wt, int line_terminator)
> >   {
> >    const char *reason;
> > + const char *note;
> > + timestamp_t created;
> >
> >    printf("worktree %s%c", wt->path, line_terminator);
> >    if (wt->is_bare)
> > @@ -975,6 +1003,18 @@ static void show_worktree_porcelain(struct
> > worktree *wt, int line_terminator)
> >    printf("branch %s%c", wt->head_ref, line_terminator);
> >    }
> >
> > + created = worktree_created_at(wt);
> > + if (created)
> > + printf("created %s%c",
> > +        show_date(created, 0, DATE_MODE(ISO8601_STRICT)),
> > +        line_terminator);
> > +
> > + note = worktree_note(wt);
> > + if (note && *note) {
> > + fputs("note ", stdout);
> > + write_name_quoted(note, stdout, line_terminator);
> > + }
> > +
> >    reason = worktree_lock_reason(wt);
> >    if (reason) {
> >    fputs("locked", stdout);
> > @@ -1034,6 +1074,21 @@ static void show_worktree(struct worktree *wt,
> > struct worktree_display *display,
> >    else if (reason)
> >    strbuf_addstr(&sb, " prunable");
> >
> > + if (show_created || verbose) {
> > + timestamp_t created = worktree_created_at(wt);
> > + if (created)
> > + strbuf_addf(&sb, "\n\tcreated: %s",
> > +     show_date(created, 0, DATE_MODE(ISO8601)));
> > + else if (show_created && !is_main_worktree(wt))
> > + strbuf_addstr(&sb, "\n\tcreated: unknown");
> > + }
> > +
> > + if (show_note || verbose) {
> > + const char *note = worktree_note(wt);
> > + if (note && *note)
> > + strbuf_addf(&sb, "\n\tnote: %s", note);
> > + }
> > +
> >    printf("%s\n", sb.buf);
> >    strbuf_release(&sb);
> >   }
> > @@ -1068,6 +1123,27 @@ static int pathcmp(const void *a_, const void *b_)
> >    return fspathcmp((*a)->path, (*b)->path);
> >   }
> >
> > +static int createdcmp(const void *a_, const void *b_)
> > +{
> > + struct worktree *const *a = a_;
> > + struct worktree *const *b = b_;
> > + timestamp_t ta = worktree_created_at(*a);
> > + timestamp_t tb = worktree_created_at(*b);
> > +
> > + /* Worktrees without a recorded timestamp (legacy) sort after those
> > with one. */
> > + if (!ta && !tb)
> > + return fspathcmp((*a)->path, (*b)->path);
> > + if (!ta)
> > + return 1;
> > + if (!tb)
> > + return -1;
> > + if (ta < tb)
> > + return -1;
> > + if (ta > tb)
> > + return 1;
> > + return 0;
> > +}
> > +
> >   static void pathsort(struct worktree **wt)
> >   {
> >    int n = 0;
> > @@ -1078,11 +1154,43 @@ static void pathsort(struct worktree **wt)
> >    QSORT(wt, n, pathcmp);
> >   }
> >
> > +static int sort_worktrees(struct worktree **wt, const char *key)
> > +{
> > + int n = 0, reverse = 0;
> > + struct worktree **p = wt;
> > + int (*cmp)(const void *, const void *);
> > +
> > + if (*key == '-') {
> > + reverse = 1;
> > + key++;
> > + }
> > + if (!strcmp(key, "path"))
> > + cmp = pathcmp;
> > + else if (!strcmp(key, "created"))
> > + cmp = createdcmp;
> > + else
> > + return -1;
> > +
> > + while (*p++)
> > + n++;
> > + QSORT(wt, n, cmp);
> > + if (reverse) {
> > + int i;
> > + for (i = 0; i < n / 2; i++) {
> > + struct worktree *tmp = wt[i];
> > + wt[i] = wt[n - 1 - i];
> > + wt[n - 1 - i] = tmp;
> > + }
> > + }
> > + return 0;
> > +}
> > +
> >   static int list(int ac, const char **av, const char *prefix,
> >    struct repository *repo UNUSED)
> >   {
> >    int porcelain = 0;
> >    int line_terminator = '\n';
> > + const char *sort_key = NULL;
> >
> >    struct option options[] = {
> >    OPT_BOOL(0, "porcelain", &porcelain, N_("machine-readable output")),
> > @@ -1091,6 +1199,12 @@ static int list(int ac, const char **av, const
> > char *prefix,
> >    N_("add 'prunable' annotation to missing worktrees older than <time>")),
> >    OPT_SET_INT('z', NULL, &line_terminator,
> >        N_("terminate records with a NUL character"), '\0'),
> > + OPT_BOOL(0, "show-created", &show_created,
> > + N_("show worktree creation timestamps")),
> > + OPT_BOOL(0, "show-note", &show_note,
> > + N_("show worktree notes")),
> > + OPT_STRING(0, "sort", &sort_key, N_("key"),
> > +    N_("sort worktrees by key (path, created); prefix with - to reverse")),
> >    OPT_END()
> >    };
> >
> > @@ -1107,8 +1221,13 @@ static int list(int ac, const char **av, const
> > char *prefix,
> >    int path_maxwidth = 0, abbrev = DEFAULT_ABBREV, i;
> >    struct worktree_display *display = NULL;
> >
> > - /* sort worktrees by path but keep main worktree at top */
> > - pathsort(worktrees + 1);
> > + /* sort worktrees but keep main worktree at top */
> > + if (sort_key) {
> > + if (sort_worktrees(worktrees + 1, sort_key))
> > + die(_("unknown sort key '%s'"), sort_key);
> > + } else {
> > + pathsort(worktrees + 1);
> > + }
> >
> >    if (!porcelain)
> >    measure_widths(worktrees, &abbrev,
> > @@ -1200,6 +1319,32 @@ static int unlock_worktree(int ac, const char
> > **av, const char *prefix,
> >    return ret;
> >   }
> >
> > +static int annotate_worktree(int ac, const char **av, const char *prefix,
> > +      struct repository *repo UNUSED)
> > +{
> > + struct option options[] = {
> > + OPT_END()
> > + };
> > + struct worktree **worktrees, *wt;
> > + int ret;
> > +
> > + ac = parse_options(ac, av, prefix, options, git_worktree_annotate_usage, 0);
> > + if (ac < 1 || ac > 2)
> > + usage_with_options(git_worktree_annotate_usage, options);
> > +
> > + worktrees = get_worktrees();
> > + wt = find_worktree(worktrees, prefix, av[0]);
> > + if (!wt)
> > + die(_("'%s' is not a working tree"), av[0]);
> > + if (is_main_worktree(wt))
> > + die(_("The main working tree cannot be annotated"));
> > +
> > + ret = set_worktree_note(wt, ac == 2 ? av[1] : NULL);
> > +
> > + free_worktrees(worktrees);
> > + return ret;
> > +}
> > +
> >   static void validate_no_submodules(const struct worktree *wt)
> >   {
> >    struct index_state istate = INDEX_STATE_INIT(the_repository);
> > @@ -1469,6 +1614,7 @@ int cmd_worktree(int ac,
> >    parse_opt_subcommand_fn *fn = NULL;
> >    struct option options[] = {
> >    OPT_SUBCOMMAND("add", &fn, add),
> > + OPT_SUBCOMMAND("annotate", &fn, annotate_worktree),
> >    OPT_SUBCOMMAND("prune", &fn, prune),
> >    OPT_SUBCOMMAND("list", &fn, list),
> >    OPT_SUBCOMMAND("lock", &fn, lock_worktree),
> > diff --git a/t/meson.build b/t/meson.build
> > index 2af8d01279..7b6e8435d7 100644
> > --- a/t/meson.build
> > +++ b/t/meson.build
> > @@ -308,6 +308,7 @@ integration_tests = [
> >     't2405-worktree-submodule.sh',
> >     't2406-worktree-repair.sh',
> >     't2407-worktree-heads.sh',
> > +  't2410-worktree-metadata.sh',
> >     't2500-untracked-overwriting.sh',
> >     't2501-cwd-empty.sh',
> >     't3000-ls-files-others.sh',
> > diff --git a/t/t2402-worktree-list.sh b/t/t2402-worktree-list.sh
> > index e0c6abd2f5..8422340443 100755
> > --- a/t/t2402-worktree-list.sh
> > +++ b/t/t2402-worktree-list.sh
> > @@ -71,7 +71,8 @@ test_expect_success '"list" all worktrees --porcelain' '
> >    echo "HEAD $(git rev-parse HEAD)" >>expect &&
> >    echo "detached" >>expect &&
> >    echo >>expect &&
> > - git worktree list --porcelain >actual &&
> > + git worktree list --porcelain >actual.raw &&
> > + grep -v "^created " actual.raw >actual &&
> >    test_cmp expect actual
> >   '
> >
> > @@ -86,7 +87,7 @@ test_expect_success '"list" all worktrees --porcelain -z' '
> >    "$(git -C here rev-parse --show-toplevel)" \
> >    "$(git rev-parse HEAD)" >>expect &&
> >    git worktree list --porcelain -z >_actual &&
> > - nul_to_q <_actual >actual &&
> > + nul_to_q <_actual | tr Q "\n" | grep -v "^created " | tr "\n" Q >actual &&
> >    test_cmp expect actual
> >   '
> >
> > @@ -220,7 +221,7 @@ test_expect_success '"list" all worktrees from bare main' '
> >   '
> >
> >   test_expect_success '"list" all worktrees --porcelain from bare main' '
> > - test_when_finished "rm -rf there actual expect && git -C bare1
> > worktree prune" &&
> > + test_when_finished "rm -rf there actual actual.raw expect && git -C
> > bare1 worktree prune" &&
> >    git -C bare1 worktree add --detach ../there main &&
> >    echo "worktree $(pwd)/bare1" >expect &&
> >    echo "bare" >>expect &&
> > @@ -229,7 +230,8 @@ test_expect_success '"list" all worktrees
> > --porcelain from bare main' '
> >    echo "HEAD $(git -C there rev-parse HEAD)" >>expect &&
> >    echo "detached" >>expect &&
> >    echo >>expect &&
> > - git -C bare1 worktree list --porcelain >actual &&
> > + git -C bare1 worktree list --porcelain >actual.raw &&
> > + grep -v "^created " actual.raw >actual &&
> >    test_cmp expect actual
> >   '
> >
> > diff --git a/t/t2410-worktree-metadata.sh b/t/t2410-worktree-metadata.sh
> > new file mode 100755
> > index 0000000000..3f8b508593
> > --- /dev/null
> > +++ b/t/t2410-worktree-metadata.sh
> > @@ -0,0 +1,143 @@
> > +#!/bin/sh
> > +
> > +test_description='git worktree creation timestamp and note metadata'
> > +
> > +GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main
> > +export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
> > +
> > +. ./test-lib.sh
> > +
> > +test_expect_success 'setup' '
> > + test_commit init
> > +'
> > +
> > +test_expect_success 'add writes created file' '
> > + test_when_finished "git worktree remove -f wt1 && git worktree prune" &&
> > + git worktree add wt1 &&
> > + test_path_is_file .git/worktrees/wt1/created &&
> > + # contents should be a positive integer (unix timestamp)
> > + created=$(cat .git/worktrees/wt1/created) &&
> > + test "$created" -gt 0
> > +'
> > +
> > +test_expect_success 'add --note writes note file' '
> > + test_when_finished "git worktree remove -f wt2 && git worktree prune" &&
> > + git worktree add --note "investigating bug" wt2 &&
> > + test_path_is_file .git/worktrees/wt2/note &&
> > + echo "investigating bug" >expect &&
> > + test_cmp expect .git/worktrees/wt2/note
> > +'
> > +
> > +test_expect_success 'add without --note does not create note file' '
> > + test_when_finished "git worktree remove -f wt3 && git worktree prune" &&
> > + git worktree add wt3 &&
> > + test_path_is_missing .git/worktrees/wt3/note
> > +'
> > +
> > +test_expect_success 'annotate sets a note on an existing worktree' '
> > + test_when_finished "git worktree remove -f wt4 && git worktree prune" &&
> > + git worktree add wt4 &&
> > + git worktree annotate wt4 "later note" &&
> > + echo "later note" >expect &&
> > + test_cmp expect .git/worktrees/wt4/note
> > +'
> > +
> > +test_expect_success 'annotate replaces an existing note' '
> > + test_when_finished "git worktree remove -f wt5 && git worktree prune" &&
> > + git worktree add --note "old" wt5 &&
> > + git worktree annotate wt5 "new" &&
> > + echo "new" >expect &&
> > + test_cmp expect .git/worktrees/wt5/note
> > +'
> > +
> > +test_expect_success 'annotate with no text clears the note' '
> > + test_when_finished "git worktree remove -f wt6 && git worktree prune" &&
> > + git worktree add --note "to delete" wt6 &&
> > + test_path_is_file .git/worktrees/wt6/note &&
> > + git worktree annotate wt6 &&
> > + test_path_is_missing .git/worktrees/wt6/note
> > +'
> > +
> > +test_expect_success 'annotate refuses to operate on the main worktree' '
> > + test_must_fail git worktree annotate . "should fail" 2>err &&
> > + grep -i "main working tree" err
> > +'
> > +
> > +test_expect_success 'list --show-note displays note in human output' '
> > + test_when_finished "git worktree remove -f wt7 && git worktree prune" &&
> > + git worktree add --note "release branch" wt7 &&
> > + git worktree list --show-note >actual &&
> > + grep "note: release branch" actual
> > +'
> > +
> > +test_expect_success 'list --show-created displays created timestamp' '
> > + test_when_finished "git worktree remove -f wt8 && git worktree prune" &&
> > + git worktree add wt8 &&
> > + git worktree list --show-created >actual &&
> > + grep "created: " actual
> > +'
> > +
> > +test_expect_success 'list --show-created shows unknown for legacy worktrees' '
> > + test_when_finished "git worktree remove -f wt9 && git worktree prune" &&
> > + git worktree add wt9 &&
> > + rm .git/worktrees/wt9/created &&
> > + git worktree list --show-created >actual &&
> > + grep "created: unknown" actual
> > +'
> > +
> > +test_expect_success 'list --porcelain always includes created and note' '
> > + test_when_finished "git worktree remove -f wtp && git worktree prune" &&
> > + git worktree add --note "porcelain test" wtp &&
> > + git worktree list --porcelain >actual &&
> > + grep "^created " actual &&
> > + grep "^note porcelain test" actual
> > +'
> > +
> > +test_expect_success 'list --sort=created orders by creation time' '
> > + test_when_finished "git worktree remove -f a && git worktree remove
> > -f b && git worktree remove -f c && git worktree prune" &&
> > + git worktree add a &&
> > + git worktree add b &&
> > + git worktree add c &&
> > + echo 1000 >.git/worktrees/a/created &&
> > + echo 2000 >.git/worktrees/b/created &&
> > + echo 3000 >.git/worktrees/c/created &&
> > + git worktree list --sort=created --porcelain >actual &&
> > + grep "^worktree " actual | sed -n "2,4p" >linked &&
> > + awk "NR==1" linked | grep -q "/a$" &&
> > + awk "NR==2" linked | grep -q "/b$" &&
> > + awk "NR==3" linked | grep -q "/c$"
> > +'
> > +
> > +test_expect_success 'list --sort=-created reverses order' '
> > + test_when_finished "git worktree remove -f a && git worktree remove
> > -f b && git worktree remove -f c && git worktree prune" &&
> > + git worktree add a &&
> > + git worktree add b &&
> > + git worktree add c &&
> > + echo 1000 >.git/worktrees/a/created &&
> > + echo 2000 >.git/worktrees/b/created &&
> > + echo 3000 >.git/worktrees/c/created &&
> > + git worktree list --sort=-created --porcelain >actual &&
> > + grep "^worktree " actual | sed -n "2,4p" >linked &&
> > + awk "NR==1" linked | grep -q "/c$" &&
> > + awk "NR==2" linked | grep -q "/b$" &&
> > + awk "NR==3" linked | grep -q "/a$"
> > +'
> > +
> > +test_expect_success 'list --sort=created places legacy worktrees last' '
> > + test_when_finished "git worktree remove -f early && git worktree
> > remove -f legacy && git worktree prune" &&
> > + git worktree add early &&
> > + echo 1000 >.git/worktrees/early/created &&
> > + git worktree add legacy &&
> > + rm .git/worktrees/legacy/created &&
> > + git worktree list --sort=created --porcelain >actual &&
> > + grep "^worktree " actual | sed -n "2,3p" >linked &&
> > + awk "NR==1" linked | grep -q "/early$" &&
> > + awk "NR==2" linked | grep -q "/legacy$"
> > +'
> > +
> > +test_expect_success 'list --sort with unknown key fails' '
> > + test_must_fail git worktree list --sort=bogus 2>err &&
> > + grep -i "unknown sort key" err
> > +'
> > +
> > +test_done
> > diff --git a/worktree.c b/worktree.c
> > index 97eddc3916..7989e694b7 100644
> > --- a/worktree.c
> > +++ b/worktree.c
> > @@ -14,6 +14,8 @@
> >   #include "dir.h"
> >   #include "wt-status.h"
> >   #include "config.h"
> > +#include "date.h"
> > +#include "wrapper.h"
> >
> >   void free_worktree(struct worktree *worktree)
> >   {
> > @@ -24,6 +26,7 @@ void free_worktree(struct worktree *worktree)
> >    free(worktree->head_ref);
> >    free(worktree->lock_reason);
> >    free(worktree->prune_reason);
> > + free(worktree->note);
> >    free(worktree);
> >   }
> >
> > @@ -324,6 +327,81 @@ const char *worktree_lock_reason(struct worktree *wt)
> >    return wt->lock_reason;
> >   }
> >
> > +timestamp_t worktree_created_at(struct worktree *wt)
> > +{
> > + if (is_main_worktree(wt))
> > + return 0;
> > +
> > + if (!wt->created_at_valid) {
> > + struct strbuf path = STRBUF_INIT;
> > + struct strbuf buf = STRBUF_INIT;
> > +
> > + strbuf_addstr(&path, worktree_git_path(wt, "created"));
> > + if (file_exists(path.buf) &&
> > +     strbuf_read_file(&buf, path.buf, 0) >= 0) {
> > + char *end;
> > + timestamp_t t;
> > + strbuf_trim(&buf);
> > + t = parse_timestamp(buf.buf, &end, 10);
> > + if (end != buf.buf && *end == '\0')
> > + wt->created_at = t;
> > + }
> > + wt->created_at_valid = 1;
> > + strbuf_release(&path);
> > + strbuf_release(&buf);
> > + }
> > +
> > + return wt->created_at;
> > +}
> > +
> > +const char *worktree_note(struct worktree *wt)
> > +{
> > + if (is_main_worktree(wt))
> > + return NULL;
> > +
> > + if (!wt->note_valid) {
> > + struct strbuf path = STRBUF_INIT;
> > +
> > + strbuf_addstr(&path, worktree_git_path(wt, "note"));
> > + if (file_exists(path.buf)) {
> > + struct strbuf note = STRBUF_INIT;
> > + if (strbuf_read_file(&note, path.buf, 0) < 0)
> > + die_errno(_("failed to read '%s'"), path.buf);
> > + strbuf_trim_trailing_newline(&note);
> > + wt->note = strbuf_detach(&note, NULL);
> > + } else
> > + wt->note = NULL;
> > + wt->note_valid = 1;
> > + strbuf_release(&path);
> > + }
> > +
> > + return wt->note;
> > +}
> > +
> > +int set_worktree_note(struct worktree *wt, const char *text)
> > +{
> > + char *path;
> > + int ret = 0;
> > +
> > + if (is_main_worktree(wt))
> > + return error(_("cannot set note on the main worktree"));
> > +
> > + path = repo_common_path(wt->repo, "worktrees/%s/note", wt->id);
> > + if (!text || !*text) {
> > + if (file_exists(path) && unlink(path))
> > + ret = error_errno(_("failed to remove '%s'"), path);
> > + } else {
> > + write_file(path, "%s", text);
> > + }
> > +
> > + /* invalidate cache so a follow-up worktree_note() re-reads */
> > + FREE_AND_NULL(wt->note);
> > + wt->note_valid = 0;
> > +
> > + free(path);
> > + return ret;
> > +}
> > +
> >   const char *worktree_prune_reason(struct worktree *wt, timestamp_t expire)
> >   {
> >    struct strbuf reason = STRBUF_INIT;
> > diff --git a/worktree.h b/worktree.h
> > index 1075409f9a..0fcdb8bd1b 100644
> > --- a/worktree.h
> > +++ b/worktree.h
> > @@ -13,12 +13,16 @@ struct worktree {
> >    char *head_ref; /* NULL if HEAD is broken or detached */
> >    char *lock_reason; /* private - use worktree_lock_reason */
> >    char *prune_reason;     /* private - use worktree_prune_reason */
> > + char *note; /* private - use worktree_note */
> >    struct object_id head_oid;
> > + timestamp_t created_at; /* private - use worktree_created_at; 0 if unknown */
> >    int is_detached;
> >    int is_bare;
> >    int is_current; /* does `path` match `repo->worktree` */
> >    int lock_reason_valid; /* private */
> >    int prune_reason_valid; /* private */
> > + int note_valid;        /* private */
> > + int created_at_valid;  /* private */
> >   };
> >
> >   /*
> > @@ -96,6 +100,25 @@ int is_main_worktree(const struct worktree *wt);
> >    */
> >   const char *worktree_lock_reason(struct worktree *wt);
> >
> > +/*
> > + * Return the worktree's recorded creation timestamp, or 0 if no timestamp
> > + * was recorded (e.g. a worktree created before this metadata existed, or
> > + * the main worktree which never carries the file).
> > + */
> > +timestamp_t worktree_created_at(struct worktree *wt);
> > +
> > +/*
> > + * Return the user-supplied note/description for the given worktree, or NULL
> > + * if none was set.
> > + */
> > +const char *worktree_note(struct worktree *wt);
> > +
> > +/*
> > + * Write or replace the worktree's note. Pass NULL or "" to delete the note.
> > + * Returns 0 on success, -1 on failure. Not valid for the main worktree.
> > + */
> > +int set_worktree_note(struct worktree *wt, const char *text);
> > +
> >   /*
> >    * Return the reason string if the given worktree should be pruned, otherwise
> >    * NULL if it should not be pruned. `expire` defines a grace period to prune
> > --
> >
>


-- 
Norbert Kiesel | Staff Software Engineer | Credit Karma
norbert.kiesel@creditkarma.com | www.creditkarma.com

This email may contain confidential and privileged information. Any
review, use, distribution, or disclosure by anyone other than the
intended recipient(s) is prohibited. If you are not the intended
recipient, please contact the sender by reply email and delete all
copies of this message.

^ permalink raw reply

* Re: [PATCH v2] compat/posix.h: enable UNUSED warning messages for Clang
From: Dominik Loidolt @ 2026-06-05 15:53 UTC (permalink / raw)
  To: Patrick Steinhardt; +Cc: gitster, git, asedeno, asedeno, avarab
In-Reply-To: <aiLNqQgiQPlviB5X@pks.im>

On Fri, Jun 05, 2026 at 03:22:49PM +0200, Patrick Steinhardt wrote:
> I was wondering about that, too. The question that I have is whether
> there's any particular reason why the check was written that way. So in
> the best case we'd do some digging into the history to figure out why
> this looks the way it looks like.

I think the current bit-shift style introduced by 89c855ed3c (git-compat-util.h:
implement a different ARRAY_SIZE macro for for safely deriving the size of
array, 2015-04-30) was inherited from glibc [0].

I found that NetBSD [1] has long used the more explicit comparison form instead
of the bit-shift style, and other BSDs seem to do the same. So there is at
least established precedent for writing the version check that way. :-)

I see no obvious reason to prefer the bit-shift style today.

 Dominik

[0] https://sourceware.org/git/?p=glibc.git;a=commitdiff;h=4360eafdd20769fa9d42c075853271debd06f7d1
[1] https://github.com/NetBSD/src/commit/2fffc76da21e012509677f5310464f62797bd1bf

^ permalink raw reply

* Re: [PATCH] worktree: record creation time and free-form note
From: Phillip Wood @ 2026-06-05 15:17 UTC (permalink / raw)
  To: Kiesel, Norbert, git
In-Reply-To: <CAPGaHku+RAV+FA3C0md0xHiavfdB_anoqcMM06MAiU1VyMAdLA@mail.gmail.com>

Hi Norbert

On 02/06/2026 22:40, Kiesel, Norbert wrote:
> 
> Add per-worktree metadata so users can answer "what is this worktree
> for, and when did I make it?" without resorting to external notes.

A couple of thoughts related to this

Isn't "what is the worktree for" a property of the branch that's checked 
out, not the worktree itself? We already have 
branch.<branch>.description to add a descritpion to a branch. If you 
have a detached HEAD it is trickier though.

I don't think I've ever wanted to know when a worktree was created. I 
would find it useful to be able to sort worktrees by when they were last 
updated (i.e. the reflog date of HEAD in each worktree) to see which 
ones are stale though.

Thanks

Phillip

> When `git worktree add` creates a linked worktree, it now writes a
> `created` file containing the unix timestamp. A new `--note <string>`
> option to `add`, and a new `git worktree annotate <worktree> [<note>]`
> subcommand, store an optional free-form description in a `note` file
> next to the other administrative files. Passing `annotate` without a
> note clears it. The main worktree carries no metadata and cannot be
> annotated.
> 
> `git worktree list` learns `--show-created` and `--show-note` for
> human-readable output, and `--sort=<key>` (path or created, optionally
> prefixed with `-` to reverse) for ordering linked worktrees; the main
> worktree always stays first. Worktrees without a recorded timestamp
> (those created before this change) display as `created: unknown` and
> sort after timestamped ones. Porcelain output unconditionally emits
> `created` and `note` lines when the corresponding metadata is present.
> 
> Tests cover add/annotate/list behaviour and the legacy-worktree case.
> The two existing porcelain assertions in t2402 are taught to strip the
> new `created` line so they continue to pass.
> 
> Signed-off-by: Norbert Kiesel <norbert.kiesel@creditkarma.com>
> ---
>   Documentation/git-worktree.adoc |  61 ++++++++++++-
>   builtin/worktree.c              | 152 +++++++++++++++++++++++++++++++-
>   t/meson.build                   |   1 +
>   t/t2402-worktree-list.sh        |  10 ++-
>   t/t2410-worktree-metadata.sh    | 143 ++++++++++++++++++++++++++++++
>   worktree.c                      |  78 ++++++++++++++++
>   worktree.h                      |  23 +++++
>   7 files changed, 459 insertions(+), 9 deletions(-)
>   create mode 100755 t/t2410-worktree-metadata.sh
> 
> diff --git a/Documentation/git-worktree.adoc b/Documentation/git-worktree.adoc
> index fbf8426cd9..200f3d7772 100644
> --- a/Documentation/git-worktree.adoc
> +++ b/Documentation/git-worktree.adoc
> @@ -10,8 +10,11 @@ SYNOPSIS
>   --------
>   [synopsis]
>   git worktree add [-f] [--detach] [--checkout] [--lock [--reason <string>]]
> + [--note <string>]
>    [--orphan] [(-b | -B) <new-branch>] <path> [<commit-ish>]
> -git worktree list [-v | --porcelain [-z]]
> +git worktree annotate <worktree> [<note>]
> +git worktree list [-v | --porcelain [-z]] [--show-created] [--show-note]
> + [--sort=<key>]
>   git worktree lock [--reason <string>] <worktree>
>   git worktree move <worktree> <new-path>
>   git worktree prune [-n] [-v] [--expire <expire>]
> @@ -106,6 +109,15 @@ passed to the command. In the event the
> repository has a remote and
>   command fails with a warning reminding the user to fetch from their remote
>   first (or override by using `-f`/`--force`).
> 
> +`annotate <worktree> [<note>]`::
> +
> +Set, replace, or clear a free-form note (description) on a linked worktree.
> +Useful for recording what a worktree was created for so it can be identified
> +later. With _<note>_, the worktree's note is set or replaced; without a note
> +argument, the existing note is cleared. The note for a worktree may also be
> +set at creation time with `git worktree add --note <note>`. The main
> +worktree cannot be annotated.
> +
>   `list`::
> 
>   List details of each worktree.  The main worktree is listed first,
> @@ -114,6 +126,20 @@ whether the worktree is bare, the revision
> currently checked out, the
>   branch currently checked out (or "detached HEAD" if none), "locked" if
>   the worktree is locked, "prunable" if the worktree can be pruned by the
>   `prune` command.
> ++
> +Each worktree's creation timestamp is recorded when it is created with
> +`git worktree add`. Worktrees created before this feature existed have no
> +recorded creation timestamp; for them, `list` reports `created: unknown`
> +in human output and omits the `created` line in `--porcelain` output. Pass
> +`--show-created` to include creation timestamps in human output. Worktrees
> +without a recorded timestamp sort last (or first when reversed) with
> +`--sort=created`.
> ++
> +Pass `--show-note` to include any user-provided note in human output. In
> +`--porcelain` output, both `created` and `note` lines are emitted whenever
> +present. Use `--sort=<key>` (where _<key>_ is `path` or `created`,
> +optionally prefixed with `-` to reverse) to order the linked worktrees;
> +the main worktree always remains first.
> 
>   `lock`::
> 
> @@ -286,6 +312,32 @@ _<time>_.
>    With `lock` or with `add --lock`, an explanation why the worktree
>    is locked.
> 
> +`--note <string>`::
> + With `add`, attach a free-form note (description) to the new worktree.
> + The note is stored alongside the worktree's administrative files and
> + can be displayed with `git worktree list --show-note` or in
> + `--porcelain` output. It can be changed later with
> + `git worktree annotate`.
> +
> +`--show-created`::
> + With `list`, include each worktree's creation timestamp in the
> + human-readable output. Worktrees with no recorded creation time are
> + shown as `created: unknown`. In `--porcelain` output, the creation
> + timestamp is always included (when available) on a `created` line.
> +
> +`--show-note`::
> + With `list`, include each worktree's note (if set) in the
> + human-readable output. In `--porcelain` output, the note is always
> + included (when set) on a `note` line.
> +
> +`--sort=<key>`::
> + With `list`, sort linked worktrees by _<key>_, which is one of
> + `path` or `created`. Prefix with `-` to reverse the order, e.g.
> + `--sort=-created` lists newest first. The main worktree is always
> + listed first regardless of sort order. Worktrees with no recorded
> + creation timestamp sort after those that have one (or before, when
> + reversed).
> +
>   _<worktree>_::
>    Worktrees can be identified by path, either relative or absolute.
>   +
> @@ -462,7 +514,9 @@ are terminated with NUL rather than a newline.
> Attributes are listed with a
>   label and value separated by a single space.  Boolean attributes (like `bare`
>   and `detached`) are listed as a label only, and are present only
>   if the value is true.  Some attributes (like `locked`) can be listed as a label
> -only or with a value depending upon whether a reason is available.  The first
> +only or with a value depending upon whether a reason is available.  Optional
> +valued attributes (like `created` and `note`) appear only when the
> +corresponding metadata has been recorded for that worktree.  The first
>   attribute of a worktree is always `worktree`, an empty line indicates the
>   end of the record.  For example:
> 
> @@ -474,10 +528,13 @@ bare
>   worktree /path/to/linked-worktree
>   HEAD abcd1234abcd1234abcd1234abcd1234abcd1234
>   branch refs/heads/master
> +created 2026-06-01T12:34:56Z
> +note investigating login bug
> 
>   worktree /path/to/other-linked-worktree
>   HEAD 1234abc1234abc1234abc1234abc1234abc1234a
>   detached
> +created 2026-05-28T08:15:00Z
> 
>   worktree /path/to/linked-worktree-locked-no-reason
>   HEAD 5678abc5678abc5678abc5678abc5678abc5678c
> diff --git a/builtin/worktree.c b/builtin/worktree.c
> index d21c43fde3..ac22277d6c 100644
> --- a/builtin/worktree.c
> +++ b/builtin/worktree.c
> @@ -27,13 +27,16 @@
>   #include "utf8.h"
>   #include "worktree.h"
>   #include "quote.h"
> +#include "date.h"
> 
>   #define BUILTIN_WORKTREE_ADD_USAGE \
>    N_("git worktree add [-f] [--detach] [--checkout] [--lock [--reason
> <string>]]\n" \
> +    "                 [--note <string>]\n" \
>       "                 [--orphan] [(-b | -B) <new-branch>] <path>
> [<commit-ish>]")
> 
>   #define BUILTIN_WORKTREE_LIST_USAGE \
> - N_("git worktree list [-v | --porcelain [-z]]")
> + N_("git worktree list [-v | --porcelain [-z]] [--show-created]
> [--show-note]\n" \
> +    "                  [--sort=<key>]")
>   #define BUILTIN_WORKTREE_LOCK_USAGE \
>    N_("git worktree lock [--reason <string>] <worktree>")
>   #define BUILTIN_WORKTREE_MOVE_USAGE \
> @@ -46,6 +49,8 @@
>    N_("git worktree repair [<path>...]")
>   #define BUILTIN_WORKTREE_UNLOCK_USAGE \
>    N_("git worktree unlock <worktree>")
> +#define BUILTIN_WORKTREE_ANNOTATE_USAGE \
> + N_("git worktree annotate <worktree> [<note>]")
> 
>   #define WORKTREE_ADD_DWIM_ORPHAN_INFER_TEXT \
>    _("No possible source branch, inferring '--orphan'")
> @@ -66,6 +71,7 @@
> 
>   static const char * const git_worktree_usage[] = {
>    BUILTIN_WORKTREE_ADD_USAGE,
> + BUILTIN_WORKTREE_ANNOTATE_USAGE,
>    BUILTIN_WORKTREE_LIST_USAGE,
>    BUILTIN_WORKTREE_LOCK_USAGE,
>    BUILTIN_WORKTREE_MOVE_USAGE,
> @@ -116,6 +122,11 @@ static const char * const git_worktree_unlock_usage[] = {
>    NULL
>   };
> 
> +static const char * const git_worktree_annotate_usage[] = {
> + BUILTIN_WORKTREE_ANNOTATE_USAGE,
> + NULL
> +};
> +
>   struct add_opts {
>    int force;
>    int detach;
> @@ -124,6 +135,7 @@ struct add_opts {
>    int orphan;
>    int relative_paths;
>    const char *keep_locked;
> + const char *note;
>   };
> 
>   static int show_only;
> @@ -131,6 +143,8 @@ static int verbose;
>   static int guess_remote;
>   static int use_relative_paths;
>   static timestamp_t expire;
> +static int show_created;
> +static int show_note;
> 
>   static int git_worktree_config(const char *var, const char *value,
>           const struct config_context *ctx, void *cb)
> @@ -544,6 +558,16 @@ static int add_worktree(const char *path, const
> char *refname,
>    strbuf_addf(&sb, "%s/commondir", sb_repo.buf);
>    write_file(sb.buf, "../..");
> 
> + strbuf_reset(&sb);
> + strbuf_addf(&sb, "%s/created", sb_repo.buf);
> + write_file(sb.buf, "%"PRItime, (timestamp_t) time(NULL));
> +
> + if (opts->note && *opts->note) {
> + strbuf_reset(&sb);
> + strbuf_addf(&sb, "%s/note", sb_repo.buf);
> + write_file(sb.buf, "%s", opts->note);
> + }
> +
>    /*
>    * Set up the ref store of the worktree and create the HEAD reference.
>    */
> @@ -815,6 +839,8 @@ static int add(int ac, const char **av, const char *prefix,
>    OPT_BOOL(0, "lock", &keep_locked, N_("keep the new working tree locked")),
>    OPT_STRING(0, "reason", &lock_reason, N_("string"),
>       N_("reason for locking")),
> + OPT_STRING(0, "note", &opts.note, N_("string"),
> +    N_("attach a free-form note/description to the worktree")),
>    OPT__QUIET(&opts.quiet, N_("suppress progress reporting")),
>    OPT_PASSTHRU(0, "track", &opt_track, NULL,
>         N_("set up tracking mode (see git-branch(1))"),
> @@ -963,6 +989,8 @@ static int add(int ac, const char **av, const char *prefix,
>   static void show_worktree_porcelain(struct worktree *wt, int line_terminator)
>   {
>    const char *reason;
> + const char *note;
> + timestamp_t created;
> 
>    printf("worktree %s%c", wt->path, line_terminator);
>    if (wt->is_bare)
> @@ -975,6 +1003,18 @@ static void show_worktree_porcelain(struct
> worktree *wt, int line_terminator)
>    printf("branch %s%c", wt->head_ref, line_terminator);
>    }
> 
> + created = worktree_created_at(wt);
> + if (created)
> + printf("created %s%c",
> +        show_date(created, 0, DATE_MODE(ISO8601_STRICT)),
> +        line_terminator);
> +
> + note = worktree_note(wt);
> + if (note && *note) {
> + fputs("note ", stdout);
> + write_name_quoted(note, stdout, line_terminator);
> + }
> +
>    reason = worktree_lock_reason(wt);
>    if (reason) {
>    fputs("locked", stdout);
> @@ -1034,6 +1074,21 @@ static void show_worktree(struct worktree *wt,
> struct worktree_display *display,
>    else if (reason)
>    strbuf_addstr(&sb, " prunable");
> 
> + if (show_created || verbose) {
> + timestamp_t created = worktree_created_at(wt);
> + if (created)
> + strbuf_addf(&sb, "\n\tcreated: %s",
> +     show_date(created, 0, DATE_MODE(ISO8601)));
> + else if (show_created && !is_main_worktree(wt))
> + strbuf_addstr(&sb, "\n\tcreated: unknown");
> + }
> +
> + if (show_note || verbose) {
> + const char *note = worktree_note(wt);
> + if (note && *note)
> + strbuf_addf(&sb, "\n\tnote: %s", note);
> + }
> +
>    printf("%s\n", sb.buf);
>    strbuf_release(&sb);
>   }
> @@ -1068,6 +1123,27 @@ static int pathcmp(const void *a_, const void *b_)
>    return fspathcmp((*a)->path, (*b)->path);
>   }
> 
> +static int createdcmp(const void *a_, const void *b_)
> +{
> + struct worktree *const *a = a_;
> + struct worktree *const *b = b_;
> + timestamp_t ta = worktree_created_at(*a);
> + timestamp_t tb = worktree_created_at(*b);
> +
> + /* Worktrees without a recorded timestamp (legacy) sort after those
> with one. */
> + if (!ta && !tb)
> + return fspathcmp((*a)->path, (*b)->path);
> + if (!ta)
> + return 1;
> + if (!tb)
> + return -1;
> + if (ta < tb)
> + return -1;
> + if (ta > tb)
> + return 1;
> + return 0;
> +}
> +
>   static void pathsort(struct worktree **wt)
>   {
>    int n = 0;
> @@ -1078,11 +1154,43 @@ static void pathsort(struct worktree **wt)
>    QSORT(wt, n, pathcmp);
>   }
> 
> +static int sort_worktrees(struct worktree **wt, const char *key)
> +{
> + int n = 0, reverse = 0;
> + struct worktree **p = wt;
> + int (*cmp)(const void *, const void *);
> +
> + if (*key == '-') {
> + reverse = 1;
> + key++;
> + }
> + if (!strcmp(key, "path"))
> + cmp = pathcmp;
> + else if (!strcmp(key, "created"))
> + cmp = createdcmp;
> + else
> + return -1;
> +
> + while (*p++)
> + n++;
> + QSORT(wt, n, cmp);
> + if (reverse) {
> + int i;
> + for (i = 0; i < n / 2; i++) {
> + struct worktree *tmp = wt[i];
> + wt[i] = wt[n - 1 - i];
> + wt[n - 1 - i] = tmp;
> + }
> + }
> + return 0;
> +}
> +
>   static int list(int ac, const char **av, const char *prefix,
>    struct repository *repo UNUSED)
>   {
>    int porcelain = 0;
>    int line_terminator = '\n';
> + const char *sort_key = NULL;
> 
>    struct option options[] = {
>    OPT_BOOL(0, "porcelain", &porcelain, N_("machine-readable output")),
> @@ -1091,6 +1199,12 @@ static int list(int ac, const char **av, const
> char *prefix,
>    N_("add 'prunable' annotation to missing worktrees older than <time>")),
>    OPT_SET_INT('z', NULL, &line_terminator,
>        N_("terminate records with a NUL character"), '\0'),
> + OPT_BOOL(0, "show-created", &show_created,
> + N_("show worktree creation timestamps")),
> + OPT_BOOL(0, "show-note", &show_note,
> + N_("show worktree notes")),
> + OPT_STRING(0, "sort", &sort_key, N_("key"),
> +    N_("sort worktrees by key (path, created); prefix with - to reverse")),
>    OPT_END()
>    };
> 
> @@ -1107,8 +1221,13 @@ static int list(int ac, const char **av, const
> char *prefix,
>    int path_maxwidth = 0, abbrev = DEFAULT_ABBREV, i;
>    struct worktree_display *display = NULL;
> 
> - /* sort worktrees by path but keep main worktree at top */
> - pathsort(worktrees + 1);
> + /* sort worktrees but keep main worktree at top */
> + if (sort_key) {
> + if (sort_worktrees(worktrees + 1, sort_key))
> + die(_("unknown sort key '%s'"), sort_key);
> + } else {
> + pathsort(worktrees + 1);
> + }
> 
>    if (!porcelain)
>    measure_widths(worktrees, &abbrev,
> @@ -1200,6 +1319,32 @@ static int unlock_worktree(int ac, const char
> **av, const char *prefix,
>    return ret;
>   }
> 
> +static int annotate_worktree(int ac, const char **av, const char *prefix,
> +      struct repository *repo UNUSED)
> +{
> + struct option options[] = {
> + OPT_END()
> + };
> + struct worktree **worktrees, *wt;
> + int ret;
> +
> + ac = parse_options(ac, av, prefix, options, git_worktree_annotate_usage, 0);
> + if (ac < 1 || ac > 2)
> + usage_with_options(git_worktree_annotate_usage, options);
> +
> + worktrees = get_worktrees();
> + wt = find_worktree(worktrees, prefix, av[0]);
> + if (!wt)
> + die(_("'%s' is not a working tree"), av[0]);
> + if (is_main_worktree(wt))
> + die(_("The main working tree cannot be annotated"));
> +
> + ret = set_worktree_note(wt, ac == 2 ? av[1] : NULL);
> +
> + free_worktrees(worktrees);
> + return ret;
> +}
> +
>   static void validate_no_submodules(const struct worktree *wt)
>   {
>    struct index_state istate = INDEX_STATE_INIT(the_repository);
> @@ -1469,6 +1614,7 @@ int cmd_worktree(int ac,
>    parse_opt_subcommand_fn *fn = NULL;
>    struct option options[] = {
>    OPT_SUBCOMMAND("add", &fn, add),
> + OPT_SUBCOMMAND("annotate", &fn, annotate_worktree),
>    OPT_SUBCOMMAND("prune", &fn, prune),
>    OPT_SUBCOMMAND("list", &fn, list),
>    OPT_SUBCOMMAND("lock", &fn, lock_worktree),
> diff --git a/t/meson.build b/t/meson.build
> index 2af8d01279..7b6e8435d7 100644
> --- a/t/meson.build
> +++ b/t/meson.build
> @@ -308,6 +308,7 @@ integration_tests = [
>     't2405-worktree-submodule.sh',
>     't2406-worktree-repair.sh',
>     't2407-worktree-heads.sh',
> +  't2410-worktree-metadata.sh',
>     't2500-untracked-overwriting.sh',
>     't2501-cwd-empty.sh',
>     't3000-ls-files-others.sh',
> diff --git a/t/t2402-worktree-list.sh b/t/t2402-worktree-list.sh
> index e0c6abd2f5..8422340443 100755
> --- a/t/t2402-worktree-list.sh
> +++ b/t/t2402-worktree-list.sh
> @@ -71,7 +71,8 @@ test_expect_success '"list" all worktrees --porcelain' '
>    echo "HEAD $(git rev-parse HEAD)" >>expect &&
>    echo "detached" >>expect &&
>    echo >>expect &&
> - git worktree list --porcelain >actual &&
> + git worktree list --porcelain >actual.raw &&
> + grep -v "^created " actual.raw >actual &&
>    test_cmp expect actual
>   '
> 
> @@ -86,7 +87,7 @@ test_expect_success '"list" all worktrees --porcelain -z' '
>    "$(git -C here rev-parse --show-toplevel)" \
>    "$(git rev-parse HEAD)" >>expect &&
>    git worktree list --porcelain -z >_actual &&
> - nul_to_q <_actual >actual &&
> + nul_to_q <_actual | tr Q "\n" | grep -v "^created " | tr "\n" Q >actual &&
>    test_cmp expect actual
>   '
> 
> @@ -220,7 +221,7 @@ test_expect_success '"list" all worktrees from bare main' '
>   '
> 
>   test_expect_success '"list" all worktrees --porcelain from bare main' '
> - test_when_finished "rm -rf there actual expect && git -C bare1
> worktree prune" &&
> + test_when_finished "rm -rf there actual actual.raw expect && git -C
> bare1 worktree prune" &&
>    git -C bare1 worktree add --detach ../there main &&
>    echo "worktree $(pwd)/bare1" >expect &&
>    echo "bare" >>expect &&
> @@ -229,7 +230,8 @@ test_expect_success '"list" all worktrees
> --porcelain from bare main' '
>    echo "HEAD $(git -C there rev-parse HEAD)" >>expect &&
>    echo "detached" >>expect &&
>    echo >>expect &&
> - git -C bare1 worktree list --porcelain >actual &&
> + git -C bare1 worktree list --porcelain >actual.raw &&
> + grep -v "^created " actual.raw >actual &&
>    test_cmp expect actual
>   '
> 
> diff --git a/t/t2410-worktree-metadata.sh b/t/t2410-worktree-metadata.sh
> new file mode 100755
> index 0000000000..3f8b508593
> --- /dev/null
> +++ b/t/t2410-worktree-metadata.sh
> @@ -0,0 +1,143 @@
> +#!/bin/sh
> +
> +test_description='git worktree creation timestamp and note metadata'
> +
> +GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main
> +export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
> +
> +. ./test-lib.sh
> +
> +test_expect_success 'setup' '
> + test_commit init
> +'
> +
> +test_expect_success 'add writes created file' '
> + test_when_finished "git worktree remove -f wt1 && git worktree prune" &&
> + git worktree add wt1 &&
> + test_path_is_file .git/worktrees/wt1/created &&
> + # contents should be a positive integer (unix timestamp)
> + created=$(cat .git/worktrees/wt1/created) &&
> + test "$created" -gt 0
> +'
> +
> +test_expect_success 'add --note writes note file' '
> + test_when_finished "git worktree remove -f wt2 && git worktree prune" &&
> + git worktree add --note "investigating bug" wt2 &&
> + test_path_is_file .git/worktrees/wt2/note &&
> + echo "investigating bug" >expect &&
> + test_cmp expect .git/worktrees/wt2/note
> +'
> +
> +test_expect_success 'add without --note does not create note file' '
> + test_when_finished "git worktree remove -f wt3 && git worktree prune" &&
> + git worktree add wt3 &&
> + test_path_is_missing .git/worktrees/wt3/note
> +'
> +
> +test_expect_success 'annotate sets a note on an existing worktree' '
> + test_when_finished "git worktree remove -f wt4 && git worktree prune" &&
> + git worktree add wt4 &&
> + git worktree annotate wt4 "later note" &&
> + echo "later note" >expect &&
> + test_cmp expect .git/worktrees/wt4/note
> +'
> +
> +test_expect_success 'annotate replaces an existing note' '
> + test_when_finished "git worktree remove -f wt5 && git worktree prune" &&
> + git worktree add --note "old" wt5 &&
> + git worktree annotate wt5 "new" &&
> + echo "new" >expect &&
> + test_cmp expect .git/worktrees/wt5/note
> +'
> +
> +test_expect_success 'annotate with no text clears the note' '
> + test_when_finished "git worktree remove -f wt6 && git worktree prune" &&
> + git worktree add --note "to delete" wt6 &&
> + test_path_is_file .git/worktrees/wt6/note &&
> + git worktree annotate wt6 &&
> + test_path_is_missing .git/worktrees/wt6/note
> +'
> +
> +test_expect_success 'annotate refuses to operate on the main worktree' '
> + test_must_fail git worktree annotate . "should fail" 2>err &&
> + grep -i "main working tree" err
> +'
> +
> +test_expect_success 'list --show-note displays note in human output' '
> + test_when_finished "git worktree remove -f wt7 && git worktree prune" &&
> + git worktree add --note "release branch" wt7 &&
> + git worktree list --show-note >actual &&
> + grep "note: release branch" actual
> +'
> +
> +test_expect_success 'list --show-created displays created timestamp' '
> + test_when_finished "git worktree remove -f wt8 && git worktree prune" &&
> + git worktree add wt8 &&
> + git worktree list --show-created >actual &&
> + grep "created: " actual
> +'
> +
> +test_expect_success 'list --show-created shows unknown for legacy worktrees' '
> + test_when_finished "git worktree remove -f wt9 && git worktree prune" &&
> + git worktree add wt9 &&
> + rm .git/worktrees/wt9/created &&
> + git worktree list --show-created >actual &&
> + grep "created: unknown" actual
> +'
> +
> +test_expect_success 'list --porcelain always includes created and note' '
> + test_when_finished "git worktree remove -f wtp && git worktree prune" &&
> + git worktree add --note "porcelain test" wtp &&
> + git worktree list --porcelain >actual &&
> + grep "^created " actual &&
> + grep "^note porcelain test" actual
> +'
> +
> +test_expect_success 'list --sort=created orders by creation time' '
> + test_when_finished "git worktree remove -f a && git worktree remove
> -f b && git worktree remove -f c && git worktree prune" &&
> + git worktree add a &&
> + git worktree add b &&
> + git worktree add c &&
> + echo 1000 >.git/worktrees/a/created &&
> + echo 2000 >.git/worktrees/b/created &&
> + echo 3000 >.git/worktrees/c/created &&
> + git worktree list --sort=created --porcelain >actual &&
> + grep "^worktree " actual | sed -n "2,4p" >linked &&
> + awk "NR==1" linked | grep -q "/a$" &&
> + awk "NR==2" linked | grep -q "/b$" &&
> + awk "NR==3" linked | grep -q "/c$"
> +'
> +
> +test_expect_success 'list --sort=-created reverses order' '
> + test_when_finished "git worktree remove -f a && git worktree remove
> -f b && git worktree remove -f c && git worktree prune" &&
> + git worktree add a &&
> + git worktree add b &&
> + git worktree add c &&
> + echo 1000 >.git/worktrees/a/created &&
> + echo 2000 >.git/worktrees/b/created &&
> + echo 3000 >.git/worktrees/c/created &&
> + git worktree list --sort=-created --porcelain >actual &&
> + grep "^worktree " actual | sed -n "2,4p" >linked &&
> + awk "NR==1" linked | grep -q "/c$" &&
> + awk "NR==2" linked | grep -q "/b$" &&
> + awk "NR==3" linked | grep -q "/a$"
> +'
> +
> +test_expect_success 'list --sort=created places legacy worktrees last' '
> + test_when_finished "git worktree remove -f early && git worktree
> remove -f legacy && git worktree prune" &&
> + git worktree add early &&
> + echo 1000 >.git/worktrees/early/created &&
> + git worktree add legacy &&
> + rm .git/worktrees/legacy/created &&
> + git worktree list --sort=created --porcelain >actual &&
> + grep "^worktree " actual | sed -n "2,3p" >linked &&
> + awk "NR==1" linked | grep -q "/early$" &&
> + awk "NR==2" linked | grep -q "/legacy$"
> +'
> +
> +test_expect_success 'list --sort with unknown key fails' '
> + test_must_fail git worktree list --sort=bogus 2>err &&
> + grep -i "unknown sort key" err
> +'
> +
> +test_done
> diff --git a/worktree.c b/worktree.c
> index 97eddc3916..7989e694b7 100644
> --- a/worktree.c
> +++ b/worktree.c
> @@ -14,6 +14,8 @@
>   #include "dir.h"
>   #include "wt-status.h"
>   #include "config.h"
> +#include "date.h"
> +#include "wrapper.h"
> 
>   void free_worktree(struct worktree *worktree)
>   {
> @@ -24,6 +26,7 @@ void free_worktree(struct worktree *worktree)
>    free(worktree->head_ref);
>    free(worktree->lock_reason);
>    free(worktree->prune_reason);
> + free(worktree->note);
>    free(worktree);
>   }
> 
> @@ -324,6 +327,81 @@ const char *worktree_lock_reason(struct worktree *wt)
>    return wt->lock_reason;
>   }
> 
> +timestamp_t worktree_created_at(struct worktree *wt)
> +{
> + if (is_main_worktree(wt))
> + return 0;
> +
> + if (!wt->created_at_valid) {
> + struct strbuf path = STRBUF_INIT;
> + struct strbuf buf = STRBUF_INIT;
> +
> + strbuf_addstr(&path, worktree_git_path(wt, "created"));
> + if (file_exists(path.buf) &&
> +     strbuf_read_file(&buf, path.buf, 0) >= 0) {
> + char *end;
> + timestamp_t t;
> + strbuf_trim(&buf);
> + t = parse_timestamp(buf.buf, &end, 10);
> + if (end != buf.buf && *end == '\0')
> + wt->created_at = t;
> + }
> + wt->created_at_valid = 1;
> + strbuf_release(&path);
> + strbuf_release(&buf);
> + }
> +
> + return wt->created_at;
> +}
> +
> +const char *worktree_note(struct worktree *wt)
> +{
> + if (is_main_worktree(wt))
> + return NULL;
> +
> + if (!wt->note_valid) {
> + struct strbuf path = STRBUF_INIT;
> +
> + strbuf_addstr(&path, worktree_git_path(wt, "note"));
> + if (file_exists(path.buf)) {
> + struct strbuf note = STRBUF_INIT;
> + if (strbuf_read_file(&note, path.buf, 0) < 0)
> + die_errno(_("failed to read '%s'"), path.buf);
> + strbuf_trim_trailing_newline(&note);
> + wt->note = strbuf_detach(&note, NULL);
> + } else
> + wt->note = NULL;
> + wt->note_valid = 1;
> + strbuf_release(&path);
> + }
> +
> + return wt->note;
> +}
> +
> +int set_worktree_note(struct worktree *wt, const char *text)
> +{
> + char *path;
> + int ret = 0;
> +
> + if (is_main_worktree(wt))
> + return error(_("cannot set note on the main worktree"));
> +
> + path = repo_common_path(wt->repo, "worktrees/%s/note", wt->id);
> + if (!text || !*text) {
> + if (file_exists(path) && unlink(path))
> + ret = error_errno(_("failed to remove '%s'"), path);
> + } else {
> + write_file(path, "%s", text);
> + }
> +
> + /* invalidate cache so a follow-up worktree_note() re-reads */
> + FREE_AND_NULL(wt->note);
> + wt->note_valid = 0;
> +
> + free(path);
> + return ret;
> +}
> +
>   const char *worktree_prune_reason(struct worktree *wt, timestamp_t expire)
>   {
>    struct strbuf reason = STRBUF_INIT;
> diff --git a/worktree.h b/worktree.h
> index 1075409f9a..0fcdb8bd1b 100644
> --- a/worktree.h
> +++ b/worktree.h
> @@ -13,12 +13,16 @@ struct worktree {
>    char *head_ref; /* NULL if HEAD is broken or detached */
>    char *lock_reason; /* private - use worktree_lock_reason */
>    char *prune_reason;     /* private - use worktree_prune_reason */
> + char *note; /* private - use worktree_note */
>    struct object_id head_oid;
> + timestamp_t created_at; /* private - use worktree_created_at; 0 if unknown */
>    int is_detached;
>    int is_bare;
>    int is_current; /* does `path` match `repo->worktree` */
>    int lock_reason_valid; /* private */
>    int prune_reason_valid; /* private */
> + int note_valid;        /* private */
> + int created_at_valid;  /* private */
>   };
> 
>   /*
> @@ -96,6 +100,25 @@ int is_main_worktree(const struct worktree *wt);
>    */
>   const char *worktree_lock_reason(struct worktree *wt);
> 
> +/*
> + * Return the worktree's recorded creation timestamp, or 0 if no timestamp
> + * was recorded (e.g. a worktree created before this metadata existed, or
> + * the main worktree which never carries the file).
> + */
> +timestamp_t worktree_created_at(struct worktree *wt);
> +
> +/*
> + * Return the user-supplied note/description for the given worktree, or NULL
> + * if none was set.
> + */
> +const char *worktree_note(struct worktree *wt);
> +
> +/*
> + * Write or replace the worktree's note. Pass NULL or "" to delete the note.
> + * Returns 0 on success, -1 on failure. Not valid for the main worktree.
> + */
> +int set_worktree_note(struct worktree *wt, const char *text);
> +
>   /*
>    * Return the reason string if the given worktree should be pruned, otherwise
>    * NULL if it should not be pruned. `expire` defines a grace period to prune
> --
> 


^ permalink raw reply

* Re: [PATCH v2 5/9] reset: introduce ability to skip reference updates
From: Phillip Wood @ 2026-06-05 15:12 UTC (permalink / raw)
  To: Patrick Steinhardt, git; +Cc: Pablo Sabater, Junio C Hamano
In-Reply-To: <20260603-b4-pks-history-drop-v2-5-742cb5b5176d@pks.im>

Hi Patrick

On 03/06/2026 17:14, Patrick Steinhardt wrote:
> In a subsequent commit we'll introduce a new caller to `reset_head()`
> that really only wants to update the index and working tree, without
> updating any references. Introduce a new flag that lets the caller
> perform this operation.

We already have a flag to update ORIG_HEAD so would it make more sense 
to have a flag to update HEAD, rather than adding a flag to disable the 
updates? It would mean updating the existing callers but I think it is a 
clearer api and it avoids the pitfall of

	RESET_HEAD_ORIG_HEAD | RESET_HEAD_SKIP_REF_UPDATES

I wonder about the function name as well if we make updating HEAD 
optional then what does reset_head() mean? Maybe we should rename it 
something along the lines of reset_worktree() or update_working_copy()? 
I'm not really sure what a good name would be.

Thanks

Phillip

> Signed-off-by: Patrick Steinhardt <ps@pks.im>
> ---
>   reset.c | 7 ++++++-
>   reset.h | 3 +++
>   2 files changed, 9 insertions(+), 1 deletion(-)
> 
> diff --git a/reset.c b/reset.c
> index a8d7eea4d6..ed9df6ca5c 100644
> --- a/reset.c
> +++ b/reset.c
> @@ -93,6 +93,7 @@ int reset_head(struct repository *r, const struct reset_head_opts *opts)
>   	unsigned refs_only = opts->flags & RESET_HEAD_REFS_ONLY;
>   	unsigned update_orig_head = opts->flags & RESET_HEAD_ORIG_HEAD;
>   	unsigned dry_run = opts->flags & RESET_HEAD_DRY_RUN;
> +	unsigned skip_ref_updates = opts->flags & RESET_HEAD_SKIP_REF_UPDATES;
>   	struct object_id *head = NULL, head_oid;
>   	struct tree_desc desc[2] = { { NULL }, { NULL } };
>   	struct lock_file lock = LOCK_INIT;
> @@ -112,6 +113,9 @@ int reset_head(struct repository *r, const struct reset_head_opts *opts)
>   	if (opts->branch_msg && !opts->branch)
>   		BUG("branch reflog message given without a branch");
>   
> +	if (skip_ref_updates && (opts->branch || refs_only))
> +		BUG("asked to perform ref updates and skip them at the same time");
> +
>   	if (!refs_only && !dry_run && repo_hold_locked_index(r, &lock, LOCK_REPORT_ON_ERROR) < 0) {
>   		ret = -1;
>   		goto leave_reset_head;
> @@ -196,7 +200,8 @@ int reset_head(struct repository *r, const struct reset_head_opts *opts)
>   		goto leave_reset_head;
>   	}
>   
> -	if (oid != &head_oid || update_orig_head || switch_to_branch)
> +	if (!skip_ref_updates &&
> +	    (oid != &head_oid || update_orig_head || switch_to_branch))
>   		ret = update_refs(r, opts, oid, head);
>   
>   leave_reset_head:
> diff --git a/reset.h b/reset.h
> index 9f696382c1..cb0700ffa7 100644
> --- a/reset.h
> +++ b/reset.h
> @@ -27,6 +27,9 @@ enum reset_head_flags {
>   	 * any user-visible state.
>   	 */
>   	RESET_HEAD_DRY_RUN = (1 << 5),
> +
> +	/* Skip updating any references, only update the worktree and index. */
> +	RESET_HEAD_SKIP_REF_UPDATES = (1 << 6),
>   };
>   
>   struct reset_head_opts {
> 


^ permalink raw reply

* Re: [PATCH v2 3/9] reset: modernize flags passed to `reset_head()`
From: Phillip Wood @ 2026-06-05 15:08 UTC (permalink / raw)
  To: Patrick Steinhardt, git; +Cc: Pablo Sabater, Junio C Hamano
In-Reply-To: <20260603-b4-pks-history-drop-v2-3-742cb5b5176d@pks.im>

Hi Patrick

On 03/06/2026 17:14, Patrick Steinhardt wrote:

> -/* Update ORIG_HEAD as well as HEAD */
> -#define RESET_ORIG_HEAD (1<<4)
 > [...]> +	/* Update ORIG_HEAD as well as HEAD */
> +	RESET_HEAD_ORIG_HEAD = (1 << 4),

I'm having a hard time parsing this new name, if we must have a 
"RESET_HEAD_" prefix can we call it something like 
RESET_HEAD_UPDATE_ORIG_HEAD?

Thanks

Phillip

> +};
>   
>   struct reset_head_opts {
>   	/*
> @@ -33,7 +39,7 @@ struct reset_head_opts {
>   	/*
>   	 * Flags defined above.
>   	 */
> -	unsigned flags;
> +	enum reset_head_flags flags;
>   	/*
>   	 * Optional reflog message for branch, defaults to head_msg.
>   	 */
> @@ -45,7 +51,7 @@ struct reset_head_opts {
>   	const char *head_msg;
>   	/*
>   	 * Optional reflog message for ORIG_HEAD, if this omitted and flags
> -	 * contains RESET_ORIG_HEAD then default_reflog_action must be given.
> +	 * contains RESET_HEAD_ORIG_HEAD then default_reflog_action must be given.
>   	 */
>   	const char *orig_head_msg;
>   	/*
> diff --git a/sequencer.c b/sequencer.c
> index 1ee4b2875b..0b89a977b0 100644
> --- a/sequencer.c
> +++ b/sequencer.c
> @@ -4870,7 +4870,7 @@ static int checkout_onto(struct repository *r, struct replay_opts *opts,
>   	struct reset_head_opts ropts = {
>   		.oid = onto,
>   		.orig_head = orig_head,
> -		.flags = RESET_HEAD_DETACH | RESET_ORIG_HEAD |
> +		.flags = RESET_HEAD_DETACH | RESET_HEAD_ORIG_HEAD |
>   				RESET_HEAD_RUN_POST_CHECKOUT_HOOK,
>   		.head_msg = reflog_message(opts, "start", "checkout %s",
>   					   onto_name),
> 


^ permalink raw reply

* Re: [PATCH v12 4/6] branch: add --prune-merged <branch>
From: Phillip Wood @ 2026-06-05 15:04 UTC (permalink / raw)
  To: Harald Nordgren via GitGitGadget, git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Harald Nordgren
In-Reply-To: <629b38ab-42d8-4763-919f-005b6ada68a0@gmail.com>

On 05/06/2026 14:50, Phillip Wood wrote:
> 
> I wonder about the name - the other options that delete branches are 
> called "delete", not "prune". Also "--prune-merged" does not delete the 
> branches listed by "--merged" so maybe "--delete-forked" would be better?

"delete-forked" doesn't capture the fact the branch has been merged 
though - I wonder if anyone has a better idea

Thanks

Phillip
> I've not commented in detail on the code as it will need to change a bit 
> once we match on full refnames and do the filtering in 
> apply_ref_filter() but I think the basics are sound.
> 
> I'll stop here - I did quickly scan the next two patches and they both 
> looked like sensible ideas.
> 
> Thanks
> 
> Phillip
> 
>> deletes the local branches that "--forked <branch>" would list,
>> restricted to those whose tip is reachable from their configured
>> upstream: the work has already landed on the upstream they track,
>> so the local copy is no longer needed.
>>
>> Reachability is read from local refs; nothing is fetched. Users
>> who want fresh upstream refs run "git fetch" first.
>>
>> Three classes of branches are spared:
>>
>>    * any branch checked out in any worktree;
>>    * any branch whose upstream no longer resolves locally (its
>>      disappearance is not, on its own, evidence of integration);
>>    * any branch whose push destination equals its upstream
>>      (<branch>@{push} == <branch>@{upstream}). Such a branch
>>      cannot be distinguished from a freshly pulled trunk that
>>      just looks "fully merged", e.g. local "main" tracking and
>>      pushing to "origin/main" right after a pull. Only branches
>>      that push somewhere other than their upstream (typically
>>      topics in a fork-based workflow) are treated as candidates.
>>
>> Deletion goes through the existing delete_branches() in warn-only
>> mode and with the HEAD-fallback disabled: a branch that is not
>> yet fully merged to its upstream is reported as a one-line warning
>> and skipped, so a single un-mergeable topic does not abort the
>> whole sweep. We only act on upstream-merged status.
>>
>> Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
>> ---
>>   Documentation/git-branch.adoc |  23 +++++
>>   builtin/branch.c              | 117 +++++++++++++++++++--
>>   t/t3200-branch.sh             | 188 ++++++++++++++++++++++++++++++++++
>>   3 files changed, 318 insertions(+), 10 deletions(-)
>>
>> diff --git a/Documentation/git-branch.adoc b/Documentation/git- 
>> branch.adoc
>> index 8002d7f38c..f7942fcd7d 100644
>> --- a/Documentation/git-branch.adoc
>> +++ b/Documentation/git-branch.adoc
>> @@ -25,6 +25,7 @@ git branch (-m|-M) [<old-branch>] <new-branch>
>>   git branch (-c|-C) [<old-branch>] <new-branch>
>>   git branch (-d|-D) [-r] <branch-name>...
>>   git branch --edit-description [<branch-name>]
>> +git branch (--prune-merged <branch>)...
>>   DESCRIPTION
>>   -----------
>> @@ -206,6 +207,28 @@ This option is only applicable in non-verbose mode.
>>       `master`) or a shell-style glob (e.g. `'origin/*'`). The
>>       option can be repeated to widen the filter.
>> +`--prune-merged <branch>`::
>> +    Delete the local branches that `--forked` would list for the
>> +    same _<branch>_, but only those whose tip is reachable from
>> +    their configured upstream. In other words, the work on the
>> +    branch has already landed on the upstream it tracks, so the
>> +    local copy is no longer needed. May be given more than once to
>> +    union the matches; positional arguments are not accepted.
>> ++
>> +Reachability is checked against whatever the upstream refs say
>> +locally; nothing is fetched. Run `git fetch` first if you want
>> +the upstream refs refreshed.
>> ++
>> +A branch is left alone if any of the following holds:
>> +its upstream no longer resolves locally; it is checked out in any
>> +worktree; or its push destination (`<branch>@{push}`) equals its
>> +upstream (`<branch>@{upstream}`), so it cannot be distinguished
>> +from a freshly pulled trunk that just looks "fully merged".
>> ++
>> +Branches refused by the "fully merged" safety check are listed as
>> +warnings and skipped; pass them to `git branch -D` explicitly if
>> +you want them gone.
>> +
>>   `-v`::
>>   `-vv`::
>>   `--verbose`::
>> diff --git a/builtin/branch.c b/builtin/branch.c
>> index 09afdd9257..736480b002 100644
>> --- a/builtin/branch.c
>> +++ b/builtin/branch.c
>> @@ -39,6 +39,7 @@ static const char * const builtin_branch_usage[] = {
>>       N_("git branch [<options>] (-c | -C) [<old-branch>] <new-branch>"),
>>       N_("git branch [<options>] [-r | -a] [--points-at]"),
>>       N_("git branch [<options>] [-r | -a] [--format]"),
>> +    N_("git branch [<options>] (--prune-merged <branch>)..."),
>>       NULL
>>   };
>> @@ -782,17 +783,13 @@ static int upstream_matches(const char 
>> *short_upstream,
>>       return 0;
>>   }
>> -static int branch_upstream_matches(const char *full_refname,
>> +static int branch_upstream_matches(const char *short_branch_name,
>>                      const struct upstream_pattern *patterns,
>>                      size_t nr_patterns)
>>   {
>> -    const char *short_name;
>> -    struct branch *branch;
>> +    struct branch *branch = branch_get(short_branch_name);
>>       const char *upstream;
>> -    if (!skip_prefix(full_refname, "refs/heads/", &short_name))
>> -        return 0;
>> -    branch = branch_get(short_name);
>>       if (!branch)
>>           return 0;
>>       upstream = branch_get_upstream(branch, NULL);
>> @@ -813,8 +810,9 @@ static void filter_array_by_forked(struct 
>> ref_array *array,
>>       for (i = 0; i < array->nr; i++) {
>>           struct ref_array_item *item = array->items[i];
>> -        if (branch_upstream_matches(item->refname,
>> -                        patterns, nr_patterns))
>> +        const char *short_name;
>> +        if (skip_prefix(item->refname, "refs/heads/", &short_name) &&
>> +            branch_upstream_matches(short_name, patterns, nr_patterns))
>>               array->items[kept++] = item;
>>           else
>>               free_ref_array_item(item);
>> @@ -824,6 +822,94 @@ static void filter_array_by_forked(struct 
>> ref_array *array,
>>       upstream_pattern_list_clear(patterns, nr_patterns);
>>   }
>> +struct forked_cb {
>> +    const struct upstream_pattern *patterns;
>> +    size_t nr_patterns;
>> +    struct string_list *out;
>> +};
>> +
>> +static int collect_forked_branch(const struct reference *ref, void 
>> *cb_data)
>> +{
>> +    struct forked_cb *cb = cb_data;
>> +
>> +    if (ref->flags & REF_ISSYMREF)
>> +        return 0;
>> +    if (branch_upstream_matches(ref->name, cb->patterns, cb- 
>> >nr_patterns))
>> +        string_list_append(cb->out, ref->name);
>> +    return 0;
>> +}
>> +
>> +static void collect_forked_set(const struct string_list *upstreams,
>> +                   struct string_list *out)
>> +{
>> +    struct upstream_pattern *patterns = NULL;
>> +    size_t nr_patterns = 0;
>> +    struct forked_cb cb;
>> +
>> +    parse_forked_args(upstreams, &patterns, &nr_patterns);
>> +    cb.patterns = patterns;
>> +    cb.nr_patterns = nr_patterns;
>> +    cb.out = out;
>> +
>> +    refs_for_each_branch_ref(get_main_ref_store(the_repository),
>> +                 collect_forked_branch, &cb);
>> +
>> +    string_list_sort(out);
>> +
>> +    upstream_pattern_list_clear(patterns, nr_patterns);
>> +}
>> +
>> +static int prune_merged_branches(const struct string_list *upstreams,
>> +                 int quiet)
>> +{
>> +    struct ref_store *refs = get_main_ref_store(the_repository);
>> +    struct string_list candidates = STRING_LIST_INIT_DUP;
>> +    struct strvec deletable = STRVEC_INIT;
>> +    struct string_list_item *item;
>> +    int ret = 0;
>> +
>> +    if (!upstreams->nr)
>> +        die(_("--prune-merged requires at least one <branch>"));
>> +
>> +    collect_forked_set(upstreams, &candidates);
>> +
>> +    for_each_string_list_item(item, &candidates) {
>> +        const char *short_name = item->string;
>> +        struct branch *branch = branch_get(short_name);
>> +        const char *upstream, *push;
>> +        struct strbuf full = STRBUF_INIT;
>> +        int skip;
>> +
>> +        strbuf_addf(&full, "refs/heads/%s", short_name);
>> +        skip = !!branch_checked_out(full.buf);
>> +        strbuf_release(&full);
>> +        if (skip)
>> +            continue;
>> +
>> +        upstream = branch ? branch_get_upstream(branch, NULL) : NULL;
>> +        if (!upstream || !refs_ref_exists(refs, upstream))
>> +            continue;
>> +        push = branch ? branch_get_push(branch, NULL) : NULL;
>> +        if (!push || !strcmp(push, upstream))
>> +            continue;
>> +
>> +        strvec_push(&deletable, short_name);
>> +    }
>> +
>> +    if (deletable.nr)
>> +        ret = delete_branches(deletable.nr, deletable.v,
>> +                      0, /* force */
>> +                      FILTER_REFS_BRANCHES,
>> +                      quiet,
>> +                      1, /* warn_only */
>> +                      1, /* no_head_fallback */
>> +                      0  /* dry_run */);
>> +
>> +    strvec_clear(&deletable);
>> +    string_list_clear(&candidates, 0);
>> +    return ret;
>> +}
>> +
>>   static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
>>   static int edit_branch_description(const char *branch_name)
>> @@ -866,6 +952,7 @@ int cmd_branch(int argc,
>>       int delete = 0, rename = 0, copy = 0, list = 0,
>>           unset_upstream = 0, show_current = 0, edit_description = 0;
>>       struct string_list forked_upstreams = STRING_LIST_INIT_DUP;
>> +    struct string_list prune_merged_upstreams = STRING_LIST_INIT_DUP;
>>       const char *new_upstream = NULL;
>>       int noncreate_actions = 0;
>>       /* possible options */
>> @@ -921,6 +1008,8 @@ int cmd_branch(int argc,
>>                N_("edit the description for the branch")),
>>           OPT_STRING_LIST(0, "forked", &forked_upstreams, N_("branch"),
>>               N_("list local branches whose upstream matches <branch> 
>> (repeatable)")),
>> +        OPT_STRING_LIST(0, "prune-merged", &prune_merged_upstreams, 
>> N_("branch"),
>> +            N_("delete local branches whose upstream matches <branch> 
>> and is merged (repeatable)")),
>>           OPT__FORCE(&force, N_("force creation, move/rename, 
>> deletion"), PARSE_OPT_NOCOMPLETE),
>>           OPT_MERGED(&filter, N_("print only branches that are merged")),
>>           OPT_NO_MERGED(&filter, N_("print only branches that are not 
>> merged")),
>> @@ -965,7 +1054,8 @@ int cmd_branch(int argc,
>>                    0);
>>       if (!delete && !rename && !copy && !edit_description && ! 
>> new_upstream &&
>> -        !show_current && !unset_upstream && argc == 0)
>> +        !show_current && !unset_upstream && ! 
>> prune_merged_upstreams.nr &&
>> +        argc == 0)
>>           list = 1;
>>       if (filter.with_commit || filter.no_commit ||
>> @@ -975,7 +1065,7 @@ int cmd_branch(int argc,
>>       noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
>>                   !!show_current + !!list + !!edit_description +
>> -                !!unset_upstream;
>> +                !!unset_upstream + !!prune_merged_upstreams.nr;
>>       if (noncreate_actions > 1)
>>           usage_with_options(builtin_branch_usage, options);
>> @@ -1016,6 +1106,12 @@ int cmd_branch(int argc,
>>           ret = delete_branches(argc, argv, delete > 1, filter.kind,
>>                         quiet, 0, 0, 0);
>>           goto out;
>> +    } else if (prune_merged_upstreams.nr) {
>> +        if (argc)
>> +            die(_("--prune-merged does not take positional arguments; "
>> +                  "repeat --prune-merged for each <branch>"));
>> +        ret = prune_merged_branches(&prune_merged_upstreams, quiet);
>> +        goto out;
>>       } else if (show_current) {
>>           print_current_branch_name();
>>           ret = 0;
>> @@ -1178,5 +1274,6 @@ int cmd_branch(int argc,
>>   out:
>>       string_list_clear(&sorting_options, 0);
>>       string_list_clear(&forked_upstreams, 0);
>> +    string_list_clear(&prune_merged_upstreams, 0);
>>       return ret;
>>   }
>> diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
>> index 4e7deddc04..beb86987ad 100755
>> --- a/t/t3200-branch.sh
>> +++ b/t/t3200-branch.sh
>> @@ -1809,4 +1809,192 @@ test_expect_success '--forked requires a value' '
>>       test_grep "requires a value" err
>>   '
>> +test_expect_success '--prune-merged: setup' '
>> +    test_create_repo pm-upstream &&
>> +    test_commit -C pm-upstream base &&
>> +    git -C pm-upstream checkout -b next &&
>> +    test_commit -C pm-upstream one-commit &&
>> +    test_commit -C pm-upstream two-commit &&
>> +    git -C pm-upstream branch one HEAD~ &&
>> +    git -C pm-upstream branch two HEAD &&
>> +    git -C pm-upstream branch wip main &&
>> +    git -C pm-upstream checkout main &&
>> +    test_create_repo pm-fork
>> +'
>> +
>> +test_expect_success '--prune-merged deletes branches integrated into 
>> upstream' '
>> +    test_when_finished "rm -rf pm-merged" &&
>> +    git clone pm-upstream pm-merged &&
>> +    git -C pm-merged remote add fork ../pm-fork &&
>> +    test_config -C pm-merged remote.pushDefault fork &&
>> +    test_config -C pm-merged push.default current &&
>> +    git -C pm-merged branch one one-commit &&
>> +    git -C pm-merged branch --set-upstream-to=origin/next one &&
>> +    git -C pm-merged branch two two-commit &&
>> +    git -C pm-merged branch --set-upstream-to=origin/next two &&
>> +
>> +    git -C pm-merged branch --prune-merged "origin/*" &&
>> +
>> +    test_must_fail git -C pm-merged rev-parse --verify refs/heads/one &&
>> +    test_must_fail git -C pm-merged rev-parse --verify refs/heads/two
>> +'
>> +
>> +test_expect_success '--prune-merged accepts a literal upstream' '
>> +    test_when_finished "rm -rf pm-literal" &&
>> +    git clone pm-upstream pm-literal &&
>> +    git -C pm-literal remote add fork ../pm-fork &&
>> +    test_config -C pm-literal remote.pushDefault fork &&
>> +    test_config -C pm-literal push.default current &&
>> +    git -C pm-literal branch one one-commit &&
>> +    git -C pm-literal branch --set-upstream-to=origin/next one &&
>> +
>> +    git -C pm-literal branch --prune-merged origin/next &&
>> +
>> +    test_must_fail git -C pm-literal rev-parse --verify refs/heads/one
>> +'
>> +
>> +test_expect_success '--prune-merged unions multiple <branch> 
>> arguments' '
>> +    test_when_finished "rm -rf pm-union" &&
>> +    git clone pm-upstream pm-union &&
>> +    git -C pm-union remote add fork ../pm-fork &&
>> +    test_config -C pm-union remote.pushDefault fork &&
>> +    test_config -C pm-union push.default current &&
>> +    git -C pm-union branch one one-commit &&
>> +    git -C pm-union branch --set-upstream-to=origin/next one &&
>> +    git -C pm-union branch two base &&
>> +    git -C pm-union branch --set-upstream-to=origin/main two &&
>> +    git -C pm-union checkout --detach &&
>> +
>> +    git -C pm-union branch --prune-merged origin/next --prune-merged 
>> origin/main &&
>> +
>> +    test_must_fail git -C pm-union rev-parse --verify refs/heads/one &&
>> +    test_must_fail git -C pm-union rev-parse --verify refs/heads/two
>> +'
>> +
>> +test_expect_success '--prune-merged accepts a local upstream' '
>> +    test_when_finished "rm -rf pm-local" &&
>> +    git clone pm-upstream pm-local &&
>> +    git -C pm-local remote add fork ../pm-fork &&
>> +    test_config -C pm-local remote.pushDefault fork &&
>> +    test_config -C pm-local push.default current &&
>> +    git -C pm-local checkout -b trunk &&
>> +    git -C pm-local branch one one-commit &&
>> +    git -C pm-local branch --set-upstream-to=trunk one &&
>> +    git -C pm-local merge --ff-only one-commit &&
>> +
>> +    git -C pm-local branch --prune-merged trunk &&
>> +
>> +    test_must_fail git -C pm-local rev-parse --verify refs/heads/one
>> +'
>> +
>> +test_expect_success '--prune-merged warns instead of erroring on un- 
>> integrated commits' '
>> +    test_when_finished "rm -rf pm-unmerged" &&
>> +    git clone pm-upstream pm-unmerged &&
>> +    git -C pm-unmerged remote add fork ../pm-fork &&
>> +    test_config -C pm-unmerged remote.pushDefault fork &&
>> +    test_config -C pm-unmerged push.default current &&
>> +    git -C pm-unmerged checkout -b wip origin/wip &&
>> +    git -C pm-unmerged branch --set-upstream-to=origin/next wip &&
>> +    test_commit -C pm-unmerged local-only &&
>> +    git -C pm-unmerged checkout - &&
>> +
>> +    git -C pm-unmerged branch --prune-merged "origin/*" 2>err &&
>> +    test_grep "not fully merged" err &&
>> +    test_grep ! "If you are sure you want to delete it" err &&
>> +    git -C pm-unmerged rev-parse --verify refs/heads/wip
>> +'
>> +
>> +test_expect_success '--prune-merged is silent about not-merged-to- 
>> HEAD' '
>> +    test_when_finished "rm -rf pm-nohead" &&
>> +    git clone pm-upstream pm-nohead &&
>> +    git -C pm-nohead remote add fork ../pm-fork &&
>> +    test_config -C pm-nohead remote.pushDefault fork &&
>> +    test_config -C pm-nohead push.default current &&
>> +    git -C pm-nohead branch topic one-commit &&
>> +    git -C pm-nohead branch --set-upstream-to=origin/next topic &&
>> +
>> +    git -C pm-nohead branch --prune-merged "origin/*" 2>err &&
>> +
>> +    test_grep ! "not yet merged to HEAD" err &&
>> +    test_must_fail git -C pm-nohead rev-parse --verify refs/heads/topic
>> +'
>> +
>> +test_expect_success '--prune-merged skips branches whose upstream is 
>> gone' '
>> +    test_when_finished "rm -rf pm-upstream-gone" &&
>> +    git clone pm-upstream pm-upstream-gone &&
>> +    git -C pm-upstream-gone remote add fork ../pm-fork &&
>> +    test_config -C pm-upstream-gone remote.pushDefault fork &&
>> +    test_config -C pm-upstream-gone push.default current &&
>> +    git -C pm-upstream-gone branch one one-commit &&
>> +    git -C pm-upstream-gone branch --set-upstream-to=origin/next one &&
>> +
>> +    git -C pm-upstream-gone update-ref -d refs/remotes/origin/next &&
>> +    git -C pm-upstream-gone branch --prune-merged "origin/*" &&
>> +
>> +    git -C pm-upstream-gone rev-parse --verify refs/heads/one
>> +'
>> +
>> +test_expect_success '--prune-merged never deletes the checked-out 
>> branch' '
>> +    test_when_finished "rm -rf pm-head" &&
>> +    git clone pm-upstream pm-head &&
>> +    git -C pm-head remote add fork ../pm-fork &&
>> +    test_config -C pm-head remote.pushDefault fork &&
>> +    test_config -C pm-head push.default current &&
>> +    git -C pm-head checkout -b one one-commit &&
>> +    git -C pm-head branch --set-upstream-to=origin/next one &&
>> +
>> +    git -C pm-head branch --prune-merged "origin/*" &&
>> +
>> +    git -C pm-head rev-parse --verify refs/heads/one
>> +'
>> +
>> +test_expect_success '--prune-merged spares branches that push back to 
>> their upstream' '
>> +    test_when_finished "rm -rf pm-push-eq" &&
>> +    git clone pm-upstream pm-push-eq &&
>> +    git -C pm-push-eq checkout --detach &&
>> +
>> +    git -C pm-push-eq branch --prune-merged "origin/*" &&
>> +
>> +    git -C pm-push-eq rev-parse --verify refs/heads/main
>> +'
>> +
>> +test_expect_success '--prune-merged spares a per-branch 
>> pushRemote==upstream remote' '
>> +    test_when_finished "rm -rf pm-push-branch" &&
>> +    git clone pm-upstream pm-push-branch &&
>> +    git -C pm-push-branch remote add fork ../pm-fork &&
>> +    test_config -C pm-push-branch remote.pushDefault fork &&
>> +    test_config -C pm-push-branch push.default current &&
>> +    test_config -C pm-push-branch branch.main.pushRemote origin &&
>> +    git -C pm-push-branch checkout --detach &&
>> +
>> +    git -C pm-push-branch branch --prune-merged "origin/*" &&
>> +
>> +    git -C pm-push-branch rev-parse --verify refs/heads/main
>> +'
>> +
>> +test_expect_success '--prune-merged prunes when @{push} differs from 
>> @{upstream}' '
>> +    test_when_finished "rm -rf pm-push-diff" &&
>> +    git clone pm-upstream pm-push-diff &&
>> +    git -C pm-push-diff remote add fork ../pm-fork &&
>> +    test_config -C pm-push-diff remote.pushDefault fork &&
>> +    test_config -C pm-push-diff push.default current &&
>> +    git -C pm-push-diff branch topic one-commit &&
>> +    git -C pm-push-diff branch --set-upstream-to=origin/next topic &&
>> +    git -C pm-push-diff checkout --detach &&
>> +
>> +    git -C pm-push-diff branch --prune-merged "origin/*" &&
>> +
>> +    test_must_fail git -C pm-push-diff rev-parse --verify refs/heads/ 
>> topic
>> +'
>> +
>> +test_expect_success '--prune-merged requires a value' '
>> +    test_must_fail git -C forked branch --prune-merged 2>err &&
>> +    test_grep "requires a value" err
>> +'
>> +
>> +test_expect_success '--prune-merged rejects positional arguments' '
>> +    test_must_fail git -C forked branch --prune-merged origin/one 
>> other/foreign 2>err &&
>> +    test_grep "does not take positional arguments" err
>> +'
>> +
>>   test_done
> 


^ permalink raw reply

* Re: [PATCH 01/16] packfile: rename `struct packfile_store` to `odb_source_packed`
From: Karthik Nayak @ 2026-06-05 14:25 UTC (permalink / raw)
  To: Patrick Steinhardt, git
In-Reply-To: <20260604-pks-odb-source-packed-v1-1-2e7ab31b4b5c@pks.im>

[-- Attachment #1: Type: text/plain, Size: 1159 bytes --]

Patrick Steinhardt <ps@pks.im> writes:

> Not too long ago, we have introduced the packfile store in b7983adb51
> (packfile: introduce a new `struct packfile_store`, 2025-09-23). This
> struct is responsible for managing all of our access to packfiles and is
> used as one of the two sources of objects for the "files" source.
>
> Back when I introduced this structure I didn't have the clear vision yet
> that it will eventually also turn into a proper object database source,
> and how exactly that infrastructure will look like. Now though it's
> becoming increasingly clear that it does make sense to treat it just the
> same as any of our other ODB sources.
>
> The consequence is that the naming is now a bit out-of-date: it's just
> another source and will be turned into a proper `struct odb_source` over
> the next couple of commits, but it's not named accordingly.
>
> Rename the structure to `odb_source_packed` to align it with this goal
> and to bring it in line with the other sources we already have.
>

Looks good, I'm assuming we'll also rename drop some of the
`packfile_store_*` functions as things get cleaned up in the following
commits.

[-- Attachment #2: signature.asc --]
[-- Type: application/pgp-signature, Size: 690 bytes --]

^ permalink raw reply

* Re: [PATCH v3 0/8] setup: centralize object database creation
From: Karthik Nayak @ 2026-06-05 14:16 UTC (permalink / raw)
  To: Patrick Steinhardt, git; +Cc: Kristoffer Haugsbakk, Junio C Hamano
In-Reply-To: <20260604-b4-pks-setup-centralize-odb-creation-v3-0-0691834f318a@pks.im>

[-- Attachment #1: Type: text/plain, Size: 4824 bytes --]

Patrick Steinhardt <ps@pks.im> writes:

> Hi,
>
> this small patch series refactors the logic for how we discover and
> configure repositories. Most importantly, this involves the following
> two steps:
>
>   1. We unify the logic to apply the repository format, which is
>      currently open-coded across multiple sites. These sites have
>      already diverged, where some repository extensions are not
>      consistently applied.
>
>   2. We then centralize creation of the object database to happen at the
>      same time we apply the repository format.
>
> The end result is that we apply the repository format exactly once, and
> that's also the point in time where we can finalize the setup of the
> repo's data structures as we know about all details of the repo at that
> time. Ultimately, this makes it trivial to introduce the "objectStorage"
> extension, even though that's not part of this patch series.
>
> The series is built on top of aec3f58750 (Sync with 'maint', 2026-05-21)
> with ps/setup-wo-the-repository at df69f40c34 (setup: stop using
> `the_repository` in `init_db()`, 2026-05-19) merged into it.
>
> Changes in v3:
>   - Explain the move of `verify_repository_format()` better.
>   - Document that `apply_repository_format()` also verifies the format.
>   - Link to v2: https://patch.msgid.link/20260526-b4-pks-setup-centralize-odb-creation-v2-0-2fa5b385c13e@pks.im
>
> Changes in v2:
>   - Commit message improvements.
>   - Link to v1: https://patch.msgid.link/20260521-b4-pks-setup-centralize-odb-creation-v1-0-f130d2a7e8ae@pks.im
>
> Thanks!
>
> Patrick
>
> ---
> Patrick Steinhardt (8):
>       t0001: plug test gaps for git-init(1) with GIT_OBJECT_DIRECTORY
>       setup: drop `setup_git_env()`
>       setup: deduplicate logic to apply repository format
>       repository: stop initializing the object database in `repo_set_gitdir()`
>       setup: stop creating the object database in `setup_git_env()`
>       setup: stop initializing object database without repository
>       repository: stop reading loose object map twice on repo init
>       setup: construct object database in `apply_repository_format()`
>
>  commit-graph.c  |   4 +-
>  environment.h   |   8 +---
>  refs.c          |   3 +-
>  repository.c    |  40 +++++------------
>  repository.h    |   3 --
>  setup.c         | 130 +++++++++++++++++++++++++++++++-------------------------
>  setup.h         |  20 +++++++++
>  t/t0001-init.sh |  10 +++++
>  8 files changed, 118 insertions(+), 100 deletions(-)
>
> Range-diff versus v2:
>
> 1:  50224c1a12 = 1:  a6f452b947 t0001: plug test gaps for git-init(1) with GIT_OBJECT_DIRECTORY
> 2:  6d655e00e3 = 2:  905e618dc6 setup: drop `setup_git_env()`
> 3:  2e7e9bb052 ! 3:  e11f16333d setup: deduplicate logic to apply repository format
>     @@ Commit message
>
>          Introduce a new function `apply_repository_format()` that takes a repo
>          and applies a given format to it and adapt all callsites to use it.
>     -    While at it, rename `check_repository_format()` to clarify that it
>     -    doesn't only _check_ the format, but that it also applies it.
>     +    This function is also the new caller of `verify_repository_format()` so
>     +    that we can ensure that we never apply an invalid repository format.
>     +    The verification we have in `read_and_verify_repository_format()` is
>     +    thus redundant now and dropped.
>     +
>     +    Rename `read_and_verify_repository_format()` accordingly. While at it,
>     +    also rename `check_repository_format()` to clarify that it doesn't only
>     +    _check_ the format, but that it also applies it.
>
>          Signed-off-by: Patrick Steinhardt <ps@pks.im>
>
>     @@ setup.h: void clear_repository_format(struct repository_format *format);
>      +/*
>      + * Apply the given repository format to the repo. This initializes extensions
>      + * and basic data structures required for normal operation. Returns 0 on
>     -+ * success, a negative error code otherwise.
>     ++ * success, a negative error code when the format is not valid as determined by
>     ++ * `verify_repository_format()`.
>      + */
>      +int apply_repository_format(struct repository *repo,
>      +			    const struct repository_format *format,
> 4:  81b92bca7f = 4:  b0d7c11fe6 repository: stop initializing the object database in `repo_set_gitdir()`
> 5:  807fc56353 = 5:  d0af56fdae setup: stop creating the object database in `setup_git_env()`
> 6:  96563ff99f = 6:  3e75c5b0a6 setup: stop initializing object database without repository
> 7:  c14f45169c = 7:  50fa2fdb3c repository: stop reading loose object map twice on repo init
> 8:  e67c6e66d6 = 8:  4dff9d1794 setup: construct object database in `apply_repository_format()`
>

The range-diff looks good and as expected. Thanks!

[-- Attachment #2: signature.asc --]
[-- Type: application/pgp-signature, Size: 690 bytes --]

^ permalink raw reply

* [PATCH v3 4/4] doc: replay: move “default” to the right-hand side
From: kristofferhaugsbakk @ 2026-06-05 13:56 UTC (permalink / raw)
  To: Junio C Hamano
  Cc: Kristoffer Haugsbakk, Siddharth Asthana, git, Patrick Steinhardt
In-Reply-To: <V3_CV_doc_replay_config.780@msgid.xyz>

From: Kristoffer Haugsbakk <code@khaugsbakk.name>

This is now a description list (see previous commit) and parentheticals
like this do not go on the left-hand side. Moving it to the other side
makes it stand out just as much and is also more consistent with the
rest of the documentation.

Let’s also do the same for the `replay.refAction` description list.
That makes the two desc. lists identical in the first sentence. Let’s
add a comment about that for future editors.

Signed-off-by: Kristoffer Haugsbakk <code@khaugsbakk.name>
---

Notes (series):
    v2:
    • It’s “description list”, not “definition list”
      • (Same mistake I have done for “line continuation” (it’s “list”))
    • It’s e.g. “right-hand side” (drop “-side” hyphen)
    • Change `replay.refAction` “default” placement
    • Now that these two description lists are so similar, add an
      AsciiDoc comment about it for future editors. Note that I
      outright deleted this list in the previous version because I
      didn’t want to keep them in synch. But we can remain aware of
      these with two comments.
    
    ---
    
    v1:
    > do not go on the left-hand-side.
    
    At least I haven’t seen it.

 Documentation/config/replay.adoc | 5 ++++-
 Documentation/git-replay.adoc    | 5 ++++-
 2 files changed, 8 insertions(+), 2 deletions(-)

diff --git a/Documentation/config/replay.adoc b/Documentation/config/replay.adoc
index 7328da9537d..40d1695782a 100644
--- a/Documentation/config/replay.adoc
+++ b/Documentation/config/replay.adoc
@@ -3,7 +3,10 @@ replay.refAction::
 	The value can be:
 +
 --
-`update`;; Update refs directly using an atomic transaction (default behavior).
+////
+These use the first sentences from the description list in git-replay(1).
+////
+`update`;; (default) Update refs directly using an atomic transaction.
 `print`;; Output update-ref commands for pipeline use.
 --
 +
diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc
index b4fe43ec687..ea4d14baddb 100644
--- a/Documentation/git-replay.adoc
+++ b/Documentation/git-replay.adoc
@@ -80,7 +80,10 @@ incompatible with `--contained` (which is a modifier for `--onto` only).
 	Control how references are updated. The mode can be:
 +
 --
-`update` (default);; Update refs directly using an atomic transaction.
+////
+Expanded description list compared to 'replay.refAction'.
+////
+`update`;; (default) Update refs directly using an atomic transaction.
 	All refs are updated or none are (all-or-nothing behavior).
 `print`;; Output update-ref commands for pipeline use. This is the
 	traditional behavior where output can be piped to `git update-ref --stdin`.
-- 
2.54.0.22.g9e26862b904


^ permalink raw reply related

* [PATCH v3 3/4] doc: replay: use a nested description list
From: kristofferhaugsbakk @ 2026-06-05 13:56 UTC (permalink / raw)
  To: Junio C Hamano
  Cc: Kristoffer Haugsbakk, Siddharth Asthana, git, Patrick Steinhardt
In-Reply-To: <V3_CV_doc_replay_config.780@msgid.xyz>

From: Kristoffer Haugsbakk <code@khaugsbakk.name>

This bullet list for `--ref-action` introduces a term with a colon.
This is exactly what a description list is, structurally. Let’s be
stylistically consistent and use the desc. list markup construct.

In short, just transform this unordered list in the same way that we
did for `replay.refAction` in the previous commit.

Signed-off-by: Kristoffer Haugsbakk <code@khaugsbakk.name>
---

Notes (series):
    v3:
    • Msg:[1] Fix typo: “stylistically”
    • Msg: Simplify message. Devote one paragraph to   † 1: Commit
      explain the transformation. Then delegate to the         message
      previous patch since we did the same trans-
      formation there.
    
    ---
    
    v2:
    • Msg: Mention that the explanation for the description list is the
      same as in the previous commit
    • Msg: It’s “description list”, not “definition list”

 Documentation/git-replay.adoc | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/Documentation/git-replay.adoc b/Documentation/git-replay.adoc
index 4de85088d6c..b4fe43ec687 100644
--- a/Documentation/git-replay.adoc
+++ b/Documentation/git-replay.adoc
@@ -80,10 +80,10 @@ incompatible with `--contained` (which is a modifier for `--onto` only).
 	Control how references are updated. The mode can be:
 +
 --
-	* `update` (default): Update refs directly using an atomic transaction.
-	  All refs are updated or none are (all-or-nothing behavior).
-	* `print`: Output update-ref commands for pipeline use. This is the
-	  traditional behavior where output can be piped to `git update-ref --stdin`.
+`update` (default);; Update refs directly using an atomic transaction.
+	All refs are updated or none are (all-or-nothing behavior).
+`print`;; Output update-ref commands for pipeline use. This is the
+	traditional behavior where output can be piped to `git update-ref --stdin`.
 --
 +
 The default mode can be configured via the `replay.refAction` configuration variable.
-- 
2.54.0.22.g9e26862b904


^ permalink raw reply related


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