Git development
 help / color / mirror / Atom feed
* Re: [PATCH RFC 1/2] builtin/history: abort reword on unchanged message
From: Pablo Sabater @ 2026-06-09 10:14 UTC (permalink / raw)
  To: Junio C Hamano; +Cc: git, Patrick Steinhardt, Kaartic Sivaraam
In-Reply-To: <xmqqmrx5z0po.fsf@gitster.g>

El lun, 8 jun 2026 a las 14:16, Junio C Hamano (<gitster@pobox.com>) escribió:
>
[snip]
>
> `git rebase -i` may have an excuse that because it, unlike "git
> commit --amend", operates on multiple commits by design.  A single
> "--force" option given to the command would not have worked as an
> escape hatch to allow the user to tell the command "in this reword
> of this particular commit, I ended up doing nothing, but I still
> want an updated committer log timestamp".  Perhaps giving the
> "--force" (or --force-rewrite") option at "rebase --continue" time
> may work, but in any case, unless we plan to transition to these
> "better" default behaviour at a big version boundary, speculating
> what a "better" behaviour would have been may be fun but not very
> productive.
>
>
> [Footnote]
>
>  *1* Besides, doesn't "--update-refs" in "rebase -i" allow you to
>      adjust the branches?
>
>  *2* But it is an established behaviour people _rely_ on, so even
>      though it may have been better if these commands behaved
>      differently, it probably is a bit too late to change it now.
>
>  *3* This includes the case where the original author is especially
>      difficult to work with and would complain any change to their
>      commits, even if the only change you made for them is a
>      typofix.  Fixing a small typo/grammo may not be worth your time
>      and unpleasant exchanges with them after touching their commit.

True, after reading it, history being more costly or the in memory are
not good args.
I do agree that these commands that do reword should check if the
reword ends up being the same message, given that history is a new
command we can have it from the start so users do not really expect
other behavior.
About the --force sounds good to me. I could seek to implement it in
this series if it's ok.
The footnote 3 is indeed a good example haha, but yeah, why rewrite
the history unnecessarily.

Thanks,
Pablo

^ permalink raw reply

* [PATCH v14 6/6] branch: add --dry-run for --prune-merged
From: Harald Nordgren via GitGitGadget @ 2026-06-09 10:11 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v14.git.git.1780999917.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

With --dry-run, --prune-merged prints the local branches it would
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.

--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 |  8 ++++++-
 builtin/branch.c              | 13 ++++++++---
 t/t3200-branch.sh             | 44 +++++++++++++++++++++++++++++++++++
 3 files changed, 61 insertions(+), 4 deletions(-)

diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 5c43dc55a8..1f49a831fd 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -25,7 +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>...
+git branch [--dry-run] --prune-merged <branch>...
 
 DESCRIPTION
 -----------
@@ -226,6 +226,12 @@ 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.
 
+`--dry-run`::
+	With `--prune-merged`, print which branches would be
+	deleted and exit without touching any ref.  Useful for
+	sanity-checking a wide pattern like `'origin/*'` before
+	committing to the deletion.
+
 `-v`::
 `-vv`::
 `--verbose`::
diff --git a/builtin/branch.c b/builtin/branch.c
index 52a0371292..7c52a88af2 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -717,7 +717,7 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset
 }
 
 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 ref_filter filter = REF_FILTER_INIT;
@@ -777,7 +777,8 @@ 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);
 	ref_array_clear(&candidates);
@@ -827,6 +828,7 @@ int cmd_branch(int argc,
 	int delete = 0, rename = 0, copy = 0, list = 0,
 	    unset_upstream = 0, show_current = 0, edit_description = 0;
 	int prune_merged = 0;
+	int dry_run = 0;
 	const char *new_upstream = NULL;
 	int noncreate_actions = 0;
 	/* possible options */
@@ -882,6 +884,8 @@ int cmd_branch(int argc,
 			 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),
 		OPT_MERGED(&filter, N_("print only branches that are merged")),
 		OPT_NO_MERGED(&filter, N_("print only branches that are not merged")),
@@ -944,6 +948,9 @@ int cmd_branch(int argc,
 	if (noncreate_actions > 1)
 		usage_with_options(builtin_branch_usage, options);
 
+	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"));
@@ -983,7 +990,7 @@ int cmd_branch(int argc,
 				      (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();
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 3f7b1fc3d6..305c0141fc 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -2040,4 +2040,48 @@ test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
 	test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
 '
 
+test_expect_success '--prune-merged --dry-run lists but does not delete' '
+	test_when_finished "rm -rf pm-dry" &&
+	git clone pm-upstream pm-dry &&
+	git -C pm-dry remote add fork ../pm-fork &&
+	test_config -C pm-dry remote.pushDefault fork &&
+	test_config -C pm-dry push.default current &&
+	git -C pm-dry branch one one-commit &&
+	git -C pm-dry branch --set-upstream-to=origin/next one &&
+	git -C pm-dry branch two two-commit &&
+	git -C pm-dry branch --set-upstream-to=origin/next two &&
+
+	git -C pm-dry branch --dry-run --prune-merged "origin/*" >actual &&
+	test_grep "Would delete branch one " actual &&
+	test_grep "Would delete branch two " actual &&
+
+	git -C pm-dry rev-parse --verify refs/heads/one &&
+	git -C pm-dry rev-parse --verify refs/heads/two
+'
+
+test_expect_success '--prune-merged --dry-run only lists branches the live run would delete' '
+	test_when_finished "rm -rf pm-dry-mixed" &&
+	git clone pm-upstream pm-dry-mixed &&
+	git -C pm-dry-mixed remote add fork ../pm-fork &&
+	test_config -C pm-dry-mixed remote.pushDefault fork &&
+	test_config -C pm-dry-mixed push.default current &&
+	git -C pm-dry-mixed checkout -b wip origin/next &&
+	git -C pm-dry-mixed branch --set-upstream-to=origin/next wip &&
+	test_commit -C pm-dry-mixed local-only &&
+	git -C pm-dry-mixed checkout - &&
+	git -C pm-dry-mixed branch merged one-commit &&
+	git -C pm-dry-mixed branch --set-upstream-to=origin/next merged &&
+
+	git -C pm-dry-mixed branch --dry-run --prune-merged "origin/*" >out &&
+	test_grep "Would delete branch merged" out &&
+	test_grep ! "Would delete branch wip" out &&
+	git -C pm-dry-mixed rev-parse --verify refs/heads/wip &&
+	git -C pm-dry-mixed rev-parse --verify refs/heads/merged
+'
+
+test_expect_success '--dry-run without --prune-merged is rejected' '
+	test_must_fail git -C forked branch --dry-run 2>err &&
+	test_grep "requires --prune-merged" err
+'
+
 test_done
-- 
gitgitgadget

^ permalink raw reply related

* [PATCH v14 5/6] branch: add branch.<name>.pruneMerged opt-out
From: Harald Nordgren via GitGitGadget @ 2026-06-09 10:11 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v14.git.git.1780999917.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

Setting branch.<name>.pruneMerged=false exempts that branch from
"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.

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/config/branch.adoc |  7 +++++++
 Documentation/git-branch.adoc    |  5 +++--
 builtin/branch.c                 | 14 ++++++++++++++
 t/t3200-branch.sh                | 30 ++++++++++++++++++++++++++++++
 4 files changed, 54 insertions(+), 2 deletions(-)

diff --git a/Documentation/config/branch.adoc b/Documentation/config/branch.adoc
index a4db9fa5c8..6c1b5bb9cd 100644
--- a/Documentation/config/branch.adoc
+++ b/Documentation/config/branch.adoc
@@ -102,3 +102,10 @@ for details).
 	`git branch --edit-description`. Branch description is
 	automatically added to the `format-patch` cover letter or
 	`request-pull` summary.
+
+`branch.<name>.pruneMerged`::
+	If set to `false`, branch _<name>_ is exempt from
+	`git branch --prune-merged`.  Useful for a topic branch you
+	intend to develop further after an initial round has been
+	merged upstream.  Defaults to true.  Explicit deletion via
+	`git branch -d` is unaffected.
diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index fdaccc9662..5c43dc55a8 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -217,9 +217,10 @@ 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
+worktree; 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".
+from a freshly pulled trunk that just looks "fully merged"; or
+`branch.<name>.pruneMerged` is set to `false`.
 +
 Branches refused by the "fully merged" safety check are listed as
 warnings and skipped; pass them to `git branch -D` explicitly if
diff --git a/builtin/branch.c b/builtin/branch.c
index af37a0ceb7..52a0371292 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -741,6 +741,8 @@ static int prune_merged_branches(int argc, const char **argv,
 		const char *short_name;
 		struct branch *branch;
 		const char *upstream, *push;
+		struct strbuf key = STRBUF_INIT;
+		int opt_out;
 
 		if (!skip_prefix(full_name, "refs/heads/", &short_name))
 			continue;
@@ -755,6 +757,18 @@ static int prune_merged_branches(int argc, const char **argv,
 		if (!push || !strcmp(push, upstream))
 			continue;
 
+		strbuf_addf(&key, "branch.%s.prunemerged", short_name);
+		if (!repo_config_get_bool(the_repository, key.buf, &opt_out) &&
+		    !opt_out) {
+			if (!quiet)
+				fprintf(stderr,
+					_("Skipping '%s' (branch.%s.pruneMerged is false)\n"),
+					short_name, short_name);
+			strbuf_release(&key);
+			continue;
+		}
+		strbuf_release(&key);
+
 		strvec_push(&deletable, short_name);
 	}
 
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 27ea1319bb..3f7b1fc3d6 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -2010,4 +2010,34 @@ 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' '
+	test_when_finished "rm -rf pm-optout" &&
+	git clone pm-upstream pm-optout &&
+	git -C pm-optout remote add fork ../pm-fork &&
+	test_config -C pm-optout remote.pushDefault fork &&
+	test_config -C pm-optout push.default current &&
+	git -C pm-optout branch one one-commit &&
+	git -C pm-optout branch --set-upstream-to=origin/next one &&
+	git -C pm-optout branch two two-commit &&
+	git -C pm-optout branch --set-upstream-to=origin/next two &&
+	test_config -C pm-optout branch.one.pruneMerged false &&
+
+	git -C pm-optout branch --prune-merged "origin/*" 2>err &&
+
+	git -C pm-optout rev-parse --verify refs/heads/one &&
+	test_must_fail git -C pm-optout rev-parse --verify refs/heads/two &&
+	test_grep "Skipping .one." err
+'
+
+test_expect_success 'branch -d still deletes a pruneMerged=false branch' '
+	test_when_finished "rm -rf pm-optout-d" &&
+	git clone pm-upstream pm-optout-d &&
+	git -C pm-optout-d branch one one-commit &&
+	git -C pm-optout-d branch --set-upstream-to=origin/next one &&
+	test_config -C pm-optout-d branch.one.pruneMerged false &&
+
+	git -C pm-optout-d branch -d one &&
+	test_must_fail git -C pm-optout-d rev-parse --verify refs/heads/one
+'
+
 test_done
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v14 4/6] branch: add --prune-merged <branch>
From: Harald Nordgren via GitGitGadget @ 2026-06-09 10:11 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v14.git.git.1780999917.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

	git branch --prune-merged <branch>...

deletes the local branches that "--forked <branch>" would list,
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. Run
"git fetch" first if you want fresh upstream refs.

Three kinds of branches are spared:

  * any branch checked out in any worktree;
  * 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} 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.

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 |  24 ++++
 builtin/branch.c              |  67 +++++++++++-
 t/t3200-branch.sh             | 201 ++++++++++++++++++++++++++++++++++
 3 files changed, 290 insertions(+), 2 deletions(-)

diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index 62ebab6051..fdaccc9662 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
 -----------
@@ -201,6 +202,29 @@ This option is only applicable in non-verbose mode.
 	Print the name of the current branch. In detached `HEAD` state,
 	nothing is printed.
 
+`--prune-merged <branch>...`::
+	Delete the local branches that `--forked` would list for the
+	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
+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 2cc5a8cde0..af37a0ceb7 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -38,6 +38,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
 };
 
@@ -715,6 +716,61 @@ static int parse_opt_forked(const struct option *opt, const char *arg, int unset
 	return 0;
 }
 
+static int prune_merged_branches(int argc, const char **argv,
+				 int quiet)
+{
+	struct ref_store *refs = get_main_ref_store(the_repository);
+	struct ref_filter filter = REF_FILTER_INIT;
+	struct ref_array candidates;
+	struct strvec deletable = STRVEC_INIT;
+	int i, ret = 0;
+
+	if (!argc)
+		die(_("--prune-merged requires at least one <branch>"));
+
+	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]);
+
+	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;
+
+		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;
+		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,
+				      FILTER_REFS_BRANCHES,
+				      DELETE_BRANCH_WARN_ONLY |
+				      DELETE_BRANCH_NO_HEAD_FALLBACK |
+				      (quiet ? DELETE_BRANCH_QUIET : 0));
+
+	strvec_clear(&deletable);
+	ref_array_clear(&candidates);
+	ref_filter_clear(&filter);
+	return ret;
+}
+
 static GIT_PATH_FUNC(edit_description, "EDIT_DESCRIPTION")
 
 static int edit_branch_description(const char *branch_name)
@@ -756,6 +812,7 @@ 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;
+	int prune_merged = 0;
 	const char *new_upstream = NULL;
 	int noncreate_actions = 0;
 	/* possible options */
@@ -809,6 +866,8 @@ 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_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")),
@@ -856,7 +915,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 &&
+	    argc == 0)
 		list = 1;
 
 	if (filter.with_commit || filter.no_commit ||
@@ -866,7 +926,7 @@ int cmd_branch(int argc,
 
 	noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
 			    !!show_current + !!list + !!edit_description +
-			    !!unset_upstream;
+			    !!unset_upstream + !!prune_merged;
 	if (noncreate_actions > 1)
 		usage_with_options(builtin_branch_usage, options);
 
@@ -908,6 +968,9 @@ int cmd_branch(int argc,
 				      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |
 				      (quiet ? DELETE_BRANCH_QUIET : 0));
 		goto out;
+	} else if (prune_merged) {
+		ret = prune_merged_branches(argc, argv, quiet);
+		goto out;
 	} else if (show_current) {
 		print_current_branch_name();
 		ret = 0;
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index 4e7deddc04..27ea1319bb 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1809,4 +1809,205 @@ 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 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 at least one <branch>' '
+	test_must_fail git -C forked branch --prune-merged 2>err &&
+	test_grep "requires at least one <branch>" 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
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v14 3/6] branch: prepare delete_branches for a bulk caller
From: Harald Nordgren via GitGitGadget @ 2026-06-09 10:11 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v14.git.git.1780999917.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

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 | 28 +++++++++++++++++++++-------
 1 file changed, 21 insertions(+), 7 deletions(-)

diff --git a/builtin/branch.c b/builtin/branch.c
index 4fb012c7a4..2cc5a8cde0 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -168,10 +168,13 @@ static int branch_merged(int kind, const char *name,
 	 * upstream, if any, otherwise with HEAD", we should just
 	 * return the result of the repo_in_merge_bases() above without
 	 * any of the following code, but during the transition period,
-	 * a gentle reminder is in order.
+	 * a gentle reminder is in order.  Callers that opt out of the
+	 * HEAD fallback by passing head_rev=NULL are not interested in
+	 * the reminder either: they have already established that the
+	 * branch has an upstream, so HEAD is irrelevant to the decision.
 	 */
-	if (head_rev != reference_rev) {
-		int expect = head_rev ? repo_in_merge_bases(the_repository, rev, head_rev) : 0;
+	if (head_rev && head_rev != reference_rev) {
+		int expect = repo_in_merge_bases(the_repository, rev, head_rev);
 		if (expect < 0)
 			exit(128);
 		if (expect == merged)
@@ -193,6 +196,8 @@ 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 check_branch_commit(const char *branchname, const char *refname,
@@ -240,7 +245,7 @@ static int delete_branches(int argc, const char **argv, int kinds,
 	int i;
 	int ret = 0;
 	int remote_branch = 0;
-	int force, quiet;
+	int force, quiet, dry_run, no_head_fallback;
 	struct strbuf bname = STRBUF_INIT;
 	enum interpret_branch_kind allowed_interpret;
 	struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
@@ -268,8 +273,10 @@ static int delete_branches(int argc, const char **argv, int kinds,
 
 	force = flags & DELETE_BRANCH_FORCE;
 	quiet = flags & DELETE_BRANCH_QUIET;
+	dry_run = flags & DELETE_BRANCH_DRY_RUN;
+	no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;
 
-	if (!force)
+	if (!force && !no_head_fallback)
 		head_rev = lookup_commit_reference(the_repository, &head_oid);
 
 	for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
@@ -340,13 +347,20 @@ static int delete_branches(int argc, const char **argv, int kinds,
 		free(target);
 	}
 
-	if (refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))
+	if (!dry_run &&
+	    refs_delete_refs(get_main_ref_store(the_repository), NULL, &refs_to_delete, REF_NO_DEREF))
 		ret = 1;
 
 	for_each_string_list_item(item, &refs_to_delete) {
 		char *describe_ref = item->util;
 		char *name = item->string;
-		if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
+		if (dry_run) {
+			if (!quiet)
+				printf(remote_branch
+					? _("Would delete remote-tracking branch %s (was %s).\n")
+					: _("Would delete branch %s (was %s).\n"),
+					name + branch_name_pos, describe_ref);
+		} else if (!refs_ref_exists(get_main_ref_store(the_repository), name)) {
 			char *refname = name + branch_name_pos;
 			if (!quiet)
 				printf(remote_branch
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v14 2/6] branch: let delete_branches warn instead of error on bulk refusal
From: Harald Nordgren via GitGitGadget @ 2026-06-09 10:11 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v14.git.git.1780999917.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

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 | 54 +++++++++++++++++++++++++++++++++---------------
 1 file changed, 37 insertions(+), 17 deletions(-)

diff --git a/builtin/branch.c b/builtin/branch.c
index c159f45b4c..4fb012c7a4 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -189,20 +189,33 @@ 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, unsigned int flags)
 {
+	int force = flags & DELETE_BRANCH_FORCE;
 	struct commit *rev = lookup_commit_reference(the_repository, oid);
 	if (!force && !rev) {
 		error(_("couldn't look up commit object for '%s'"), refname);
 		return -1;
 	}
 	if (!force && !branch_merged(kinds, branchname, rev, head_rev)) {
-		error(_("the branch '%s' is not fully merged"), branchname);
-		advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
-				  _("If you are sure you want to delete it, "
-				  "run 'git branch -D %s'"), branchname);
+		if (flags & DELETE_BRANCH_WARN_ONLY) {
+			warning(_("the branch '%s' is not fully merged"),
+				branchname);
+		} else {
+			error(_("the branch '%s' is not fully merged"),
+			      branchname);
+			advise_if_enabled(ADVICE_FORCE_DELETE_BRANCH,
+					  _("If you are sure you want to delete it, "
+					  "run 'git branch -D %s'"), branchname);
+		}
 		return -1;
 	}
 	return 0;
@@ -217,8 +230,8 @@ static void delete_branch_config(const char *branchname)
 	strbuf_release(&buf);
 }
 
-static int delete_branches(int argc, const char **argv, int force, int kinds,
-			   int quiet)
+static int delete_branches(int argc, const char **argv, int kinds,
+			   unsigned int flags)
 {
 	struct commit *head_rev = NULL;
 	struct object_id oid;
@@ -227,6 +240,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
 	int i;
 	int ret = 0;
 	int remote_branch = 0;
+	int force, quiet;
 	struct strbuf bname = STRBUF_INIT;
 	enum interpret_branch_kind allowed_interpret;
 	struct string_list refs_to_delete = STRING_LIST_INIT_DUP;
@@ -241,7 +255,7 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
 		remote_branch = 1;
 		allowed_interpret = INTERPRET_BRANCH_REMOTE;
 
-		force = 1;
+		flags |= DELETE_BRANCH_FORCE;
 		break;
 	case FILTER_REFS_BRANCHES:
 		fmt = "refs/heads/%s";
@@ -252,12 +266,15 @@ static int delete_branches(int argc, const char **argv, int force, int kinds,
 	}
 	branch_name_pos = strcspn(fmt, "%");
 
+	force = flags & DELETE_BRANCH_FORCE;
+	quiet = flags & DELETE_BRANCH_QUIET;
+
 	if (!force)
 		head_rev = lookup_commit_reference(the_repository, &head_oid);
 
 	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);
@@ -279,7 +296,7 @@ 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);
@@ -291,7 +308,7 @@ 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 (virtual_target)
@@ -306,16 +323,17 @@ 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;
+					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:
@@ -872,7 +890,9 @@ 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, filter.kind,
+				      (delete > 1 ? DELETE_BRANCH_FORCE : 0) |
+				      (quiet ? DELETE_BRANCH_QUIET : 0));
 		goto out;
 	} else if (show_current) {
 		print_current_branch_name();
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v14 1/6] branch: add --forked filter for --list mode
From: Harald Nordgren via GitGitGadget @ 2026-06-09 10:11 UTC (permalink / raw)
  To: git
  Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren, Harald Nordgren
In-Reply-To: <pull.2285.v14.git.git.1780999917.gitgitgadget@gmail.com>

From: Harald Nordgren <haraldnordgren@gmail.com>

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 glob
(e.g. "origin/*"), and may be repeated to widen the filter.

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

Signed-off-by: Harald Nordgren <haraldnordgren@gmail.com>
---
 Documentation/git-branch.adoc | 10 +++-
 builtin/branch.c              | 18 ++++++-
 ref-filter.c                  | 70 ++++++++++++++++++++++++++
 ref-filter.h                  | 10 ++++
 t/t3200-branch.sh             | 92 +++++++++++++++++++++++++++++++++++
 5 files changed, 197 insertions(+), 3 deletions(-)

diff --git a/Documentation/git-branch.adoc b/Documentation/git-branch.adoc
index c0afddc424..62ebab6051 100644
--- a/Documentation/git-branch.adoc
+++ b/Documentation/git-branch.adoc
@@ -13,6 +13,7 @@ git branch [--color[=<when>] | --no-color] [--show-current]
 	   [--column[=<options>] | --no-column] [--sort=<key>]
 	   [--merged [<commit>]] [--no-merged [<commit>]]
 	   [--contains [<commit>]] [--no-contains [<commit>]]
+	   [(--forked <branch>)...]
 	   [--points-at <object>] [--format=<format>]
 	   [(-r|--remotes) | (-a|--all)]
 	   [--list] [<pattern>...]
@@ -51,7 +52,8 @@ 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
@@ -311,6 +313,12 @@ 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>`::
+	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. Implies `--list`.
+
 `--points-at <object>`::
 	Only list branches of _<object>_.
 
diff --git a/builtin/branch.c b/builtin/branch.c
index 1572a4f9ef..c159f45b4c 100644
--- a/builtin/branch.c
+++ b/builtin/branch.c
@@ -30,7 +30,7 @@
 #include "commit-reach.h"
 
 static const char * const builtin_branch_usage[] = {
-	N_("git branch [<options>] [-r | -a] [--merged] [--no-merged]"),
+	N_("git branch [<options>] [-r | -a] [--merged] [--no-merged] [(--forked <branch>)...]"),
 	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>..."),
@@ -673,6 +673,16 @@ static void copy_or_rename_branch(const char *oldname, const char *newname, int
 	free_worktrees(worktrees);
 }
 
+static int parse_opt_forked(const struct option *opt, const char *arg, int unset)
+{
+	struct ref_filter *filter = opt->value;
+
+	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")
 
 static int edit_branch_description(const char *branch_name)
@@ -770,6 +780,9 @@ 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"),
@@ -815,7 +828,8 @@ 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 || filter.forked.nr)
 		list = 1;
 
 	noncreate_actions = !!delete + !!rename + !!copy + !!new_upstream +
diff --git a/ref-filter.c b/ref-filter.c
index 1da4c0e60d..1ddd5a3f6d 100644
--- a/ref-filter.c
+++ b/ref-filter.c
@@ -2744,6 +2744,72 @@ static int filter_exclude_match(struct ref_filter *filter, const char *refname)
 	return match_pattern(filter->exclude.v, refname, filter->ignore_case);
 }
 
+static const char *short_upstream_name(const char *full_ref)
+{
+	const char *short_name = full_ref;
+	(void)(skip_prefix(short_name, "refs/heads/", &short_name) ||
+	       skip_prefix(short_name, "refs/remotes/", &short_name));
+	return short_name;
+}
+
+/*
+ * 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(refname, "refs/heads/", &short_name))
+		return 0;
+	branch = branch_get(short_name);
+	if (!branch)
+		return 0;
+	upstream = branch_get_upstream(branch, NULL);
+	if (!upstream)
+		return 0;
+
+	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;
+}
+
+int ref_filter_forked_add(struct ref_filter *filter, const char *arg)
+{
+	struct object_id oid;
+	char *full_ref = NULL;
+
+	if (has_glob_specials(arg)) {
+		strvec_push(&filter->forked, arg);
+		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/"))) {
+		strvec_push(&filter->forked, full_ref);
+		free(full_ref);
+		return 0;
+	}
+	free(full_ref);
+	return -1;
+}
+
 /*
  * 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.
@@ -2979,6 +3045,9 @@ 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;
+
 	/*
 	 * A merge filter is applied on refs pointing to commits. Hence
 	 * obtain the commit using the 'oid' available and discard all
@@ -3765,6 +3834,7 @@ 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);
diff --git a/ref-filter.h b/ref-filter.h
index 120221b47f..9361296e2a 100644
--- a/ref-filter.h
+++ b/ref-filter.h
@@ -67,6 +67,7 @@ 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;
@@ -110,6 +111,7 @@ 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, \
@@ -172,6 +174,14 @@ 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. */
diff --git a/t/t3200-branch.sh b/t/t3200-branch.sh
index e7829c2c4b..4e7deddc04 100755
--- a/t/t3200-branch.sh
+++ b/t/t3200-branch.sh
@@ -1717,4 +1717,96 @@ test_expect_success 'errors if given a bad branch name' '
 	test_cmp expect actual
 '
 
+test_expect_success '--forked: setup' '
+	test_create_repo forked-upstream &&
+	test_commit -C forked-upstream base &&
+	git -C forked-upstream branch one base &&
+	git -C forked-upstream branch two base &&
+
+	test_create_repo forked-other &&
+	test_commit -C forked-other other-base &&
+	git -C forked-other branch foreign other-base &&
+
+	git clone forked-upstream forked &&
+	git -C forked remote add other ../forked-other &&
+	git -C forked fetch other &&
+	git -C forked branch local-base &&
+	git -C forked branch --track local-one origin/one &&
+	git -C forked branch --track local-two origin/two &&
+	git -C forked branch --track local-foreign other/foreign &&
+	git -C forked branch detached &&
+	git -C forked branch --track local-trunk local-base
+'
+
+test_expect_success '--forked <upstream-tracking-branch> filters by upstream' '
+	git -C forked branch --forked origin/one --format="%(refname:short)" >actual &&
+	echo local-one >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '--forked <glob> filters by wildmatch' '
+	git -C forked branch --forked "origin/*" --format="%(refname:short)" >actual &&
+	cat >expect <<-\EOF &&
+	local-one
+	local-two
+	main
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success '--forked <local-branch> matches branches with local upstream' '
+	git -C forked branch --forked local-base --format="%(refname:short)" >actual &&
+	echo local-trunk >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '--forked can be repeated to widen the filter' '
+	git -C forked branch --forked origin/one --forked other/foreign --format="%(refname:short)" >actual &&
+	cat >expect <<-\EOF &&
+	local-foreign
+	local-one
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success '--forked combines literal and glob arguments' '
+	git -C forked branch --forked local-base --forked "other/*" --format="%(refname:short)" >actual &&
+	cat >expect <<-\EOF &&
+	local-foreign
+	local-trunk
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success '--forked "*/*" covers every remote-tracking upstream' '
+	git -C forked branch --forked "*/*" --format="%(refname:short)" >actual &&
+	cat >expect <<-\EOF &&
+	local-foreign
+	local-one
+	local-two
+	main
+	EOF
+	test_cmp expect actual
+'
+
+test_expect_success '--forked composes with --no-merged' '
+	test_when_finished "git -C forked checkout detached" &&
+	git -C forked checkout local-one &&
+	test_commit -C forked local-only &&
+	git -C forked branch --forked "origin/*" --no-merged origin/one \
+		--format="%(refname:short)" >actual &&
+	echo local-one >expect &&
+	test_cmp expect actual
+'
+
+test_expect_success '--forked rejects unknown branch/pattern' '
+	test_must_fail git -C forked branch --forked nope 2>err &&
+	test_grep "not a valid branch or pattern" err
+'
+
+test_expect_success '--forked requires a value' '
+	test_must_fail git -C forked branch --forked 2>err &&
+	test_grep "requires a value" err
+'
+
 test_done
-- 
gitgitgadget


^ permalink raw reply related

* [PATCH v14 0/6] branch: prune-merged
From: Harald Nordgren via GitGitGadget @ 2026-06-09 10:11 UTC (permalink / raw)
  To: git; +Cc: Kristoffer Haugsbakk, Johannes Sixt, Phillip Wood,
	Harald Nordgren
In-Reply-To: <pull.2285.v13.git.git.1780684553.gitgitgadget@gmail.com>

 * Fixed a git branch -d -r regression (broke t5404/t5505/t5514): the
   remotes path set a local force but not the DELETE_BRANCH_FORCE bit that
   check_branch_commit() reads, so it wrongly ran the merge check.
 * Made flags the single source of truth in delete_branches() so the bit and
   the derived locals can't disagree.
 * Works locally, but GitHub CI has problems that are there for other
   branches too, hopefully not related
   (https://github.com/git/git/pull/2285).

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                 | 186 +++++++++++++---
 ref-filter.c                     |  70 ++++++
 ref-filter.h                     |  10 +
 t/t3200-branch.sh                | 367 +++++++++++++++++++++++++++++++
 6 files changed, 653 insertions(+), 28 deletions(-)


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

Range-diff vs v13:

 1:  ccd07cff25 = 1:  7383872f4b branch: add --forked filter for --list mode
 2:  a7672713f6 ! 2:  7ef9502e01 branch: let delete_branches warn instead of error on bulk refusal
     @@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int fo
       	int i;
       	int ret = 0;
       	int remote_branch = 0;
     -+	int force = flags & DELETE_BRANCH_FORCE;
     -+	int quiet = flags & DELETE_BRANCH_QUIET;
     ++	int force, 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,
     + 		remote_branch = 1;
     + 		allowed_interpret = INTERPRET_BRANCH_REMOTE;
     + 
     +-		force = 1;
     ++		flags |= DELETE_BRANCH_FORCE;
     + 		break;
     + 	case FILTER_REFS_BRANCHES:
     + 		fmt = "refs/heads/%s";
     +@@ builtin/branch.c: static int delete_branches(int argc, const char **argv, int force, int kinds,
     + 	}
     + 	branch_name_pos = strcspn(fmt, "%");
     + 
     ++	force = flags & DELETE_BRANCH_FORCE;
     ++	quiet = flags & DELETE_BRANCH_QUIET;
     ++
     + 	if (!force)
     + 		head_rev = lookup_commit_reference(the_repository, &head_oid);
       
       	for (i = 0; i < argc; i++, strbuf_reset(&bname)) {
       		char *target = NULL;
 3:  5ee7643d3a ! 3:  259113e304 branch: prepare delete_branches for a bulk caller
     @@ builtin/branch.c: enum delete_branch_flags {
       
       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 i;
     + 	int ret = 0;
       	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;
     +-	int force, quiet;
     ++	int force, quiet, dry_run, 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, "%");
     + 
     + 	force = flags & DELETE_BRANCH_FORCE;
     + 	quiet = flags & DELETE_BRANCH_QUIET;
     ++	dry_run = flags & DELETE_BRANCH_DRY_RUN;
     ++	no_head_fallback = flags & DELETE_BRANCH_NO_HEAD_FALLBACK;
       
      -	if (!force)
      +	if (!force && !no_head_fallback)
 4:  5f913c445c = 4:  9924373da0 branch: add --prune-merged <branch>
 5:  8e9a735ffe = 5:  d691d5051b branch: add branch.<name>.pruneMerged opt-out
 6:  511de4788e = 6:  ede8c61729 branch: add --dry-run for --prune-merged

-- 
gitgitgadget

^ permalink raw reply

* Re: [PATCH] worktree: record creation time and free-form note
From: Phillip Wood @ 2026-06-09 10:07 UTC (permalink / raw)
  To: Kiesel, Norbert, git, Junio C Hamano
  Cc: phillip.wood, Chris Torek, kristofferhaugsbakk
In-Reply-To: <CAPGaHkv=p62gLwkufc6TWjJR3OdV+DYdmWUZ6Xn0-qgHsw5_4Q@mail.gmail.com>

Hi Norbert

On 08/06/2026 17:12, Kiesel, Norbert wrote:
> Hi team,
> I updated my proposed extension in a couple of ways you suggested, and
> also added some more test code.

It would be much easier to comment on these changes if they were split 
out into three separate patches (adding the description, adding the 
creation time and adding sorting) with commit messages that explained 
the motivation for each change.

Without a descripion of why each change is useful it is hard to comment 
further. At this stage explaining why these changes are useful is much 
more important than the code itself so that could be done without any 
patches.

Thanks

Phillip

> Best,
>    Norbert
> 
> diff --git Documentation/git-worktree.adoc Documentation/git-worktree.adoc
> index fbf8426cd9..1cdbdc8dbe 100644
> --- Documentation/git-worktree.adoc
> +++ Documentation/git-worktree.adoc
> @@ -10,8 +10,11 @@ SYNOPSIS
>   --------
>   [synopsis]
>   git worktree add [-f] [--detach] [--checkout] [--lock [--reason <string>]]
> + [--description <string>]
>    [--orphan] [(-b | -B) <new-branch>] <path> [<commit-ish>]
> -git worktree list [-v | --porcelain [-z]]
> +git worktree describe <worktree> [<description>]
> +git worktree list [-v | --porcelain [-z]] [--show-created]
> + [--show-updated] [--show-description] [--sort=<key>]
>   git worktree lock [--reason <string>] <worktree>
>   git worktree move <worktree> <new-path>
>   git worktree prune [-n] [-v] [--expire <expire>]
> @@ -106,6 +109,16 @@ 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`).
> 
> +`describe <worktree> [<description>]`::
> +
> +Set, replace, or clear a free-form description on a linked worktree.
> +Useful for recording what a worktree was created for so it can be identified
> +later. With _<description>_, the worktree's description is set or replaced;
> +without a description argument, the existing description is cleared. The
> +description for a worktree may also be set at creation time with
> +`git worktree add --description <description>`. The main worktree cannot be
> +described.
> +
>   `list`::
> 
>   List details of each worktree.  The main worktree is listed first,
> @@ -114,6 +127,28 @@ 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-updated` to include each worktree's last-updated timestamp,
> +which is the modification time of the worktree's `HEAD` file and so
> +reflects checkouts, commits, resets, rebases, and similar Git operations.
> ++
> +Pass `--show-description` to include any user-provided description in human
> +output. In `--porcelain` output, the `created`, `updated`, and
> +`description` lines are emitted whenever the underlying data is available.
> ++
> +Use `--sort=<key>` (where _<key>_ is `path`, `created`, or `updated`,
> +optionally prefixed with `-` to reverse) to order the linked worktrees;
> +the main worktree always remains first. Sorting by `created` or `updated`
> +implies the matching `--show-created` / `--show-updated` flag so the order
> +is visible alongside the data.
> 
>   `lock`::
> 
> @@ -286,6 +321,46 @@ _<time>_.
>    With `lock` or with `add --lock`, an explanation why the worktree
>    is locked.
> 
> +`--description <string>`::
> + With `add`, attach a free-form description to the new worktree.
> + The description is stored alongside the worktree's administrative
> + files and can be displayed with `git worktree list --show-description`
> + or in `--porcelain` output. It can be changed later with
> + `git worktree describe`.
> +
> +`--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-updated`::
> + With `list`, include each linked worktree's last-updated timestamp in
> + the human-readable output, derived from the modification time of the
> + worktree's `HEAD` file. Linked worktrees whose `HEAD` cannot be read
> + are shown as `updated: unknown`. The main worktree is not annotated
> + with an updated timestamp. In `--porcelain` output, the timestamp is
> + included on an `updated` line whenever it is available (and the
> + worktree is not the main worktree).
> +
> +`--show-description`::
> + With `list`, include each worktree's description (if set) in the
> + human-readable output. In `--porcelain` output, the description is
> + always included (when set) on a `description` line.
> +
> +`--sort=<key>`::
> + With `list`, sort linked worktrees by _<key>_, which is one of
> + `path`, `created`, or `updated`. Prefix with `-` to reverse the order,
> + e.g. `--sort=-created` lists newest first. The main worktree is always
> + listed first regardless of sort order. For `created`, worktrees with no
> + recorded creation timestamp sort after those that have one (or before,
> + when reversed). For `updated`, ordering is by the modification time of
> + each worktree's `HEAD` file (a proxy for when the worktree was last
> + touched by checkout, commit, reset or rebase); worktrees whose `HEAD`
> + cannot be read sort after those that can. Sorting by `created` or
> + `updated` implies the matching `--show-created` / `--show-updated`
> + option so the values driving the order appear in human output.
> +
>   _<worktree>_::
>    Worktrees can be identified by path, either relative or absolute.
>   +
> @@ -462,7 +537,10 @@ 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`, `updated`, and `description`) 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 +552,15 @@ bare
>   worktree /path/to/linked-worktree
>   HEAD abcd1234abcd1234abcd1234abcd1234abcd1234
>   branch refs/heads/master
> +created 2026-06-01T12:34:56Z
> +updated 2026-06-04T17:20:11Z
> +description investigating login bug
> 
>   worktree /path/to/other-linked-worktree
>   HEAD 1234abc1234abc1234abc1234abc1234abc1234a
>   detached
> +created 2026-05-28T08:15:00Z
> +updated 2026-05-30T09:42:08Z
> 
>   worktree /path/to/linked-worktree-locked-no-reason
>   HEAD 5678abc5678abc5678abc5678abc5678abc5678c
> diff --git builtin/worktree.c builtin/worktree.c
> index d21c43fde3..132de668e3 100644
> --- builtin/worktree.c
> +++ builtin/worktree.c
> @@ -27,13 +27,17 @@
>   #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" \
> +    "                 [--description <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]\n" \
> +    "                  [--show-updated] [--show-description]\n" \
> +    "                  [--sort=<key>]")
>   #define BUILTIN_WORKTREE_LOCK_USAGE \
>    N_("git worktree lock [--reason <string>] <worktree>")
>   #define BUILTIN_WORKTREE_MOVE_USAGE \
> @@ -46,6 +50,8 @@
>    N_("git worktree repair [<path>...]")
>   #define BUILTIN_WORKTREE_UNLOCK_USAGE \
>    N_("git worktree unlock <worktree>")
> +#define BUILTIN_WORKTREE_DESCRIBE_USAGE \
> + N_("git worktree describe <worktree> [<description>]")
> 
>   #define WORKTREE_ADD_DWIM_ORPHAN_INFER_TEXT \
>    _("No possible source branch, inferring '--orphan'")
> @@ -66,6 +72,7 @@
> 
>   static const char * const git_worktree_usage[] = {
>    BUILTIN_WORKTREE_ADD_USAGE,
> + BUILTIN_WORKTREE_DESCRIBE_USAGE,
>    BUILTIN_WORKTREE_LIST_USAGE,
>    BUILTIN_WORKTREE_LOCK_USAGE,
>    BUILTIN_WORKTREE_MOVE_USAGE,
> @@ -116,6 +123,11 @@ static const char * const git_worktree_unlock_usage[] = {
>    NULL
>   };
> 
> +static const char * const git_worktree_describe_usage[] = {
> + BUILTIN_WORKTREE_DESCRIBE_USAGE,
> + NULL
> +};
> +
>   struct add_opts {
>    int force;
>    int detach;
> @@ -124,6 +136,7 @@ struct add_opts {
>    int orphan;
>    int relative_paths;
>    const char *keep_locked;
> + const char *description;
>   };
> 
>   static int show_only;
> @@ -131,6 +144,9 @@ static int verbose;
>   static int guess_remote;
>   static int use_relative_paths;
>   static timestamp_t expire;
> +static int show_created = -1;
> +static int show_updated = -1;
> +static int show_description;
> 
>   static int git_worktree_config(const char *var, const char *value,
>           const struct config_context *ctx, void *cb)
> @@ -544,6 +560,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->description && *opts->description) {
> + strbuf_reset(&sb);
> + strbuf_addf(&sb, "%s/description", sb_repo.buf);
> + write_file(sb.buf, "%s", opts->description);
> + }
> +
>    /*
>    * Set up the ref store of the worktree and create the HEAD reference.
>    */
> @@ -815,6 +841,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, "description", &opts.description, N_("string"),
> +    N_("attach a free-form 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 +991,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 *description;
> + timestamp_t created;
> 
>    printf("worktree %s%c", wt->path, line_terminator);
>    if (wt->is_bare)
> @@ -975,6 +1005,26 @@ 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);
> +
> + {
> + timestamp_t updated = worktree_updated_at(wt);
> + if (updated)
> + printf("updated %s%c",
> +        show_date(updated, 0, DATE_MODE(ISO8601_STRICT)),
> +        line_terminator);
> + }
> +
> + description = worktree_description(wt);
> + if (description && *description) {
> + fputs("description ", stdout);
> + write_name_quoted(description, stdout, line_terminator);
> + }
> +
>    reason = worktree_lock_reason(wt);
>    if (reason) {
>    fputs("locked", stdout);
> @@ -1034,6 +1084,32 @@ static void show_worktree(struct worktree *wt,
> struct worktree_display *display,
>    else if (reason)
>    strbuf_addstr(&sb, " prunable");
> 
> + if (show_created > 0 || verbose) {
> + timestamp_t created = worktree_created_at(wt);
> + struct date_mode mode = { .type = DATE_ISO8601, .local = 1 };
> + if (created)
> + strbuf_addf(&sb, "\n\tcreated: %s",
> +     show_date(created, 0, mode));
> + else if (show_created > 0 && !is_main_worktree(wt))
> + strbuf_addstr(&sb, "\n\tcreated: unknown");
> + }
> +
> + if (show_updated > 0 || verbose) {
> + timestamp_t updated = worktree_updated_at(wt);
> + struct date_mode mode = { .type = DATE_ISO8601, .local = 1 };
> + if (updated)
> + strbuf_addf(&sb, "\n\tupdated: %s",
> +     show_date(updated, 0, mode));
> + else if (show_updated > 0 && !is_main_worktree(wt))
> + strbuf_addstr(&sb, "\n\tupdated: unknown");
> + }
> +
> + if (show_description || verbose) {
> + const char *description = worktree_description(wt);
> + if (description && *description)
> + strbuf_addf(&sb, "\n\tdescription: %s", description);
> + }
> +
>    printf("%s\n", sb.buf);
>    strbuf_release(&sb);
>   }
> @@ -1068,6 +1144,48 @@ 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 int updatedcmp(const void *a_, const void *b_)
> +{
> + struct worktree *const *a = a_;
> + struct worktree *const *b = b_;
> + timestamp_t ta = worktree_updated_at(*a);
> + timestamp_t tb = worktree_updated_at(*b);
> +
> + /* Worktrees whose HEAD mtime can't be read sort after those that can. */
> + 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 +1196,45 @@ 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 if (!strcmp(key, "updated"))
> + cmp = updatedcmp;
> + 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 +1243,14 @@ 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-updated", &show_updated,
> + N_("show worktree last-updated timestamps")),
> + OPT_BOOL(0, "show-description", &show_description,
> + N_("show worktree descriptions")),
> + OPT_STRING(0, "sort", &sort_key, N_("key"),
> +    N_("sort worktrees by key (path, created, updated); prefix with -
> to reverse")),
>    OPT_END()
>    };
> 
> @@ -1107,8 +1267,27 @@ 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) {
> + const char *bare_key = sort_key;
> + if (*bare_key == '-')
> + bare_key++;
> + /*
> + * Sorting by a timestamp without showing it would
> + * leave the user guessing why the order is what it
> + * is, so opt in the matching display by default.
> + * An explicit --show-* / --no-show-* still wins.
> + */
> + if (!strcmp(bare_key, "created") && show_created < 0)
> + show_created = 1;
> + else if (!strcmp(bare_key, "updated") && show_updated < 0)
> + show_updated = 1;
> +
> + 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 +1379,32 @@ static int unlock_worktree(int ac, const char
> **av, const char *prefix,
>    return ret;
>   }
> 
> +static int describe_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_describe_usage, 0);
> + if (ac < 1 || ac > 2)
> + usage_with_options(git_worktree_describe_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 described"));
> +
> + ret = set_worktree_description(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 +1674,7 @@ int cmd_worktree(int ac,
>    parse_opt_subcommand_fn *fn = NULL;
>    struct option options[] = {
>    OPT_SUBCOMMAND("add", &fn, add),
> + OPT_SUBCOMMAND("describe", &fn, describe_worktree),
>    OPT_SUBCOMMAND("prune", &fn, prune),
>    OPT_SUBCOMMAND("list", &fn, list),
>    OPT_SUBCOMMAND("lock", &fn, lock_worktree),
> diff --git t/meson.build t/meson.build
> index 2af8d01279..7b6e8435d7 100644
> --- t/meson.build
> +++ 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 t/t2402-worktree-list.sh t/t2402-worktree-list.sh
> index e0c6abd2f5..fb1f4b1d3c 100755
> --- t/t2402-worktree-list.sh
> +++ 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 -Ev "^(created|updated) " 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 -Ev "^(created|updated) " | 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 -Ev "^(created|updated) " actual.raw >actual &&
>    test_cmp expect actual
>   '
> 
> diff --git t/t2410-worktree-metadata.sh t/t2410-worktree-metadata.sh
> new file mode 100755
> index 0000000000..e1ecb1c1bf
> --- /dev/null
> +++ t/t2410-worktree-metadata.sh
> @@ -0,0 +1,245 @@
> +#!/bin/sh
> +
> +test_description='git worktree creation timestamp and description 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 --description writes description file' '
> + test_when_finished "git worktree remove -f wt2 && git worktree prune" &&
> + git worktree add --description "investigating bug" wt2 &&
> + test_path_is_file .git/worktrees/wt2/description &&
> + echo "investigating bug" >expect &&
> + test_cmp expect .git/worktrees/wt2/description
> +'
> +
> +test_expect_success 'add without --description does not create
> description file' '
> + test_when_finished "git worktree remove -f wt3 && git worktree prune" &&
> + git worktree add wt3 &&
> + test_path_is_missing .git/worktrees/wt3/description
> +'
> +
> +test_expect_success 'describe sets a description on an existing worktree' '
> + test_when_finished "git worktree remove -f wt4 && git worktree prune" &&
> + git worktree add wt4 &&
> + git worktree describe wt4 "later description" &&
> + echo "later description" >expect &&
> + test_cmp expect .git/worktrees/wt4/description
> +'
> +
> +test_expect_success 'describe replaces an existing description' '
> + test_when_finished "git worktree remove -f wt5 && git worktree prune" &&
> + git worktree add --description "old" wt5 &&
> + git worktree describe wt5 "new" &&
> + echo "new" >expect &&
> + test_cmp expect .git/worktrees/wt5/description
> +'
> +
> +test_expect_success 'describe with no text clears the description' '
> + test_when_finished "git worktree remove -f wt6 && git worktree prune" &&
> + git worktree add --description "to delete" wt6 &&
> + test_path_is_file .git/worktrees/wt6/description &&
> + git worktree describe wt6 &&
> + test_path_is_missing .git/worktrees/wt6/description
> +'
> +
> +test_expect_success 'describe refuses to operate on the main worktree' '
> + test_must_fail git worktree describe . "should fail" 2>err &&
> + grep -i "main working tree" err
> +'
> +
> +test_expect_success 'list --show-description displays description in
> human output' '
> + test_when_finished "git worktree remove -f wt7 && git worktree prune" &&
> + git worktree add --description "release branch" wt7 &&
> + git worktree list --show-description >actual &&
> + grep "description: 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 --show-updated displays updated timestamp' '
> + test_when_finished "git worktree remove -f wt8u && git worktree prune" &&
> + git worktree add wt8u &&
> + git worktree list --show-updated >actual &&
> + grep "updated: " actual
> +'
> +
> +test_expect_success 'list --porcelain always includes created,
> updated, and description' '
> + test_when_finished "git worktree remove -f wtp && git worktree prune" &&
> + git worktree add --description "porcelain test" wtp &&
> + git worktree list --porcelain >actual &&
> + grep "^created " actual &&
> + grep "^updated " actual &&
> + grep "^description 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=updated orders by HEAD mtime' '
> + test_when_finished "git worktree remove -f u1 && git worktree remove
> -f u2 && git worktree remove -f u3 && git worktree prune" &&
> + git worktree add u1 &&
> + git worktree add u2 &&
> + git worktree add u3 &&
> + # Force a known ordering: u2 oldest, u1 middle, u3 newest.
> + test-tool chmtime =1000 .git/worktrees/u2/HEAD &&
> + test-tool chmtime =2000 .git/worktrees/u1/HEAD &&
> + test-tool chmtime =3000 .git/worktrees/u3/HEAD &&
> + git worktree list --sort=updated --porcelain >actual &&
> + grep "^worktree " actual | sed -n "2,4p" >linked &&
> + awk "NR==1" linked | grep -q "/u2$" &&
> + awk "NR==2" linked | grep -q "/u1$" &&
> + awk "NR==3" linked | grep -q "/u3$"
> +'
> +
> +test_expect_success 'list --sort=-updated reverses order' '
> + test_when_finished "git worktree remove -f u1 && git worktree remove
> -f u2 && git worktree remove -f u3 && git worktree prune" &&
> + git worktree add u1 &&
> + git worktree add u2 &&
> + git worktree add u3 &&
> + test-tool chmtime =1000 .git/worktrees/u2/HEAD &&
> + test-tool chmtime =2000 .git/worktrees/u1/HEAD &&
> + test-tool chmtime =3000 .git/worktrees/u3/HEAD &&
> + git worktree list --sort=-updated --porcelain >actual &&
> + grep "^worktree " actual | sed -n "2,4p" >linked &&
> + awk "NR==1" linked | grep -q "/u3$" &&
> + awk "NR==2" linked | grep -q "/u1$" &&
> + awk "NR==3" linked | grep -q "/u2$"
> +'
> +
> +test_expect_success 'list --sort=created auto-shows created timestamp' '
> + test_when_finished "git worktree remove -f autoc && git worktree prune" &&
> + git worktree add autoc &&
> + git worktree list --sort=created >actual &&
> + grep "created: " actual
> +'
> +
> +test_expect_success 'list --sort=-created auto-shows created timestamp' '
> + test_when_finished "git worktree remove -f autocr && git worktree prune" &&
> + git worktree add autocr &&
> + git worktree list --sort=-created >actual &&
> + grep "created: " actual
> +'
> +
> +test_expect_success 'list --sort=updated auto-shows updated timestamp' '
> + test_when_finished "git worktree remove -f autou && git worktree prune" &&
> + git worktree add autou &&
> + git worktree list --sort=updated >actual &&
> + grep "updated: " actual
> +'
> +
> +test_expect_success 'list --sort=-updated auto-shows updated timestamp' '
> + test_when_finished "git worktree remove -f autour && git worktree prune" &&
> + git worktree add autour &&
> + git worktree list --sort=-updated >actual &&
> + grep "updated: " actual
> +'
> +
> +test_expect_success 'list --sort=path does not auto-show timestamps' '
> + test_when_finished "git worktree remove -f autop && git worktree prune" &&
> + git worktree add autop &&
> + git worktree list --sort=path >actual &&
> + ! grep "created: " actual &&
> + ! grep "updated: " actual
> +'
> +
> +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_expect_success 'list --sort=updated --no-show-updated suppresses
> auto-show' '
> + test_when_finished "git worktree remove -f noshowu && git worktree prune" &&
> + git worktree add noshowu &&
> + git worktree list --sort=updated --no-show-updated >actual &&
> + ! grep "updated: " actual
> +'
> +
> +test_expect_success 'list --sort=created --no-show-created suppresses
> auto-show' '
> + test_when_finished "git worktree remove -f noshowc && git worktree prune" &&
> + git worktree add noshowc &&
> + git worktree list --sort=created --no-show-created >actual &&
> + ! grep "created: " actual
> +'
> +
> +test_expect_success 'list --show-updated formats human output in
> local timezone' '
> + test_when_finished "git worktree remove -f tz && git worktree prune" &&
> + git worktree add tz &&
> + # Pin HEAD mtime to a fixed unix time outside any DST transition
> + # so the rendered offset is deterministic in PST8PDT (-0700 in July).
> + test-tool chmtime =1500000000 .git/worktrees/tz/HEAD &&
> + TZ=PST8PDT git worktree list --show-updated >human &&
> + grep "updated: 2017-07-13 19:40:00 -0700" human &&
> + # Porcelain stays in UTC ISO-8601 strict form regardless of TZ.
> + TZ=PST8PDT git worktree list --porcelain >porcelain &&
> + grep "^updated 2017-07-14T02:40:00Z$" porcelain
> +'
> +
> +test_done
> diff --git worktree.c worktree.c
> index 97eddc3916..4b019a532b 100644
> --- worktree.c
> +++ 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->description);
>    free(worktree);
>   }
> 
> @@ -324,6 +327,100 @@ 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;
> +}
> +
> +timestamp_t worktree_updated_at(struct worktree *wt)
> +{
> + struct stat st;
> + char *git_dir;
> + char *head_path;
> + timestamp_t result = 0;
> +
> + if (is_main_worktree(wt))
> + return 0;
> +
> + git_dir = get_worktree_git_dir(wt);
> + head_path = xstrfmt("%s/HEAD", git_dir);
> + if (!stat(head_path, &st))
> + result = (timestamp_t) st.st_mtime;
> + free(head_path);
> + free(git_dir);
> + return result;
> +}
> +
> +const char *worktree_description(struct worktree *wt)
> +{
> + if (is_main_worktree(wt))
> + return NULL;
> +
> + if (!wt->description_valid) {
> + struct strbuf path = STRBUF_INIT;
> +
> + strbuf_addstr(&path, worktree_git_path(wt, "description"));
> + if (file_exists(path.buf)) {
> + struct strbuf description = STRBUF_INIT;
> + if (strbuf_read_file(&description, path.buf, 0) < 0)
> + die_errno(_("failed to read '%s'"), path.buf);
> + strbuf_trim_trailing_newline(&description);
> + wt->description = strbuf_detach(&description, NULL);
> + } else
> + wt->description = NULL;
> + wt->description_valid = 1;
> + strbuf_release(&path);
> + }
> +
> + return wt->description;
> +}
> +
> +int set_worktree_description(struct worktree *wt, const char *text)
> +{
> + char *path;
> + int ret = 0;
> +
> + if (is_main_worktree(wt))
> + return error(_("cannot set description on the main worktree"));
> +
> + path = repo_common_path(wt->repo, "worktrees/%s/description", 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_description() re-reads */
> + FREE_AND_NULL(wt->description);
> + wt->description_valid = 0;
> +
> + free(path);
> + return ret;
> +}
> +
>   const char *worktree_prune_reason(struct worktree *wt, timestamp_t expire)
>   {
>    struct strbuf reason = STRBUF_INIT;
> diff --git worktree.h worktree.h
> index 1075409f9a..2568830237 100644
> --- worktree.h
> +++ 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 *description; /* private - use worktree_description */
>    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 description_valid; /* private */
> + int created_at_valid;  /* private */
>   };
> 
>   /*
> @@ -96,6 +100,34 @@ 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 modification time of the worktree's HEAD file as an
> + * approximation of "when was this worktree last touched by Git" (checkout,
> + * commit, reset, rebase, etc.). Returns 0 for the main worktree, and 0 if
> + * HEAD cannot be stat'd.
> + */
> +timestamp_t worktree_updated_at(struct worktree *wt);
> +
> +/*
> + * Return the user-supplied description for the given worktree, or NULL
> + * if none was set.
> + */
> +const char *worktree_description(struct worktree *wt);
> +
> +/*
> + * Write or replace the worktree's description. Pass NULL or "" to delete
> + * the description. Returns 0 on success, -1 on failure. Not valid for the
> + * main worktree.
> + */
> +int set_worktree_description(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
> 
> On Fri, Jun 5, 2026 at 9:57 AM Chris Torek <chris.torek@gmail.com> wrote:
>>
>> 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: [PATCH RFC 1/2] builtin/history: abort reword on unchanged message
From: Pablo Sabater @ 2026-06-09 10:03 UTC (permalink / raw)
  To: Ben Knoble; +Cc: Junio C Hamano, git, Patrick Steinhardt, Kaartic Sivaraam
In-Reply-To: <3D9034D8-C38F-48A1-B637-4342BE4954AC@gmail.com>

El lun, 8 jun 2026 a las 18:44, Ben Knoble (<ben.knoble@gmail.com>) escribió:
>
>
> > Le 8 juin 2026 à 08:23, Junio C Hamano <gitster@pobox.com> a écrit :
> >
> [snip]
>
> > Having said that, I personally think that the current behaviour of
> > `commit --amend` and `history reword` are both _wrong_ [*2*].
> >
> > You may start `git commit --amend`, and after staring at the
> > existing commit log message for some time in your editor, it is
> > quite natural for you to decide that leaving the commit as-is is the
> > right thing [*3*] in your situation.  It may have been a better
> > design for the system to notice this situation and leave the commit
> > as-is, with an override option `--force` to allow users to forcibly
> > update the committer ident and timestamp in the commit header.  I am
> > not a `history reword` user (yet), but from the motivation you
> > described for this patch, I sense that the story is the same there.
>
> FWIW, in this situation I abort my editor (:cquit in Vim) so that the amend gets an error-valued exit code from the subprocess and aborts itself.
>
> Perhaps there could/should be a better side-channel for communicating that, though? I do not know how easy it is to tell other editors to « quit with errors ».

Well, I didn't know that I could exit with errors (:cq in NeoVim),
can't say much about other editors, but It would be better to abort if
the messages are the same and forget about editors.

>
> > [Footnote]
> >
> > *1* Besides, doesn't "--update-refs" in "rebase -i" allow you to
> >     adjust the branches?
> >
> > *2* But it is an established behaviour people _rely_ on, so even
> >     though it may have been better if these commands behaved
> >     differently, it probably is a bit too late to change it now.
> >
> > *3* This includes the case where the original author is especially
> >     difficult to work with and would complain any change to their
> >     commits, even if the only change you made for them is a
> >     typofix.  Fixing a small typo/grammo may not be worth your time
> >     and unpleasant exchanges with them after touching their commit.

Thanks,
Pablo

^ permalink raw reply

* Re: [PATCH v2 5/9] reset: introduce ability to skip reference updates
From: Phillip Wood @ 2026-06-09 10:03 UTC (permalink / raw)
  To: Patrick Steinhardt, phillip.wood; +Cc: git, Pablo Sabater, Junio C Hamano
In-Reply-To: <aiaI6thDj8y_EekG@pks.im>

Hi Patrick

On 08/06/2026 10:18, Patrick Steinhardt wrote:
> On Mon, Jun 08, 2026 at 11:14:08AM +0200, Patrick Steinhardt wrote:
>> On Fri, Jun 05, 2026 at 04:12:42PM +0100, Phillip Wood wrote:
>>> 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
>>
>> Hm. The question is whether it's sensible to have
>> `!RESET_HEAD_UPDATE_HEAD && RESET_HEAD_UPDATE_ORIG_HEAD`. That feels
>> like a somewhat weird request, too, and we'd have to introduce extra
>> logic to make that combination work.

As there are no users at the moment we could make that a BUG() and 
implement it later if required. At least that way we're not introducing 
conflicting flags, we're just not implementing setting a certain 
combination of refs.

>>> 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.
>>
>> That's a good point, the name does get somewhat awkward. I think we
>> should keep "reset" in there, but `reset_worktree()` to me reads as it
>> if was rather related to git-worktree(1) than anything else. Maybe
>> `reset_working_tree()`?
> 
> I think I'll skip these changes for the next iteration for now. The
> patch series has already exploded quite a bit in its scope due to the
> refactorings of `reset_head()`, so I'd prefer to maybe do such changes
> as a follow up.

Renaming the function can certainly wait. I'd quite like to sort out the 
flags though as the new flag is being added in this series.

Thanks

Phillip
> 
> Let me know in case you feel strongly about this though. Thanks!
> 
> Patrick


^ permalink raw reply

* Re: [PATCH RFC 1/2] builtin/history: abort reword on unchanged message
From: Pablo Sabater @ 2026-06-09  9:59 UTC (permalink / raw)
  To: Ben Knoble; +Cc: git, Patrick Steinhardt, Kaartic Sivaraam
In-Reply-To: <9A2F74F1-66D0-4015-B387-35B107ED6F7A@gmail.com>

El lun, 8 jun 2026 a las 18:37, Ben Knoble (<ben.knoble@gmail.com>) escribió:
[snip]
> > +test_expect_success 'aborts if the commit message is the same' '
> > +    test_when_finished "rm -rf repo" &&
> > +    git init repo &&
> > +    (
> > +        cd repo &&
> > +        test_commit first &&
> > +        test_commit second &&
> > +
> > +        git rev-parse HEAD >oid-before &&
> > +        write_script fake-editor.sh <<-\EOF &&
> > +        true
> > +        EOF
> > +        test_set_editor "$(pwd)"/fake-editor.sh &&
> > +        git history reword HEAD 2>err &&
> > +        git rev-parse HEAD >oid-after &&
> > +        test_cmp oid-before oid-after &&
> > +        test_grep "Message unchanged" err
> > +    )
>
> …but I think this test case could do something like "GIT_EDITOR=true git history reword HEAD" and avoid the script?

It does work, thanks.

>
> > +'
> > +
> > test_done
> >
> > --
> > 2.54.0
>
> Best,
> Ben
--
Pablo

^ permalink raw reply

* [PATCH] switch: add --ensure option
From: Lei Zhu via GitGitGadget @ 2026-06-09  9:23 UTC (permalink / raw)
  To: git; +Cc: Lei Zhu, Korov

From: Korov <korov9.c@gmail.com>

Add a new `git switch --ensure` (`-e`) option that behaves like an
idempotent form of branch switching.

Users who often switch between topic branches may not know whether the
local branch already exists. Without this option, they need to check
for the branch first and then choose between `git switch <branch>` and
`git switch -c <branch>`. The new option folds that workflow into a
single command.

When the target branch does not exist, `git switch -e <branch>`
behaves like `git switch -c <branch>`, including existing `--track`
and `--no-track` handling.

When the target branch already exists, `git switch -e <branch>`
switches to it without resetting the branch tip. If `--track` is
given, update the branch's upstream configuration using the explicit
start-point, or the current branch when no start-point is provided.
Fail in detached HEAD state when no start-point is available for
tracking setup.

Document the new option and add tests covering create-branch tracking,
existing-branch tracking updates, and detached-HEAD failure cases.

Signed-off-by: Korov <korov9.c@gmail.com>
---
    switch: add --ensure option
    
    Add a new git switch --ensure (-e) option that behaves like an
    idempotent form of branch switching.
    
    Users who often switch between topic branches may not know whether the
    local branch already exists. Without this option, they need to check for
    the branch first and then choose between git switch <branch> and git
    switch -c <branch>. The new option folds that workflow into a single
    command.
    
    When the target branch does not exist, git switch -e <branch> behaves
    like git switch -c <branch>, including existing --track and --no-track
    handling.
    
    When the target branch already exists, git switch -e <branch> switches
    to it without resetting the branch tip. If --track is given, update the
    branch's upstream configuration using the explicit start-point, or the
    current branch when no start-point is provided. Fail in detached HEAD
    state when no start-point is available for tracking setup.
    
    Document the new option and add tests covering create-branch tracking,
    existing-branch tracking updates, and detached-HEAD failure cases.

Published-As: https://github.com/gitgitgadget/git/releases/tag/pr-git-2324%2FKorov%2Fdev3-v1
Fetch-It-Via: git fetch https://github.com/gitgitgadget/git pr-git-2324/Korov/dev3-v1
Pull-Request: https://github.com/git/git/pull/2324

 Documentation/git-switch.adoc | 16 +++++++
 builtin/checkout.c            | 86 ++++++++++++++++++++++++++++++++++-
 t/t2060-switch.sh             | 46 +++++++++++++++++++
 3 files changed, 146 insertions(+), 2 deletions(-)

diff --git a/Documentation/git-switch.adoc b/Documentation/git-switch.adoc
index d6c4f229a5..a0ac31fa23 100644
--- a/Documentation/git-switch.adoc
+++ b/Documentation/git-switch.adoc
@@ -11,6 +11,7 @@ SYNOPSIS
 git switch [<options>] [--no-guess] <branch>
 git switch [<options>] --detach [<start-point>]
 git switch [<options>] (-c|-C) <new-branch> [<start-point>]
+git switch [<options>] -e <branch> [<start-point>]
 git switch [<options>] --orphan <new-branch>
 
 DESCRIPTION
@@ -81,6 +82,21 @@ $ git branch -f _<new-branch>_
 $ git switch _<new-branch>_
 ------------
 
+`-e <branch>`::
+`--ensure <branch>`::
+	Switch to _<branch>_ if it already exists, or create it from
+	_<start-point>_ before switching to it if it does not.
++
+When _<branch>_ does not already exist, this behaves like
+`git switch -c <branch> [<start-point>]`, including any `--track`
+or `--no-track` options.
++
+When _<branch>_ already exists, the branch tip is not changed. If
+`--track[=(direct|inherit)]` is given, the existing branch's upstream
+configuration is updated using _<start-point>_ when one is provided,
+or the current branch when _<start-point>_ is omitted. This form fails
+when `HEAD` is detached and no _<start-point>_ is given.
+
 `-d`::
 `--detach`::
 	Switch to a commit for inspection and discardable
diff --git a/builtin/checkout.c b/builtin/checkout.c
index b78b3a1d16..f56935bfe2 100644
--- a/builtin/checkout.c
+++ b/builtin/checkout.c
@@ -81,6 +81,8 @@ struct checkout_opts {
 	const char *new_branch;
 	const char *new_branch_force;
 	const char *new_orphan_branch;
+	const char *ensure_branch;
+	const char *ensure_branch_start;
 	int new_branch_log;
 	enum branch_track track;
 	struct diff_options diff_options;
@@ -988,6 +990,15 @@ static void update_refs_for_switch(const struct checkout_opts *opts,
 		free(new_branch_info->refname);
 		new_branch_info->name = xstrdup(opts->new_branch);
 		setup_branch_path(new_branch_info);
+	} else if (opts->ensure_branch && opts->branch_exists &&
+		   opts->track != BRANCH_TRACK_UNSPECIFIED) {
+		const char *tracking_source = opts->ensure_branch_start ?
+			opts->ensure_branch_start :
+			old_branch_info->name;
+		dwim_and_setup_tracking(the_repository, opts->ensure_branch,
+					tracking_source, opts->track,
+					opts->quiet);
+		remote_state_clear(the_repository->remote_state);
 	}
 
 	old_desc = old_branch_info->name;
@@ -1927,6 +1938,52 @@ static int checkout_main(int argc, const char **argv, const char *prefix,
 		die(_("options '-%c', '-%c', and '%s' cannot be used together"),
 			cb_option, toupper(cb_option), "--orphan");
 
+	if (opts->ensure_branch) {
+		struct strbuf ref = STRBUF_INIT;
+		int exists;
+
+		if (opts->new_branch || opts->new_branch_force || opts->new_orphan_branch)
+			die(_("'%s' cannot be used with '%s'"), "-e", "-c/-C/--orphan");
+		if (opts->force_detach)
+			die(_("'%s' cannot be used with '%s'"), "-e", "--detach");
+
+		exists = validate_branchname(opts->ensure_branch, &ref);
+		strbuf_release(&ref);
+
+		/* Save an explicit start point for tracking setup. */
+		if (argc > 0 && opts->track != BRANCH_TRACK_UNSPECIFIED)
+			opts->ensure_branch_start = argv[0];
+
+		if (exists) {
+			/*
+			 * Branch exists: just switch to it, don't reset.
+			 * We'll set up tracking after the switch if --track was given.
+			 */
+			opts->branch_exists = 1;
+		} else {
+			/* Branch doesn't exist: create it like -c */
+			opts->new_branch = opts->ensure_branch;
+		}
+	}
+
+	if (opts->ensure_branch && opts->branch_exists &&
+	    opts->track != BRANCH_TRACK_UNSPECIFIED &&
+	    !opts->ensure_branch_start) {
+		struct object_id head_oid;
+		char *head = refs_resolve_refdup(get_main_ref_store(the_repository),
+						 "HEAD", 0, &head_oid, NULL);
+		const char *branch;
+
+		if (!head)
+			die(_("failed to resolve HEAD as a valid ref"));
+		if (!strcmp(head, "HEAD"))
+			die(_("cannot set up tracking information; starting point '%s' is not a branch"),
+			    "HEAD");
+		if (!skip_prefix(head, "refs/heads/", &branch))
+			die(_("HEAD not found below refs/heads!"));
+		free(head);
+	}
+
 	if (opts->overlay_mode == 1 && opts->patch_mode)
 		die(_("options '%s' and '%s' cannot be used together"), "-p", "--overlay");
 
@@ -1961,8 +2018,9 @@ static int checkout_main(int argc, const char **argv, const char *prefix,
 	if (opts->new_orphan_branch)
 		opts->new_branch = opts->new_orphan_branch;
 
-	/* --track without -c/-C/-b/-B/--orphan should DWIM */
-	if (opts->track != BRANCH_TRACK_UNSPECIFIED && !opts->new_branch) {
+	/* --track without -c/-C/-b/-B/--orphan/-e should DWIM */
+	if (opts->track != BRANCH_TRACK_UNSPECIFIED && !opts->new_branch &&
+	    !(opts->ensure_branch && opts->branch_exists)) {
 		const char *argv0 = argv[0];
 		if (!argc || !strcmp(argv0, "--"))
 			die(_("--track needs a branch name"));
@@ -2012,6 +2070,28 @@ static int checkout_main(int argc, const char **argv, const char *prefix,
 			die(_("reference is not a tree: %s"), opts->from_treeish);
 	}
 
+	/*
+	 * Handle -e with existing branch: set up new_branch_info to switch
+	 * to the existing branch.
+	 */
+	if (opts->ensure_branch && opts->branch_exists) {
+		struct object_id rev;
+
+		branch_info_release(&new_branch_info);
+		memset(&new_branch_info, 0, sizeof(new_branch_info));
+		new_branch_info.name = xstrdup(opts->ensure_branch);
+		setup_branch_path(&new_branch_info);
+
+		if (new_branch_info.path &&
+		    !refs_read_ref(get_main_ref_store(the_repository),
+				   new_branch_info.path, &rev)) {
+			new_branch_info.commit = lookup_commit_reference_gently(
+				the_repository, &rev, 1);
+			if (new_branch_info.commit)
+				parse_commit_or_die(new_branch_info.commit);
+		}
+	}
+
 	if (argc) {
 		parse_pathspec(&opts->pathspec, 0,
 			       opts->patch_mode ? PATHSPEC_PREFIX_ORIGIN : 0,
@@ -2150,6 +2230,8 @@ int cmd_switch(int argc,
 			   N_("create and switch to a new branch")),
 		OPT_STRING('C', "force-create", &opts.new_branch_force, N_("branch"),
 			   N_("create/reset and switch to a branch")),
+		OPT_STRING('e', "ensure", &opts.ensure_branch, N_("branch"),
+			   N_("create if needed and switch to branch")),
 		OPT_BOOL(0, "guess", &opts.dwim_new_local_branch,
 			 N_("second guess 'git switch <no-such-branch>'")),
 		OPT_BOOL(0, "discard-changes", &opts.discard_changes,
diff --git a/t/t2060-switch.sh b/t/t2060-switch.sh
index c91c4db936..c0bff7caab 100755
--- a/t/t2060-switch.sh
+++ b/t/t2060-switch.sh
@@ -146,6 +146,52 @@ test_expect_success 'tracking info copied with autoSetupMerge=inherit' '
 	test_cmp_config "" --default "" branch.main2.merge
 '
 
+test_expect_success 'switch -e --track creates branch from current branch' '
+	test_when_finished "
+		git switch main || :
+		git branch -D ensure-new-current || :
+	" &&
+	git switch main &&
+	git switch -e ensure-new-current --track &&
+	test_cmp_rev refs/heads/main refs/heads/ensure-new-current &&
+	test_cmp_config . branch.ensure-new-current.remote &&
+	test_cmp_config refs/heads/main branch.ensure-new-current.merge
+'
+
+test_expect_success 'switch -e --track creates branch from remote-tracking branch' '
+	test_when_finished "
+		git switch main || :
+		git branch -D ensure-new || :
+	" &&
+	git switch -e ensure-new --track origin/foo &&
+	test_cmp_rev refs/remotes/origin/foo refs/heads/ensure-new &&
+	test_cmp_config origin branch.ensure-new.remote &&
+	test_cmp_config refs/heads/foo branch.ensure-new.merge
+'
+
+test_expect_success 'switch -e --track uses current branch for existing branch' '
+	test_when_finished "
+		git switch main || :
+		git branch -D ensure-existing source-for-track || :
+	" &&
+	git switch -c source-for-track main &&
+	git branch ensure-existing main &&
+	git switch -e ensure-existing --track &&
+	test_cmp_config . branch.ensure-existing.remote &&
+	test_cmp_config refs/heads/source-for-track branch.ensure-existing.merge
+'
+
+test_expect_success 'switch -e --track fails from detached HEAD without start-point' '
+	test_when_finished "
+		git switch main || :
+		git branch -D detached-target || :
+	" &&
+	git branch detached-target main &&
+	git switch --detach main &&
+	test_must_fail git switch -e detached-target --track 2>stderr &&
+	test_grep "cannot set up tracking information; starting point '\''HEAD'\'' is not a branch" stderr
+'
+
 test_expect_success 'switch back when temporarily detached and checked out elsewhere ' '
 	test_when_finished "
 		git worktree remove wt1 ||:

base-commit: 600fe743028cbfb640855f659e9851522214bc0b
-- 
gitgitgadget

^ permalink raw reply related

* [PATCH v2 17/17] odb/source-packed: drop pointer to "files" parent source
From: Patrick Steinhardt @ 2026-06-09  8:51 UTC (permalink / raw)
  To: git; +Cc: Karthik Nayak
In-Reply-To: <20260609-pks-odb-source-packed-v2-0-839089132c8b@pks.im>

Over the last commits we have turned the packfile store into a proper
object database source that can be used as a standalone backend. As
such, it is no longer necessary to have it coupled to the "files" parent
source.

Remove the pointer to the owning "files" source so that the "packed"
source can be used as a standalone entity.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 odb/source-files.c  |  2 +-
 odb/source-packed.c | 27 +++++++++++++--------------
 odb/source-packed.h |  7 ++++---
 packfile.c          |  2 +-
 4 files changed, 19 insertions(+), 19 deletions(-)

diff --git a/odb/source-files.c b/odb/source-files.c
index fa2e18e71b..3bc6419dd7 100644
--- a/odb/source-files.c
+++ b/odb/source-files.c
@@ -269,7 +269,7 @@ struct odb_source_files *odb_source_files_new(struct object_database *odb,
 	CALLOC_ARRAY(files, 1);
 	odb_source_init(&files->base, odb, ODB_SOURCE_FILES, path, local);
 	files->loose = odb_source_loose_new(odb, path, local);
-	files->packed = odb_source_packed_new(files);
+	files->packed = odb_source_packed_new(odb, path, local);
 
 	files->base.free = odb_source_files_free;
 	files->base.close = odb_source_files_close;
diff --git a/odb/source-packed.c b/odb/source-packed.c
index d513b3efc3..42c28fba0e 100644
--- a/odb/source-packed.c
+++ b/odb/source-packed.c
@@ -585,7 +585,7 @@ static void report_pack_garbage(struct string_list *list)
 }
 
 struct prepare_pack_data {
-	struct odb_source *source;
+	struct odb_source_packed *source;
 	struct string_list *garbage;
 };
 
@@ -593,15 +593,14 @@ static void prepare_pack(const char *full_name, size_t full_name_len,
 			 const char *file_name, void *_data)
 {
 	struct prepare_pack_data *data = (struct prepare_pack_data *)_data;
-	struct odb_source_files *files = odb_source_files_downcast(data->source);
 	size_t base_len = full_name_len;
 
 	if (strip_suffix_mem(full_name, &base_len, ".idx") &&
-	    !(files->packed->midx &&
-	      midx_contains_pack(files->packed->midx, file_name))) {
+	    !(data->source->midx &&
+	      midx_contains_pack(data->source->midx, file_name))) {
 		char *trimmed_path = xstrndup(full_name, full_name_len);
-		packfile_store_load_pack(files->packed,
-					 trimmed_path, data->source->local);
+		packfile_store_load_pack(data->source,
+					 trimmed_path, data->source->base.local);
 		free(trimmed_path);
 	}
 
@@ -626,7 +625,7 @@ static void prepare_pack(const char *full_name, size_t full_name_len,
 		report_garbage(PACKDIR_FILE_GARBAGE, full_name);
 }
 
-static void prepare_packed_git_one(struct odb_source *source)
+static void prepare_packed_git_one(struct odb_source_packed *source)
 {
 	struct string_list garbage = STRING_LIST_INIT_DUP;
 	struct prepare_pack_data data = {
@@ -634,7 +633,7 @@ static void prepare_packed_git_one(struct odb_source *source)
 		.garbage = &garbage,
 	};
 
-	for_each_file_in_pack_dir(source->path, prepare_pack, &data);
+	for_each_file_in_pack_dir(source->base.path, prepare_pack, &data);
 
 	report_pack_garbage(data.garbage);
 	string_list_clear(data.garbage, 0);
@@ -675,7 +674,7 @@ void odb_source_packed_prepare(struct odb_source_packed *source)
 		return;
 
 	prepare_multi_pack_index_one(source);
-	prepare_packed_git_one(&source->files->base);
+	prepare_packed_git_one(source);
 
 	sort_packs(&source->packs.head, sort_pack);
 	for (struct packfile_list_entry *e = source->packs.head; e; e = e->next)
@@ -733,14 +732,14 @@ static void odb_source_packed_free(struct odb_source *source)
 	free(packed);
 }
 
-struct odb_source_packed *odb_source_packed_new(struct odb_source_files *parent)
+struct odb_source_packed *odb_source_packed_new(struct object_database *odb,
+						const char *path,
+						bool local)
 {
 	struct odb_source_packed *packed;
 
 	CALLOC_ARRAY(packed, 1);
-	odb_source_init(&packed->base, parent->base.odb, ODB_SOURCE_PACKED,
-			parent->base.path, parent->base.local);
-	packed->files = parent;
+	odb_source_init(&packed->base, odb, ODB_SOURCE_PACKED, path, local);
 	strmap_init(&packed->packs_by_path);
 
 	packed->base.free = odb_source_packed_free;
@@ -758,7 +757,7 @@ struct odb_source_packed *odb_source_packed_new(struct odb_source_files *parent)
 	packed->base.read_alternates = odb_source_packed_read_alternates;
 	packed->base.write_alternate = odb_source_packed_write_alternate;
 
-	if (!is_absolute_path(parent->base.path))
+	if (!is_absolute_path(path))
 		chdir_notify_register(NULL, odb_source_packed_reparent, packed);
 
 	return packed;
diff --git a/odb/source-packed.h b/odb/source-packed.h
index 9d4796261a..88994098c1 100644
--- a/odb/source-packed.h
+++ b/odb/source-packed.h
@@ -10,7 +10,6 @@
  */
 struct odb_source_packed {
 	struct odb_source base;
-	struct odb_source_files *files;
 
 	/*
 	 * The list of packfiles in the order in which they have been most
@@ -66,9 +65,11 @@ struct odb_source_packed {
 
 /*
  * Allocate and initialize a new empty packfile store for the given object
- * database source.
+ * database.
  */
-struct odb_source_packed *odb_source_packed_new(struct odb_source_files *parent);
+struct odb_source_packed *odb_source_packed_new(struct object_database *odb,
+						const char *path,
+						bool local);
 
 /*
  * Cast the given object database source to the packed backend. This will cause
diff --git a/packfile.c b/packfile.c
index a577275d4f..59cee7925d 100644
--- a/packfile.c
+++ b/packfile.c
@@ -801,7 +801,7 @@ struct packed_git *packfile_store_load_pack(struct odb_source_packed *store,
 
 	p = strmap_get(&store->packs_by_path, key.buf);
 	if (!p) {
-		p = add_packed_git(store->files->base.odb->repo, idx_path,
+		p = add_packed_git(store->base.odb->repo, idx_path,
 				   strlen(idx_path), local);
 		if (p)
 			packfile_store_add_pack(store, p);

-- 
2.54.0.1136.gdb2ca164c4.dirty


^ permalink raw reply related

* [PATCH v2 16/17] midx: refactor interfaces to work on "packed" source
From: Patrick Steinhardt @ 2026-06-09  8:51 UTC (permalink / raw)
  To: git; +Cc: Karthik Nayak
In-Reply-To: <20260609-pks-odb-source-packed-v2-0-839089132c8b@pks.im>

Our interfaces used to interact with MIDXs all work on top of the
generic `struct odb_source`. This doesn't make much sense though: a MIDX
is strictly tied to the "packed" source, so passing in a generic source
gives the false sense that it may also work with a different type of
source.

Fix this conceptual weirdness and instead require the caller to pass in
a "packed" source explicitly. This also makes the next commit easier to
implement, where we drop the pointer to the "files" source in the
"packed" source.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 builtin/multi-pack-index.c |  29 +++++------
 builtin/pack-objects.c     |   3 +-
 builtin/repack.c           |   8 ++-
 midx-write.c               |  34 ++++++-------
 midx.c                     | 118 ++++++++++++++++++++++-----------------------
 midx.h                     |  30 ++++++------
 odb/source-packed.c        |  12 ++---
 pack-bitmap.c              |   8 +--
 pack-revindex.c            |   6 +--
 repack-geometry.c          |   3 +-
 repack-midx.c              |   9 ++--
 repack.c                   |   6 +--
 t/helper/test-read-midx.c  |   7 ++-
 13 files changed, 144 insertions(+), 129 deletions(-)

diff --git a/builtin/multi-pack-index.c b/builtin/multi-pack-index.c
index 00ffb36394..6e73c85cde 100644
--- a/builtin/multi-pack-index.c
+++ b/builtin/multi-pack-index.c
@@ -10,6 +10,7 @@
 #include "trace2.h"
 #include "odb.h"
 #include "odb/source.h"
+#include "odb/source-files.h"
 #include "replace-object.h"
 #include "repository.h"
 
@@ -85,12 +86,12 @@ static int parse_object_dir(const struct option *opt, const char *arg,
 	return 0;
 }
 
-static struct odb_source *handle_object_dir_option(struct repository *repo)
+static struct odb_source_files *handle_object_dir_option(struct repository *repo)
 {
 	struct odb_source *source = odb_find_source(repo->objects, opts.object_dir);
 	if (!source)
 		source = odb_add_to_alternates_memory(repo->objects, opts.object_dir);
-	return source;
+	return odb_source_files_downcast(source);
 }
 
 static struct option common_opts[] = {
@@ -167,7 +168,7 @@ static int cmd_multi_pack_index_write(int argc, const char **argv,
 			     N_("refs snapshot for selecting bitmap commits")),
 		OPT_END(),
 	};
-	struct odb_source *source;
+	struct odb_source_files *source;
 	int ret;
 
 	opts.flags |= MIDX_WRITE_BITMAP_HASH_CACHE;
@@ -211,7 +212,7 @@ static int cmd_multi_pack_index_write(int argc, const char **argv,
 
 		read_packs_from_stdin(&packs);
 
-		ret = write_midx_file_only(source, &packs,
+		ret = write_midx_file_only(source->packed, &packs,
 					   opts.preferred_pack,
 					   opts.refs_snapshot,
 					   opts.incremental_base, opts.flags);
@@ -223,7 +224,7 @@ static int cmd_multi_pack_index_write(int argc, const char **argv,
 
 	}
 
-	ret = write_midx_file(source, opts.preferred_pack,
+	ret = write_midx_file(source->packed, opts.preferred_pack,
 			      opts.refs_snapshot, opts.flags);
 
 	free(opts.refs_snapshot);
@@ -237,7 +238,7 @@ static int cmd_multi_pack_index_compact(int argc, const char **argv,
 	struct multi_pack_index *m, *cur;
 	struct multi_pack_index *from_midx = NULL;
 	struct multi_pack_index *to_midx = NULL;
-	struct odb_source *source;
+	struct odb_source_files *source;
 	int ret;
 
 	struct option *options;
@@ -282,7 +283,7 @@ static int cmd_multi_pack_index_compact(int argc, const char **argv,
 
 	FREE_AND_NULL(options);
 
-	m = get_multi_pack_index(source);
+	m = get_multi_pack_index(source->packed);
 
 	for (cur = m; cur && !(from_midx && to_midx); cur = cur->base_midx) {
 		const char *midx_csum = midx_get_checksum_hex(cur);
@@ -305,7 +306,7 @@ static int cmd_multi_pack_index_compact(int argc, const char **argv,
 			die(_("MIDX %s must be an ancestor of %s"), argv[0], argv[1]);
 	}
 
-	ret = write_midx_file_compact(source, from_midx, to_midx,
+	ret = write_midx_file_compact(source->packed, from_midx, to_midx,
 				      opts.incremental_base, opts.flags);
 
 	return ret;
@@ -319,7 +320,7 @@ static int cmd_multi_pack_index_verify(int argc, const char **argv,
 	static struct option builtin_multi_pack_index_verify_options[] = {
 		OPT_END(),
 	};
-	struct odb_source *source;
+	struct odb_source_files *source;
 
 	options = add_common_options(builtin_multi_pack_index_verify_options);
 
@@ -337,7 +338,7 @@ static int cmd_multi_pack_index_verify(int argc, const char **argv,
 
 	FREE_AND_NULL(options);
 
-	return verify_midx_file(source, opts.flags);
+	return verify_midx_file(source->packed, opts.flags);
 }
 
 static int cmd_multi_pack_index_expire(int argc, const char **argv,
@@ -348,7 +349,7 @@ static int cmd_multi_pack_index_expire(int argc, const char **argv,
 	static struct option builtin_multi_pack_index_expire_options[] = {
 		OPT_END(),
 	};
-	struct odb_source *source;
+	struct odb_source_files *source;
 
 	options = add_common_options(builtin_multi_pack_index_expire_options);
 
@@ -366,7 +367,7 @@ static int cmd_multi_pack_index_expire(int argc, const char **argv,
 
 	FREE_AND_NULL(options);
 
-	return expire_midx_packs(source, opts.flags);
+	return expire_midx_packs(source->packed, opts.flags);
 }
 
 static int cmd_multi_pack_index_repack(int argc, const char **argv,
@@ -379,7 +380,7 @@ static int cmd_multi_pack_index_repack(int argc, const char **argv,
 		  N_("during repack, collect pack-files of smaller size into a batch that is larger than this size")),
 		OPT_END(),
 	};
-	struct odb_source *source;
+	struct odb_source_files *source;
 
 	options = add_common_options(builtin_multi_pack_index_repack_options);
 
@@ -398,7 +399,7 @@ static int cmd_multi_pack_index_repack(int argc, const char **argv,
 
 	FREE_AND_NULL(options);
 
-	return midx_repack(source, (size_t)opts.batch_size, opts.flags);
+	return midx_repack(source->packed, (size_t)opts.batch_size, opts.flags);
 }
 
 int cmd_multi_pack_index(int argc,
diff --git a/builtin/pack-objects.c b/builtin/pack-objects.c
index 5e94805478..424c92cc29 100644
--- a/builtin/pack-objects.c
+++ b/builtin/pack-objects.c
@@ -1775,7 +1775,8 @@ static int want_object_in_pack_mtime(const struct object_id *oid,
 	odb_prepare_alternates(the_repository->objects);
 
 	for (source = the_repository->objects->sources; source; source = source->next) {
-		struct multi_pack_index *m = get_multi_pack_index(source);
+		struct odb_source_files *files = odb_source_files_downcast(source);
+		struct multi_pack_index *m = get_multi_pack_index(files->packed);
 		struct pack_entry e;
 
 		if (m && fill_midx_entry(m, oid, &e)) {
diff --git a/builtin/repack.c b/builtin/repack.c
index 1524a9c13a..47966a686b 100644
--- a/builtin/repack.c
+++ b/builtin/repack.c
@@ -458,6 +458,8 @@ int cmd_repack(int argc,
 	}
 
 	if (!names.nr) {
+		struct odb_source_files *files = odb_source_files_downcast(existing.source);
+
 		if (!po_args.quiet)
 			printf_ln(_("Nothing new to pack."));
 		/*
@@ -473,7 +475,7 @@ int cmd_repack(int argc,
 		 * midx_has_unknown_packs() will make the decision for
 		 * us.
 		 */
-		if (!get_multi_pack_index(existing.source))
+		if (!get_multi_pack_index(files->packed))
 			midx_must_contain_cruft = 1;
 	}
 
@@ -626,10 +628,12 @@ int cmd_repack(int argc,
 		update_server_info(repo, 0);
 
 	if (git_env_bool(GIT_TEST_MULTI_PACK_INDEX, 0)) {
+		struct odb_source_files *files = odb_source_files_downcast(existing.source);
 		unsigned flags = 0;
+
 		if (git_env_bool(GIT_TEST_MULTI_PACK_INDEX_WRITE_INCREMENTAL, 0))
 			flags |= MIDX_WRITE_INCREMENTAL;
-		write_midx_file(existing.source, NULL, NULL, flags);
+		write_midx_file(files->packed, NULL, NULL, flags);
 	}
 
 cleanup:
diff --git a/midx-write.c b/midx-write.c
index 561e9eedc0..7cafc49fb8 100644
--- a/midx-write.c
+++ b/midx-write.c
@@ -25,9 +25,9 @@
 #define NO_PREFERRED_PACK (~((uint32_t)0))
 
 extern int midx_checksum_valid(struct multi_pack_index *m);
-extern void clear_midx_files_ext(struct odb_source *source, const char *ext,
+extern void clear_midx_files_ext(struct odb_source_packed *source, const char *ext,
 				 const char *keep_hash);
-extern void clear_incremental_midx_files_ext(struct odb_source *source,
+extern void clear_incremental_midx_files_ext(struct odb_source_packed *source,
 					     const char *ext,
 					     const struct strvec *keep_hashes);
 extern int cmp_idx_or_pack_name(const char *idx_or_pack_name,
@@ -119,7 +119,7 @@ struct write_midx_context {
 	struct string_list *to_include;
 
 	struct repository *repo;
-	struct odb_source *source;
+	struct odb_source_packed *source;
 };
 
 static uint32_t midx_pack_perm(struct write_midx_context *ctx,
@@ -1107,7 +1107,7 @@ static int link_midx_to_chain(struct multi_pack_index *m)
 	return ret;
 }
 
-static void clear_midx_files(struct odb_source *source,
+static void clear_midx_files(struct odb_source_packed *source,
 			     const struct strvec *hashes, unsigned incremental)
 {
 	/*
@@ -1237,7 +1237,7 @@ static int midx_hashcmp(const struct multi_pack_index *a,
 }
 
 struct write_midx_opts {
-	struct odb_source *source; /* non-optional */
+	struct odb_source_packed *source; /* non-optional */
 
 	struct string_list *packs_to_include;
 	struct string_list *packs_to_drop;
@@ -1253,7 +1253,7 @@ struct write_midx_opts {
 
 static int write_midx_internal(struct write_midx_opts *opts)
 {
-	struct repository *r = opts->source->odb->repo;
+	struct repository *r = opts->source->base.odb->repo;
 	struct strbuf midx_name = STRBUF_INIT;
 	unsigned char midx_hash[GIT_MAX_RAWSZ];
 	uint32_t start_pack;
@@ -1301,7 +1301,7 @@ static int write_midx_internal(struct write_midx_opts *opts)
 	if (ctx.incremental)
 		strbuf_addf(&midx_name,
 			    "%s/pack/multi-pack-index.d/tmp_midx_XXXXXX",
-			    opts->source->path);
+			    opts->source->base.path);
 	else
 		get_midx_filename(opts->source, &midx_name);
 	if (safe_create_leading_directories(r, midx_name.buf))
@@ -1396,7 +1396,7 @@ static int write_midx_internal(struct write_midx_opts *opts)
 		fill_packs_from_midx_range(&ctx, bitmap_order);
 	} else {
 		ctx.to_include = opts->packs_to_include;
-		for_each_file_in_pack_dir(opts->source->path, add_pack_to_midx, &ctx);
+		for_each_file_in_pack_dir(opts->source->base.path, add_pack_to_midx, &ctx);
 	}
 	stop_progress(&ctx.progress);
 
@@ -1847,7 +1847,7 @@ static int write_midx_internal(struct write_midx_opts *opts)
 	return result;
 }
 
-int write_midx_file(struct odb_source *source,
+int write_midx_file(struct odb_source_packed *source,
 		    const char *preferred_pack_name,
 		    const char *refs_snapshot,
 		    unsigned flags)
@@ -1862,7 +1862,7 @@ int write_midx_file(struct odb_source *source,
 	return write_midx_internal(&opts);
 }
 
-int write_midx_file_only(struct odb_source *source,
+int write_midx_file_only(struct odb_source_packed *source,
 			 struct string_list *packs_to_include,
 			 const char *preferred_pack_name,
 			 const char *refs_snapshot,
@@ -1881,7 +1881,7 @@ int write_midx_file_only(struct odb_source *source,
 	return write_midx_internal(&opts);
 }
 
-int write_midx_file_compact(struct odb_source *source,
+int write_midx_file_compact(struct odb_source_packed *source,
 			    struct multi_pack_index *from,
 			    struct multi_pack_index *to,
 			    const char *incremental_base,
@@ -1898,7 +1898,7 @@ int write_midx_file_compact(struct odb_source *source,
 	return write_midx_internal(&opts);
 }
 
-int expire_midx_packs(struct odb_source *source, unsigned flags)
+int expire_midx_packs(struct odb_source_packed *source, unsigned flags)
 {
 	uint32_t i, *count, result = 0;
 	struct string_list packs_to_drop = STRING_LIST_INIT_DUP;
@@ -1915,7 +1915,7 @@ int expire_midx_packs(struct odb_source *source, unsigned flags)
 
 	if (flags & MIDX_PROGRESS)
 		progress = start_delayed_progress(
-					  source->odb->repo,
+					  source->base.odb->repo,
 					  _("Counting referenced objects"),
 					  m->num_objects);
 	for (i = 0; i < m->num_objects; i++) {
@@ -1927,7 +1927,7 @@ int expire_midx_packs(struct odb_source *source, unsigned flags)
 
 	if (flags & MIDX_PROGRESS)
 		progress = start_delayed_progress(
-					  source->odb->repo,
+					  source->base.odb->repo,
 					  _("Finding and deleting unreferenced packfiles"),
 					  m->num_packs);
 	for (i = 0; i < m->num_packs; i++) {
@@ -2085,9 +2085,9 @@ static void fill_included_packs_batch(struct repository *r,
 	free(pack_info);
 }
 
-int midx_repack(struct odb_source *source, size_t batch_size, unsigned flags)
+int midx_repack(struct odb_source_packed *source, size_t batch_size, unsigned flags)
 {
-	struct repository *r = source->odb->repo;
+	struct repository *r = source->base.odb->repo;
 	int result = 0;
 	uint32_t i, packs_to_repack = 0;
 	unsigned char *include_pack;
@@ -2131,7 +2131,7 @@ int midx_repack(struct odb_source *source, size_t batch_size, unsigned flags)
 
 	strvec_push(&cmd.args, "pack-objects");
 
-	strvec_pushf(&cmd.args, "%s/pack/pack", source->path);
+	strvec_pushf(&cmd.args, "%s/pack/pack", source->base.path);
 
 	if (delta_base_offset)
 		strvec_push(&cmd.args, "--delta-base-offset");
diff --git a/midx.c b/midx.c
index 00bbd137b2..cc6b94f9dd 100644
--- a/midx.c
+++ b/midx.c
@@ -17,9 +17,9 @@
 #define MIDX_PACK_ERROR ((void *)(intptr_t)-1)
 
 int midx_checksum_valid(struct multi_pack_index *m);
-void clear_midx_files_ext(struct odb_source *source, const char *ext,
+void clear_midx_files_ext(struct odb_source_packed *source, const char *ext,
 			  const char *keep_hash);
-void clear_incremental_midx_files_ext(struct odb_source *source, const char *ext,
+void clear_incremental_midx_files_ext(struct odb_source_packed *source, const char *ext,
 				      const struct strvec *keep_hashes);
 int cmp_idx_or_pack_name(const char *idx_or_pack_name,
 			 const char *idx_name);
@@ -27,25 +27,25 @@ int cmp_idx_or_pack_name(const char *idx_or_pack_name,
 const char *midx_get_checksum_hex(const struct multi_pack_index *m)
 {
 	return hash_to_hex_algop(midx_get_checksum_hash(m),
-				 m->source->odb->repo->hash_algo);
+				 m->source->base.odb->repo->hash_algo);
 }
 
 const unsigned char *midx_get_checksum_hash(const struct multi_pack_index *m)
 {
-	return m->data + m->data_len - m->source->odb->repo->hash_algo->rawsz;
+	return m->data + m->data_len - m->source->base.odb->repo->hash_algo->rawsz;
 }
 
-void get_midx_filename(struct odb_source *source, struct strbuf *out)
+void get_midx_filename(struct odb_source_packed *source, struct strbuf *out)
 {
 	get_midx_filename_ext(source, out, NULL, NULL);
 }
 
-void get_midx_filename_ext(struct odb_source *source, struct strbuf *out,
+void get_midx_filename_ext(struct odb_source_packed *source, struct strbuf *out,
 			   const unsigned char *hash, const char *ext)
 {
-	strbuf_addf(out, "%s/pack/multi-pack-index", source->path);
+	strbuf_addf(out, "%s/pack/multi-pack-index", source->base.path);
 	if (ext)
-		strbuf_addf(out, "-%s.%s", hash_to_hex_algop(hash, source->odb->repo->hash_algo), ext);
+		strbuf_addf(out, "-%s.%s", hash_to_hex_algop(hash, source->base.odb->repo->hash_algo), ext);
 }
 
 static int midx_read_oid_fanout(const unsigned char *chunk_start,
@@ -99,17 +99,16 @@ static int midx_read_object_offsets(const unsigned char *chunk_start,
 	return 0;
 }
 
-struct multi_pack_index *get_multi_pack_index(struct odb_source *source)
+struct multi_pack_index *get_multi_pack_index(struct odb_source_packed *source)
 {
-	struct odb_source_files *files = odb_source_files_downcast(source);
-	odb_source_packed_prepare(files->packed);
-	return files->packed->midx;
+	odb_source_packed_prepare(source);
+	return source->midx;
 }
 
-static struct multi_pack_index *load_multi_pack_index_one(struct odb_source *source,
+static struct multi_pack_index *load_multi_pack_index_one(struct odb_source_packed *source,
 							  const char *midx_name)
 {
-	struct repository *r = source->odb->repo;
+	struct repository *r = source->base.odb->repo;
 	struct multi_pack_index *m = NULL;
 	int fd;
 	struct stat st;
@@ -234,23 +233,23 @@ static struct multi_pack_index *load_multi_pack_index_one(struct odb_source *sou
 	return NULL;
 }
 
-void get_midx_chain_dirname(struct odb_source *source, struct strbuf *buf)
+void get_midx_chain_dirname(struct odb_source_packed *source, struct strbuf *buf)
 {
-	strbuf_addf(buf, "%s/pack/multi-pack-index.d", source->path);
+	strbuf_addf(buf, "%s/pack/multi-pack-index.d", source->base.path);
 }
 
-void get_midx_chain_filename(struct odb_source *source, struct strbuf *buf)
+void get_midx_chain_filename(struct odb_source_packed *source, struct strbuf *buf)
 {
 	get_midx_chain_dirname(source, buf);
 	strbuf_addstr(buf, "/multi-pack-index-chain");
 }
 
-void get_split_midx_filename_ext(struct odb_source *source, struct strbuf *buf,
+void get_split_midx_filename_ext(struct odb_source_packed *source, struct strbuf *buf,
 				 const unsigned char *hash, const char *ext)
 {
 	get_midx_chain_dirname(source, buf);
 	strbuf_addf(buf, "/multi-pack-index-%s.%s",
-		    hash_to_hex_algop(hash, source->odb->repo->hash_algo), ext);
+		    hash_to_hex_algop(hash, source->base.odb->repo->hash_algo), ext);
 }
 
 static int open_multi_pack_index_chain(const struct git_hash_algo *hash_algo,
@@ -306,11 +305,11 @@ static int add_midx_to_chain(struct multi_pack_index *midx,
 	return 1;
 }
 
-static struct multi_pack_index *load_midx_chain_fd_st(struct odb_source *source,
+static struct multi_pack_index *load_midx_chain_fd_st(struct odb_source_packed *source,
 						      int fd, struct stat *st,
 						      int *incomplete_chain)
 {
-	const struct git_hash_algo *hash_algo = source->odb->repo->hash_algo;
+	const struct git_hash_algo *hash_algo = source->base.odb->repo->hash_algo;
 	struct multi_pack_index *midx_chain = NULL;
 	struct strbuf buf = STRBUF_INIT;
 	int valid = 1;
@@ -362,7 +361,7 @@ static struct multi_pack_index *load_midx_chain_fd_st(struct odb_source *source,
 	return midx_chain;
 }
 
-static struct multi_pack_index *load_multi_pack_index_chain(struct odb_source *source)
+static struct multi_pack_index *load_multi_pack_index_chain(struct odb_source_packed *source)
 {
 	struct strbuf chain_file = STRBUF_INIT;
 	struct stat st;
@@ -370,7 +369,8 @@ static struct multi_pack_index *load_multi_pack_index_chain(struct odb_source *s
 	struct multi_pack_index *m = NULL;
 
 	get_midx_chain_filename(source, &chain_file);
-	if (open_multi_pack_index_chain(source->odb->repo->hash_algo, chain_file.buf, &fd, &st)) {
+	if (open_multi_pack_index_chain(source->base.odb->repo->hash_algo,
+					chain_file.buf, &fd, &st)) {
 		int incomplete;
 		/* ownership of fd is taken over by load function */
 		m = load_midx_chain_fd_st(source, fd, &st, &incomplete);
@@ -380,7 +380,7 @@ static struct multi_pack_index *load_multi_pack_index_chain(struct odb_source *s
 	return m;
 }
 
-struct multi_pack_index *load_multi_pack_index(struct odb_source *source)
+struct multi_pack_index *load_multi_pack_index(struct odb_source_packed *source)
 {
 	struct strbuf midx_name = STRBUF_INIT;
 	struct multi_pack_index *m;
@@ -456,7 +456,7 @@ static uint32_t midx_for_pack(struct multi_pack_index **_m,
 int prepare_midx_pack(struct multi_pack_index *m,
 		      uint32_t pack_int_id)
 {
-	struct odb_source_files *files = odb_source_files_downcast(m->source);
+	struct odb_source_packed *packed = m->source;
 	struct strbuf pack_name = STRBUF_INIT;
 	struct packed_git *p;
 
@@ -467,10 +467,10 @@ int prepare_midx_pack(struct multi_pack_index *m,
 	if (m->packs[pack_int_id])
 		return 0;
 
-	strbuf_addf(&pack_name, "%s/pack/%s", files->base.path,
+	strbuf_addf(&pack_name, "%s/pack/%s", packed->base.path,
 		    m->pack_names[pack_int_id]);
-	p = packfile_store_load_pack(files->packed,
-				     pack_name.buf, files->base.local);
+	p = packfile_store_load_pack(packed,
+				     pack_name.buf, packed->base.local);
 	strbuf_release(&pack_name);
 
 	if (!p) {
@@ -523,7 +523,7 @@ int bsearch_one_midx(const struct object_id *oid, struct multi_pack_index *m,
 {
 	int ret = bsearch_hash(oid->hash, m->chunk_oid_fanout,
 			       m->chunk_oid_lookup,
-			       m->source->odb->repo->hash_algo->rawsz,
+			       m->source->base.odb->repo->hash_algo->rawsz,
 			       result);
 	if (result)
 		*result += m->num_objects_in_base;
@@ -554,7 +554,7 @@ struct object_id *nth_midxed_object_oid(struct object_id *oid,
 	n = midx_for_object(&m, n);
 
 	oidread(oid, m->chunk_oid_lookup + st_mult(m->hash_len, n),
-		m->source->odb->repo->hash_algo);
+		m->source->base.odb->repo->hash_algo);
 	return oid;
 }
 
@@ -734,26 +734,25 @@ int midx_preferred_pack(struct multi_pack_index *m, uint32_t *pack_int_id)
 	return 0;
 }
 
-int prepare_multi_pack_index_one(struct odb_source *source)
+int prepare_multi_pack_index_one(struct odb_source_packed *source)
 {
-	struct odb_source_files *files = odb_source_files_downcast(source);
-	struct repository *r = source->odb->repo;
+	struct repository *r = source->base.odb->repo;
 
 	prepare_repo_settings(r);
 	if (!r->settings.core_multi_pack_index)
 		return 0;
 
-	if (files->packed->midx)
+	if (source->midx)
 		return 1;
 
-	files->packed->midx = load_multi_pack_index(source);
+	source->midx = load_multi_pack_index(source);
 
-	return !!files->packed->midx;
+	return !!source->midx;
 }
 
 int midx_checksum_valid(struct multi_pack_index *m)
 {
-	return hashfile_checksum_valid(m->source->odb->repo->hash_algo,
+	return hashfile_checksum_valid(m->source->base.odb->repo->hash_algo,
 				       m->data, m->data_len);
 }
 
@@ -776,7 +775,7 @@ static void clear_midx_file_ext(const char *full_path, size_t full_path_len UNUS
 		die_errno(_("failed to remove %s"), full_path);
 }
 
-void clear_midx_files_ext(struct odb_source *source, const char *ext,
+void clear_midx_files_ext(struct odb_source_packed *source, const char *ext,
 			  const char *keep_hash)
 {
 	struct clear_midx_data data = {
@@ -793,12 +792,12 @@ void clear_midx_files_ext(struct odb_source *source, const char *ext,
 		strbuf_release(&buf);
 	}
 
-	for_each_file_in_pack_dir(source->path, clear_midx_file_ext, &data);
+	for_each_file_in_pack_dir(source->base.path, clear_midx_file_ext, &data);
 
 	strset_clear(&data.keep);
 }
 
-void clear_incremental_midx_files_ext(struct odb_source *source, const char *ext,
+void clear_incremental_midx_files_ext(struct odb_source_packed *source, const char *ext,
 				      const struct strvec *keep_hashes)
 {
 	struct clear_midx_data data = {
@@ -817,7 +816,7 @@ void clear_incremental_midx_files_ext(struct odb_source *source, const char *ext
 		}
 	}
 
-	for_each_file_in_pack_subdir(source->path, "multi-pack-index.d",
+	for_each_file_in_pack_subdir(source->base.path, "multi-pack-index.d",
 				     clear_midx_file_ext, &data);
 
 	strbuf_release(&buf);
@@ -826,26 +825,28 @@ void clear_incremental_midx_files_ext(struct odb_source *source, const char *ext
 
 void clear_midx_file(struct repository *r)
 {
+	struct odb_source_files *files;
 	struct strbuf midx = STRBUF_INIT;
 
-	get_midx_filename(r->objects->sources, &midx);
-
 	if (r->objects) {
 		struct odb_source *source;
 
 		for (source = r->objects->sources; source; source = source->next) {
-			struct odb_source_files *files = odb_source_files_downcast(source);
+			files = odb_source_files_downcast(source);
 			if (files->packed->midx)
 				close_midx(files->packed->midx);
 			files->packed->midx = NULL;
 		}
 	}
 
+	files = odb_source_files_downcast(r->objects->sources);
+	get_midx_filename(files->packed, &midx);
+
 	if (remove_path(midx.buf))
 		die(_("failed to clear multi-pack-index at %s"), midx.buf);
 
-	clear_midx_files_ext(r->objects->sources, MIDX_EXT_BITMAP, NULL);
-	clear_midx_files_ext(r->objects->sources, MIDX_EXT_REV, NULL);
+	clear_midx_files_ext(files->packed, MIDX_EXT_BITMAP, NULL);
+	clear_midx_files_ext(files->packed, MIDX_EXT_REV, NULL);
 
 	strbuf_release(&midx);
 }
@@ -853,28 +854,27 @@ void clear_midx_file(struct repository *r)
 void clear_incremental_midx_files(struct repository *r,
 				  const struct strvec *keep_hashes)
 {
-	struct odb_source *source = r->objects->sources;
+	struct odb_source_files *files;
+	struct odb_source *source;
 	struct strbuf chain = STRBUF_INIT;
 
-	get_midx_chain_filename(source, &chain);
-
-	for (; source; source = source->next) {
-		struct odb_source_files *files = odb_source_files_downcast(source);
+	for (source = r->objects->sources; source; source = source->next) {
+		files = odb_source_files_downcast(source);
 		if (files->packed->midx)
 			close_midx(files->packed->midx);
 		files->packed->midx = NULL;
 	}
 
+	files = odb_source_files_downcast(r->objects->sources);
+	get_midx_chain_filename(files->packed, &chain);
+
 	if (!keep_hashes && remove_path(chain.buf))
 		die(_("failed to clear multi-pack-index chain at %s"),
 		    chain.buf);
 
-	clear_incremental_midx_files_ext(r->objects->sources, MIDX_EXT_BITMAP,
-					 keep_hashes);
-	clear_incremental_midx_files_ext(r->objects->sources, MIDX_EXT_REV,
-					 keep_hashes);
-	clear_incremental_midx_files_ext(r->objects->sources, MIDX_EXT_MIDX,
-					 keep_hashes);
+	clear_incremental_midx_files_ext(files->packed, MIDX_EXT_BITMAP, keep_hashes);
+	clear_incremental_midx_files_ext(files->packed, MIDX_EXT_REV, keep_hashes);
+	clear_incremental_midx_files_ext(files->packed, MIDX_EXT_MIDX, keep_hashes);
 
 	strbuf_release(&chain);
 }
@@ -918,9 +918,9 @@ static int compare_pair_pos_vs_id(const void *_a, const void *_b)
 			display_progress(progress, _n); \
 	} while (0)
 
-int verify_midx_file(struct odb_source *source, unsigned flags)
+int verify_midx_file(struct odb_source_packed *source, unsigned flags)
 {
-	struct repository *r = source->odb->repo;
+	struct repository *r = source->base.odb->repo;
 	struct pair_pos_vs_id *pairs = NULL;
 	uint32_t i;
 	struct progress *progress = NULL;
diff --git a/midx.h b/midx.h
index 63853a03a4..939c18e588 100644
--- a/midx.h
+++ b/midx.h
@@ -37,7 +37,7 @@ struct strvec;
 	"GIT_TEST_MULTI_PACK_INDEX_WRITE_INCREMENTAL"
 
 struct multi_pack_index {
-	struct odb_source *source;
+	struct odb_source_packed *source;
 
 	const unsigned char *data;
 	size_t data_len;
@@ -92,16 +92,16 @@ struct multi_pack_index {
 
 const char *midx_get_checksum_hex(const struct multi_pack_index *m) /* static buffer */;
 const unsigned char *midx_get_checksum_hash(const struct multi_pack_index *m);
-void get_midx_filename(struct odb_source *source, struct strbuf *out);
-void get_midx_filename_ext(struct odb_source *source, struct strbuf *out,
+void get_midx_filename(struct odb_source_packed *source, struct strbuf *out);
+void get_midx_filename_ext(struct odb_source_packed *source, struct strbuf *out,
 			   const unsigned char *hash, const char *ext);
-void get_midx_chain_dirname(struct odb_source *source, struct strbuf *out);
-void get_midx_chain_filename(struct odb_source *source, struct strbuf *out);
-void get_split_midx_filename_ext(struct odb_source *source, struct strbuf *buf,
+void get_midx_chain_dirname(struct odb_source_packed *source, struct strbuf *out);
+void get_midx_chain_filename(struct odb_source_packed *source, struct strbuf *out);
+void get_split_midx_filename_ext(struct odb_source_packed *source, struct strbuf *buf,
 				 const unsigned char *hash, const char *ext);
 
-struct multi_pack_index *get_multi_pack_index(struct odb_source *source);
-struct multi_pack_index *load_multi_pack_index(struct odb_source *source);
+struct multi_pack_index *get_multi_pack_index(struct odb_source_packed *source);
+struct multi_pack_index *load_multi_pack_index(struct odb_source_packed *source);
 int prepare_midx_pack(struct multi_pack_index *m, uint32_t pack_int_id);
 struct packed_git *nth_midxed_pack(struct multi_pack_index *m,
 				   uint32_t pack_int_id);
@@ -123,22 +123,22 @@ int midx_contains_pack(struct multi_pack_index *m,
 int midx_layer_contains_pack(struct multi_pack_index *m,
 			     const char *idx_or_pack_name);
 int midx_preferred_pack(struct multi_pack_index *m, uint32_t *pack_int_id);
-int prepare_multi_pack_index_one(struct odb_source *source);
+int prepare_multi_pack_index_one(struct odb_source_packed *source);
 
 /*
  * Variant of write_midx_file which writes a MIDX containing only the packs
  * specified in packs_to_include.
  */
-int write_midx_file(struct odb_source *source,
+int write_midx_file(struct odb_source_packed *source,
 		    const char *preferred_pack_name, const char *refs_snapshot,
 		    unsigned flags);
-int write_midx_file_only(struct odb_source *source,
+int write_midx_file_only(struct odb_source_packed *source,
 			 struct string_list *packs_to_include,
 			 const char *preferred_pack_name,
 			 const char *refs_snapshot,
 			 const char *incremental_base,
 			 unsigned flags);
-int write_midx_file_compact(struct odb_source *source,
+int write_midx_file_compact(struct odb_source_packed *source,
 			    struct multi_pack_index *from,
 			    struct multi_pack_index *to,
 			    const char *incremental_base,
@@ -146,9 +146,9 @@ int write_midx_file_compact(struct odb_source *source,
 void clear_midx_file(struct repository *r);
 void clear_incremental_midx_files(struct repository *r,
 				  const struct strvec *keep_hashes);
-int verify_midx_file(struct odb_source *source, unsigned flags);
-int expire_midx_packs(struct odb_source *source, unsigned flags);
-int midx_repack(struct odb_source *source, size_t batch_size, unsigned flags);
+int verify_midx_file(struct odb_source_packed *source, unsigned flags);
+int expire_midx_packs(struct odb_source_packed *source, unsigned flags);
+int midx_repack(struct odb_source_packed *source, size_t batch_size, unsigned flags);
 
 void close_midx(struct multi_pack_index *m);
 
diff --git a/odb/source-packed.c b/odb/source-packed.c
index 08a2de9bc5..d513b3efc3 100644
--- a/odb/source-packed.c
+++ b/odb/source-packed.c
@@ -136,8 +136,8 @@ static int for_each_prefixed_object_in_midx(
 
 	for (; m; m = m->base_midx) {
 		uint32_t num, i, first = 0;
-		int len = opts->prefix_hex_len > m->source->odb->repo->hash_algo->hexsz ?
-			m->source->odb->repo->hash_algo->hexsz : opts->prefix_hex_len;
+		int len = opts->prefix_hex_len > m->source->base.odb->repo->hash_algo->hexsz ?
+			m->source->base.odb->repo->hash_algo->hexsz : opts->prefix_hex_len;
 
 		if (!m->num_objects)
 			continue;
@@ -249,7 +249,7 @@ static int odb_source_packed_for_each_prefixed_object(
 
 	store->skip_mru_updates = true;
 
-	m = get_multi_pack_index(&store->files->base);
+	m = get_multi_pack_index(store);
 	if (m) {
 		ret = for_each_prefixed_object_in_midx(store, m, opts, data);
 		if (ret)
@@ -348,7 +348,7 @@ static int odb_source_packed_count_objects(struct odb_source *source,
 	unsigned long count = 0;
 	int ret;
 
-	m = get_multi_pack_index(&packed->files->base);
+	m = get_multi_pack_index(packed);
 	if (m)
 		count += m->num_objects + m->num_objects_in_base;
 
@@ -465,7 +465,7 @@ static int odb_source_packed_find_abbrev_len(struct odb_source *source,
 	struct packfile_list_entry *e;
 	struct multi_pack_index *m;
 
-	m = get_multi_pack_index(&packed->files->base);
+	m = get_multi_pack_index(packed);
 	if (m)
 		find_abbrev_len_for_midx(m, oid, min_len, &min_len);
 
@@ -674,7 +674,7 @@ void odb_source_packed_prepare(struct odb_source_packed *source)
 	if (source->initialized)
 		return;
 
-	prepare_multi_pack_index_one(&source->files->base);
+	prepare_multi_pack_index_one(source);
 	prepare_packed_git_one(&source->files->base);
 
 	sort_packs(&source->packs.head, sort_pack);
diff --git a/pack-bitmap.c b/pack-bitmap.c
index f9af8a96bd..6bfcbc8ce6 100644
--- a/pack-bitmap.c
+++ b/pack-bitmap.c
@@ -238,7 +238,7 @@ static uint32_t bitmap_name_hash(struct bitmap_index *index, uint32_t pos)
 static struct repository *bitmap_repo(struct bitmap_index *bitmap_git)
 {
 	if (bitmap_is_midx(bitmap_git))
-		return bitmap_git->midx->source->odb->repo;
+		return bitmap_git->midx->source->base.odb->repo;
 	return bitmap_git->pack->repo;
 }
 
@@ -711,7 +711,8 @@ static int open_midx_bitmap(struct repository *r,
 
 	odb_prepare_alternates(r->objects);
 	for (source = r->objects->sources; source; source = source->next) {
-		struct multi_pack_index *midx = get_multi_pack_index(source);
+		struct odb_source_files *files = odb_source_files_downcast(source);
+		struct multi_pack_index *midx = get_multi_pack_index(files->packed);
 		if (midx && !open_midx_bitmap_1(bitmap_git, midx))
 			ret = 0;
 	}
@@ -3399,7 +3400,8 @@ int verify_bitmap_files(struct repository *r)
 
 	odb_prepare_alternates(r->objects);
 	for (source = r->objects->sources; source; source = source->next) {
-		struct multi_pack_index *m = get_multi_pack_index(source);
+		struct odb_source_files *files = odb_source_files_downcast(source);
+		struct multi_pack_index *m = get_multi_pack_index(files->packed);
 		char *midx_bitmap_name;
 
 		if (!m)
diff --git a/pack-revindex.c b/pack-revindex.c
index 1b67863606..62387ae632 100644
--- a/pack-revindex.c
+++ b/pack-revindex.c
@@ -383,13 +383,13 @@ int load_midx_revindex(struct multi_pack_index *m)
 		 * not want to accidentally call munmap() in the middle of the
 		 * MIDX.
 		 */
-		trace2_data_string("load_midx_revindex", m->source->odb->repo,
+		trace2_data_string("load_midx_revindex", m->source->base.odb->repo,
 				   "source", "midx");
 		m->revindex_data = (const uint32_t *)m->chunk_revindex;
 		return 0;
 	}
 
-	trace2_data_string("load_midx_revindex", m->source->odb->repo,
+	trace2_data_string("load_midx_revindex", m->source->base.odb->repo,
 			   "source", "rev");
 
 	if (m->has_chain)
@@ -401,7 +401,7 @@ int load_midx_revindex(struct multi_pack_index *m)
 				      midx_get_checksum_hash(m),
 				      MIDX_EXT_REV);
 
-	ret = load_revindex_from_disk(m->source->odb->repo->hash_algo,
+	ret = load_revindex_from_disk(m->source->base.odb->repo->hash_algo,
 				      revindex_name.buf,
 				      m->num_objects,
 				      &m->revindex_map,
diff --git a/repack-geometry.c b/repack-geometry.c
index 2064683dcf..15b3412950 100644
--- a/repack-geometry.c
+++ b/repack-geometry.c
@@ -32,7 +32,8 @@ void pack_geometry_init(struct pack_geometry *geometry,
 {
 	struct packed_git *p;
 	struct strbuf buf = STRBUF_INIT;
-	struct multi_pack_index *m = get_multi_pack_index(existing->source);
+	struct odb_source_files *files = odb_source_files_downcast(existing->source);
+	struct multi_pack_index *m = get_multi_pack_index(files->packed);
 
 	repo_for_each_pack(existing->repo, p) {
 		if (geometry->midx_layer_threshold_set && m &&
diff --git a/repack-midx.c b/repack-midx.c
index b6b1de7180..7c7c3620e5 100644
--- a/repack-midx.c
+++ b/repack-midx.c
@@ -557,13 +557,14 @@ static void repack_make_midx_append_plan(struct repack_write_midx_opts *opts,
 					 struct midx_compaction_step **steps_p,
 					 size_t *steps_nr_p)
 {
+	struct odb_source_files *files = odb_source_files_downcast(opts->existing->source);
 	struct multi_pack_index *m;
 	struct midx_compaction_step *steps = NULL;
 	struct midx_compaction_step *step;
 	size_t steps_nr = 0, steps_alloc = 0;
 
 	odb_reprepare(opts->existing->repo->objects);
-	m = get_multi_pack_index(opts->existing->source);
+	m = get_multi_pack_index(files->packed);
 
 	if (opts->names->nr) {
 		struct strbuf buf = STRBUF_INIT;
@@ -606,6 +607,7 @@ static int repack_make_midx_compaction_plan(struct repack_write_midx_opts *opts,
 					    struct midx_compaction_step **steps_p,
 					    size_t *steps_nr_p)
 {
+	struct odb_source_files *files = odb_source_files_downcast(opts->existing->source);
 	struct multi_pack_index *m;
 	struct midx_compaction_step *steps = NULL;
 	struct midx_compaction_step step = { 0 };
@@ -618,7 +620,7 @@ static int repack_make_midx_compaction_plan(struct repack_write_midx_opts *opts,
 			    opts->existing->repo);
 
 	odb_reprepare(opts->existing->repo->objects);
-	m = get_multi_pack_index(opts->existing->source);
+	m = get_multi_pack_index(files->packed);
 
 	for (i = 0; m && i < m->num_packs + m->num_packs_in_base; i++) {
 		if (prepare_midx_pack(m, i)) {
@@ -938,6 +940,7 @@ static int repack_make_midx_compaction_plan(struct repack_write_midx_opts *opts,
 
 static int write_midx_incremental(struct repack_write_midx_opts *opts)
 {
+	struct odb_source_files *files = odb_source_files_downcast(opts->existing->source);
 	struct midx_compaction_step *steps = NULL;
 	struct strbuf lock_name = STRBUF_INIT;
 	struct lock_file lf;
@@ -946,7 +949,7 @@ static int write_midx_incremental(struct repack_write_midx_opts *opts)
 	size_t i;
 	int ret = 0;
 
-	get_midx_chain_filename(opts->existing->source, &lock_name);
+	get_midx_chain_filename(files->packed, &lock_name);
 	if (safe_create_leading_directories(opts->existing->repo,
 					    lock_name.buf))
 		die_errno(_("unable to create leading directories of %s"),
diff --git a/repack.c b/repack.c
index 571dabb665..d2aa58e134 100644
--- a/repack.c
+++ b/repack.c
@@ -59,10 +59,10 @@ void repack_remove_redundant_pack(struct repository *repo, const char *dir_name,
 				  bool wrote_incremental_midx)
 {
 	struct strbuf buf = STRBUF_INIT;
-	struct odb_source *source = repo->objects->sources;
-	struct multi_pack_index *m = get_multi_pack_index(source);
+	struct odb_source_files *files = odb_source_files_downcast(repo->objects->sources);
+	struct multi_pack_index *m = get_multi_pack_index(files->packed);
 	strbuf_addf(&buf, "%s.pack", base_name);
-	if (m && source->local && midx_contains_pack(m, buf.buf)) {
+	if (m && files->base.local && midx_contains_pack(m, buf.buf)) {
 		clear_midx_file(repo);
 		if (!wrote_incremental_midx)
 			clear_incremental_midx_files(repo, NULL);
diff --git a/t/helper/test-read-midx.c b/t/helper/test-read-midx.c
index 790000fb26..fb16ec0176 100644
--- a/t/helper/test-read-midx.c
+++ b/t/helper/test-read-midx.c
@@ -13,13 +13,16 @@
 
 static struct multi_pack_index *setup_midx(const char *object_dir)
 {
+	struct odb_source_files *files;
 	struct odb_source *source;
 	setup_git_directory(the_repository);
 	source = odb_find_source(the_repository->objects, object_dir);
 	if (!source)
 		source = odb_add_to_alternates_memory(the_repository->objects,
 						      object_dir);
-	return load_multi_pack_index(source);
+	files = odb_source_files_downcast(source);
+
+	return load_multi_pack_index(files->packed);
 }
 
 static int read_midx_file(const char *object_dir, const char *checksum,
@@ -70,7 +73,7 @@ static int read_midx_file(const char *object_dir, const char *checksum,
 	for (i = 0; i < m->num_packs; i++)
 		printf("%s\n", m->pack_names[i]);
 
-	printf("object-dir: %s\n", m->source->path);
+	printf("object-dir: %s\n", m->source->base.path);
 
 	if (show_objects) {
 		struct object_id oid;

-- 
2.54.0.1136.gdb2ca164c4.dirty


^ permalink raw reply related

* [PATCH v2 15/17] odb/source-packed: stub out remaining functions
From: Patrick Steinhardt @ 2026-06-09  8:51 UTC (permalink / raw)
  To: git; +Cc: Karthik Nayak
In-Reply-To: <20260609-pks-odb-source-packed-v2-0-839089132c8b@pks.im>

Stub out remaining functions that we either don't need or that are
basically no-ops.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 odb/source-packed.c | 42 ++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 42 insertions(+)

diff --git a/odb/source-packed.c b/odb/source-packed.c
index e40b52e445..08a2de9bc5 100644
--- a/odb/source-packed.c
+++ b/odb/source-packed.c
@@ -501,6 +501,43 @@ static int odb_source_packed_freshen_object(struct odb_source *source,
 	return 1;
 }
 
+static int odb_source_packed_write_object(struct odb_source *source UNUSED,
+					  const void *buf UNUSED,
+					  unsigned long len UNUSED,
+					  enum object_type type UNUSED,
+					  struct object_id *oid UNUSED,
+					  struct object_id *compat_oid UNUSED,
+					  unsigned flags UNUSED)
+{
+	return error("packed backend cannot write objects");
+}
+
+static int odb_source_packed_write_object_stream(struct odb_source *source UNUSED,
+						 struct odb_write_stream *stream UNUSED,
+						 size_t len UNUSED,
+						 struct object_id *oid UNUSED)
+{
+	return error("packed backend cannot write object streams");
+}
+
+static int odb_source_packed_begin_transaction(struct odb_source *source UNUSED,
+					       struct odb_transaction **out UNUSED)
+{
+	return error("packed backend cannot begin transactions");
+}
+
+static int odb_source_packed_read_alternates(struct odb_source *source UNUSED,
+					     struct strvec *out UNUSED)
+{
+	return 0;
+}
+
+static int odb_source_packed_write_alternate(struct odb_source *source UNUSED,
+					     const char *alternate UNUSED)
+{
+	return error("packed backend cannot write alternates");
+}
+
 void (*report_garbage)(unsigned seen_bits, const char *path);
 
 static void report_helper(const struct string_list *list,
@@ -715,6 +752,11 @@ struct odb_source_packed *odb_source_packed_new(struct odb_source_files *parent)
 	packed->base.count_objects = odb_source_packed_count_objects;
 	packed->base.find_abbrev_len = odb_source_packed_find_abbrev_len;
 	packed->base.freshen_object = odb_source_packed_freshen_object;
+	packed->base.write_object = odb_source_packed_write_object;
+	packed->base.write_object_stream = odb_source_packed_write_object_stream;
+	packed->base.begin_transaction = odb_source_packed_begin_transaction;
+	packed->base.read_alternates = odb_source_packed_read_alternates;
+	packed->base.write_alternate = odb_source_packed_write_alternate;
 
 	if (!is_absolute_path(parent->base.path))
 		chdir_notify_register(NULL, odb_source_packed_reparent, packed);

-- 
2.54.0.1136.gdb2ca164c4.dirty


^ permalink raw reply related

* [PATCH v2 14/17] odb/source-packed: wire up `freshen_object()` callback
From: Patrick Steinhardt @ 2026-06-09  8:51 UTC (permalink / raw)
  To: git; +Cc: Karthik Nayak
In-Reply-To: <20260609-pks-odb-source-packed-v2-0-839089132c8b@pks.im>

Move `packfile_store_freshen_object()` and from "packfile.c" into
"odb/source-packed.c" and wire it up as the `freshen_object()` callback
of the "packed" source.

Note that this removes the last external caller of `find_pack_entry()`
from "packfile.c", which means that we can now make this function
static.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 odb/source-files.c  |  2 +-
 odb/source-packed.c | 26 +++++++++++++++++++++++---
 odb/source-packed.h |  6 ------
 packfile.c          | 16 ----------------
 packfile.h          |  3 ---
 5 files changed, 24 insertions(+), 29 deletions(-)

diff --git a/odb/source-files.c b/odb/source-files.c
index 8ad782dc7b..fa2e18e71b 100644
--- a/odb/source-files.c
+++ b/odb/source-files.c
@@ -152,7 +152,7 @@ static int odb_source_files_freshen_object(struct odb_source *source,
 					   const struct object_id *oid)
 {
 	struct odb_source_files *files = odb_source_files_downcast(source);
-	if (packfile_store_freshen_object(files->packed, oid) ||
+	if (odb_source_freshen_object(&files->packed->base, oid) ||
 	    odb_source_freshen_object(&files->loose->base, oid))
 		return 1;
 	return 0;
diff --git a/odb/source-packed.c b/odb/source-packed.c
index b801b62023..e40b52e445 100644
--- a/odb/source-packed.c
+++ b/odb/source-packed.c
@@ -9,9 +9,9 @@
 #include "odb/streaming.h"
 #include "packfile.h"
 
-int find_pack_entry(struct odb_source_packed *store,
-		    const struct object_id *oid,
-		    struct pack_entry *e)
+static int find_pack_entry(struct odb_source_packed *store,
+			   const struct object_id *oid,
+			   struct pack_entry *e)
 {
 	struct packfile_list_entry *l;
 
@@ -482,6 +482,25 @@ static int odb_source_packed_find_abbrev_len(struct odb_source *source,
 	return 0;
 }
 
+static int odb_source_packed_freshen_object(struct odb_source *source,
+					    const struct object_id *oid)
+{
+	struct odb_source_packed *packed = odb_source_packed_downcast(source);
+	struct pack_entry e;
+
+	if (!find_pack_entry(packed, oid, &e))
+		return 0;
+	if (e.p->is_cruft)
+		return 0;
+	if (e.p->freshened)
+		return 1;
+	if (utime(e.p->pack_name, NULL))
+		return 0;
+	e.p->freshened = 1;
+
+	return 1;
+}
+
 void (*report_garbage)(unsigned seen_bits, const char *path);
 
 static void report_helper(const struct string_list *list,
@@ -695,6 +714,7 @@ struct odb_source_packed *odb_source_packed_new(struct odb_source_files *parent)
 	packed->base.for_each_object = odb_source_packed_for_each_object;
 	packed->base.count_objects = odb_source_packed_count_objects;
 	packed->base.find_abbrev_len = odb_source_packed_find_abbrev_len;
+	packed->base.freshen_object = odb_source_packed_freshen_object;
 
 	if (!is_absolute_path(parent->base.path))
 		chdir_notify_register(NULL, odb_source_packed_reparent, packed);
diff --git a/odb/source-packed.h b/odb/source-packed.h
index f430ee0b94..9d4796261a 100644
--- a/odb/source-packed.h
+++ b/odb/source-packed.h
@@ -90,10 +90,4 @@ static inline struct odb_source_packed *odb_source_packed_downcast(struct odb_so
  */
 void odb_source_packed_prepare(struct odb_source_packed *source);
 
-struct pack_entry;
-
-int find_pack_entry(struct odb_source_packed *store,
-		    const struct object_id *oid,
-		    struct pack_entry *e);
-
 #endif
diff --git a/packfile.c b/packfile.c
index 7f84094e53..a577275d4f 100644
--- a/packfile.c
+++ b/packfile.c
@@ -1892,22 +1892,6 @@ int packfile_fill_entry(struct packed_git *p,
 	return 1;
 }
 
-int packfile_store_freshen_object(struct odb_source_packed *store,
-				  const struct object_id *oid)
-{
-	struct pack_entry e;
-	if (!find_pack_entry(store, oid, &e))
-		return 0;
-	if (e.p->is_cruft)
-		return 0;
-	if (e.p->freshened)
-		return 1;
-	if (utime(e.p->pack_name, NULL))
-		return 0;
-	e.p->freshened = 1;
-	return 1;
-}
-
 static void maybe_invalidate_kept_pack_cache(struct odb_source_packed *store,
 					     unsigned flags)
 {
diff --git a/packfile.h b/packfile.h
index 79324e4010..71a71017ee 100644
--- a/packfile.h
+++ b/packfile.h
@@ -132,9 +132,6 @@ static inline void repo_for_each_pack_data_next(struct repo_for_each_pack_data *
 struct packed_git *packfile_store_load_pack(struct odb_source_packed *store,
 					    const char *idx_path, int local);
 
-int packfile_store_freshen_object(struct odb_source_packed *store,
-				  const struct object_id *oid);
-
 enum kept_pack_type {
 	KEPT_PACK_ON_DISK = (1 << 0),
 	KEPT_PACK_IN_CORE = (1 << 1),

-- 
2.54.0.1136.gdb2ca164c4.dirty


^ permalink raw reply related

* [PATCH v2 13/17] odb/source-packed: wire up `find_abbrev_len()` callback
From: Patrick Steinhardt @ 2026-06-09  8:51 UTC (permalink / raw)
  To: git; +Cc: Karthik Nayak
In-Reply-To: <20260609-pks-odb-source-packed-v2-0-839089132c8b@pks.im>

Move `packfile_store_find_abbrev_len()` and its associated helpers from
"packfile.c" into "odb/source-packed.c" and wire it up as the
`find_abbrev_len()` callback of the "packed" source.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 odb/source-files.c  |   2 +-
 odb/source-packed.c | 113 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 packfile.c          | 111 ---------------------------------------------------
 packfile.h          |   5 ---
 4 files changed, 114 insertions(+), 117 deletions(-)

diff --git a/odb/source-files.c b/odb/source-files.c
index 274923e0ba..8ad782dc7b 100644
--- a/odb/source-files.c
+++ b/odb/source-files.c
@@ -133,7 +133,7 @@ static int odb_source_files_find_abbrev_len(struct odb_source *source,
 	unsigned len = min_len;
 	int ret;
 
-	ret = packfile_store_find_abbrev_len(files->packed, oid, len, &len);
+	ret = odb_source_find_abbrev_len(&files->packed->base, oid, len, &len);
 	if (ret < 0)
 		goto out;
 
diff --git a/odb/source-packed.c b/odb/source-packed.c
index 070a4e3958..b801b62023 100644
--- a/odb/source-packed.c
+++ b/odb/source-packed.c
@@ -370,6 +370,118 @@ static int odb_source_packed_count_objects(struct odb_source *source,
 	return ret;
 }
 
+static int extend_abbrev_len(const struct object_id *a,
+			     const struct object_id *b,
+			     unsigned *out)
+{
+	unsigned len = oid_common_prefix_hexlen(a, b);
+	if (len != hash_algos[a->algo].hexsz && len >= *out)
+		*out = len + 1;
+	return 0;
+}
+
+static void find_abbrev_len_for_midx(struct multi_pack_index *m,
+				     const struct object_id *oid,
+				     unsigned min_len,
+				     unsigned *out)
+{
+	unsigned len = min_len;
+
+	for (; m; m = m->base_midx) {
+		int match = 0;
+		uint32_t num, first = 0;
+		struct object_id found_oid;
+
+		if (!m->num_objects)
+			continue;
+
+		num = m->num_objects + m->num_objects_in_base;
+		match = bsearch_one_midx(oid, m, &first);
+
+		/*
+		 * first is now the position in the packfile where we
+		 * would insert the object ID if it does not exist (or the
+		 * position of the object ID if it does exist). Hence, we
+		 * consider a maximum of two objects nearby for the
+		 * abbreviation length.
+		 */
+
+		if (!match) {
+			if (nth_midxed_object_oid(&found_oid, m, first))
+				extend_abbrev_len(&found_oid, oid, &len);
+		} else if (first < num - 1) {
+			if (nth_midxed_object_oid(&found_oid, m, first + 1))
+				extend_abbrev_len(&found_oid, oid, &len);
+		}
+		if (first > 0) {
+			if (nth_midxed_object_oid(&found_oid, m, first - 1))
+				extend_abbrev_len(&found_oid, oid, &len);
+		}
+	}
+
+	*out = len;
+}
+
+static void find_abbrev_len_for_pack(struct packed_git *p,
+				     const struct object_id *oid,
+				     unsigned min_len,
+				     unsigned *out)
+{
+	int match;
+	uint32_t num, first = 0;
+	struct object_id found_oid;
+	unsigned len = min_len;
+
+	num = p->num_objects;
+	match = bsearch_pack(oid, p, &first);
+
+	/*
+	 * first is now the position in the packfile where we would insert
+	 * the object ID if it does not exist (or the position of mad->hash if
+	 * it does exist). Hence, we consider a maximum of two objects
+	 * nearby for the abbreviation length.
+	 */
+	if (!match) {
+		if (!nth_packed_object_id(&found_oid, p, first))
+			extend_abbrev_len(&found_oid, oid, &len);
+	} else if (first < num - 1) {
+		if (!nth_packed_object_id(&found_oid, p, first + 1))
+			extend_abbrev_len(&found_oid, oid, &len);
+	}
+	if (first > 0) {
+		if (!nth_packed_object_id(&found_oid, p, first - 1))
+			extend_abbrev_len(&found_oid, oid, &len);
+	}
+
+	*out = len;
+}
+
+static int odb_source_packed_find_abbrev_len(struct odb_source *source,
+					     const struct object_id *oid,
+					     unsigned min_len,
+					     unsigned *out)
+{
+	struct odb_source_packed *packed = odb_source_packed_downcast(source);
+	struct packfile_list_entry *e;
+	struct multi_pack_index *m;
+
+	m = get_multi_pack_index(&packed->files->base);
+	if (m)
+		find_abbrev_len_for_midx(m, oid, min_len, &min_len);
+
+	for (e = packfile_store_get_packs(packed); e; e = e->next) {
+		if (e->pack->multi_pack_index)
+			continue;
+		if (open_pack_index(e->pack) || !e->pack->num_objects)
+			continue;
+
+		find_abbrev_len_for_pack(e->pack, oid, min_len, &min_len);
+	}
+
+	*out = min_len;
+	return 0;
+}
+
 void (*report_garbage)(unsigned seen_bits, const char *path);
 
 static void report_helper(const struct string_list *list,
@@ -582,6 +694,7 @@ struct odb_source_packed *odb_source_packed_new(struct odb_source_files *parent)
 	packed->base.read_object_stream = odb_source_packed_read_object_stream;
 	packed->base.for_each_object = odb_source_packed_for_each_object;
 	packed->base.count_objects = odb_source_packed_count_objects;
+	packed->base.find_abbrev_len = odb_source_packed_find_abbrev_len;
 
 	if (!is_absolute_path(parent->base.path))
 		chdir_notify_register(NULL, odb_source_packed_reparent, packed);
diff --git a/packfile.c b/packfile.c
index 2da6bbe2b5..7f84094e53 100644
--- a/packfile.c
+++ b/packfile.c
@@ -2037,117 +2037,6 @@ int for_each_object_in_pack(struct packed_git *p,
 	return r;
 }
 
-static int extend_abbrev_len(const struct object_id *a,
-			     const struct object_id *b,
-			     unsigned *out)
-{
-	unsigned len = oid_common_prefix_hexlen(a, b);
-	if (len != hash_algos[a->algo].hexsz && len >= *out)
-		*out = len + 1;
-	return 0;
-}
-
-static void find_abbrev_len_for_midx(struct multi_pack_index *m,
-				     const struct object_id *oid,
-				     unsigned min_len,
-				     unsigned *out)
-{
-	unsigned len = min_len;
-
-	for (; m; m = m->base_midx) {
-		int match = 0;
-		uint32_t num, first = 0;
-		struct object_id found_oid;
-
-		if (!m->num_objects)
-			continue;
-
-		num = m->num_objects + m->num_objects_in_base;
-		match = bsearch_one_midx(oid, m, &first);
-
-		/*
-		 * first is now the position in the packfile where we
-		 * would insert the object ID if it does not exist (or the
-		 * position of the object ID if it does exist). Hence, we
-		 * consider a maximum of two objects nearby for the
-		 * abbreviation length.
-		 */
-
-		if (!match) {
-			if (nth_midxed_object_oid(&found_oid, m, first))
-				extend_abbrev_len(&found_oid, oid, &len);
-		} else if (first < num - 1) {
-			if (nth_midxed_object_oid(&found_oid, m, first + 1))
-				extend_abbrev_len(&found_oid, oid, &len);
-		}
-		if (first > 0) {
-			if (nth_midxed_object_oid(&found_oid, m, first - 1))
-				extend_abbrev_len(&found_oid, oid, &len);
-		}
-	}
-
-	*out = len;
-}
-
-static void find_abbrev_len_for_pack(struct packed_git *p,
-				     const struct object_id *oid,
-				     unsigned min_len,
-				     unsigned *out)
-{
-	int match;
-	uint32_t num, first = 0;
-	struct object_id found_oid;
-	unsigned len = min_len;
-
-	num = p->num_objects;
-	match = bsearch_pack(oid, p, &first);
-
-	/*
-	 * first is now the position in the packfile where we would insert
-	 * the object ID if it does not exist (or the position of mad->hash if
-	 * it does exist). Hence, we consider a maximum of two objects
-	 * nearby for the abbreviation length.
-	 */
-	if (!match) {
-		if (!nth_packed_object_id(&found_oid, p, first))
-			extend_abbrev_len(&found_oid, oid, &len);
-	} else if (first < num - 1) {
-		if (!nth_packed_object_id(&found_oid, p, first + 1))
-			extend_abbrev_len(&found_oid, oid, &len);
-	}
-	if (first > 0) {
-		if (!nth_packed_object_id(&found_oid, p, first - 1))
-			extend_abbrev_len(&found_oid, oid, &len);
-	}
-
-	*out = len;
-}
-
-int packfile_store_find_abbrev_len(struct odb_source_packed *store,
-				   const struct object_id *oid,
-				   unsigned min_len,
-				   unsigned *out)
-{
-	struct packfile_list_entry *e;
-	struct multi_pack_index *m;
-
-	m = get_multi_pack_index(&store->files->base);
-	if (m)
-		find_abbrev_len_for_midx(m, oid, min_len, &min_len);
-
-	for (e = packfile_store_get_packs(store); e; e = e->next) {
-		if (e->pack->multi_pack_index)
-			continue;
-		if (open_pack_index(e->pack) || !e->pack->num_objects)
-			continue;
-
-		find_abbrev_len_for_pack(e->pack, oid, min_len, &min_len);
-	}
-
-	*out = min_len;
-	return 0;
-}
-
 struct add_promisor_object_data {
 	struct repository *repo;
 	struct oidset *set;
diff --git a/packfile.h b/packfile.h
index 0613fd3c63..79324e4010 100644
--- a/packfile.h
+++ b/packfile.h
@@ -217,11 +217,6 @@ int for_each_object_in_pack(struct packed_git *p,
 			    each_packed_object_fn, void *data,
 			    enum odb_for_each_object_flags flags);
 
-int packfile_store_find_abbrev_len(struct odb_source_packed *store,
-				   const struct object_id *oid,
-				   unsigned min_len,
-				   unsigned *out);
-
 /* A hook to report invalid files in pack directory */
 #define PACKDIR_FILE_PACK 1
 #define PACKDIR_FILE_IDX 2

-- 
2.54.0.1136.gdb2ca164c4.dirty


^ permalink raw reply related

* [PATCH v2 12/17] odb/source-packed: wire up `count_objects()` callback
From: Patrick Steinhardt @ 2026-06-09  8:51 UTC (permalink / raw)
  To: git; +Cc: Karthik Nayak
In-Reply-To: <20260609-pks-odb-source-packed-v2-0-839089132c8b@pks.im>

Move `packfile_store_count_objects()` from "packfile.c" into
"odb/source-packed.c" and wire it up as the `count_objects()` callback
of the "packed" source.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 odb/source-files.c  |  2 +-
 odb/source-packed.c | 33 +++++++++++++++++++++++++++++++++
 packfile.c          | 31 -------------------------------
 packfile.h          | 10 ----------
 4 files changed, 34 insertions(+), 42 deletions(-)

diff --git a/odb/source-files.c b/odb/source-files.c
index c73a7e5f90..274923e0ba 100644
--- a/odb/source-files.c
+++ b/odb/source-files.c
@@ -103,7 +103,7 @@ static int odb_source_files_count_objects(struct odb_source *source,
 	unsigned long count;
 	int ret;
 
-	ret = packfile_store_count_objects(files->packed, flags, &count);
+	ret = odb_source_count_objects(&files->packed->base, flags, &count);
 	if (ret < 0)
 		goto out;
 
diff --git a/odb/source-packed.c b/odb/source-packed.c
index a61c809c8c..070a4e3958 100644
--- a/odb/source-packed.c
+++ b/odb/source-packed.c
@@ -338,6 +338,38 @@ static int odb_source_packed_for_each_object(struct odb_source *source,
 	return ret;
 }
 
+static int odb_source_packed_count_objects(struct odb_source *source,
+					   enum odb_count_objects_flags flags UNUSED,
+					   unsigned long *out)
+{
+	struct odb_source_packed *packed = odb_source_packed_downcast(source);
+	struct packfile_list_entry *e;
+	struct multi_pack_index *m;
+	unsigned long count = 0;
+	int ret;
+
+	m = get_multi_pack_index(&packed->files->base);
+	if (m)
+		count += m->num_objects + m->num_objects_in_base;
+
+	for (e = packfile_store_get_packs(packed); e; e = e->next) {
+		if (e->pack->multi_pack_index)
+			continue;
+		if (open_pack_index(e->pack)) {
+			ret = -1;
+			goto out;
+		}
+
+		count += e->pack->num_objects;
+	}
+
+	*out = count;
+	ret = 0;
+
+out:
+	return ret;
+}
+
 void (*report_garbage)(unsigned seen_bits, const char *path);
 
 static void report_helper(const struct string_list *list,
@@ -549,6 +581,7 @@ struct odb_source_packed *odb_source_packed_new(struct odb_source_files *parent)
 	packed->base.read_object_info = odb_source_packed_read_object_info;
 	packed->base.read_object_stream = odb_source_packed_read_object_stream;
 	packed->base.for_each_object = odb_source_packed_for_each_object;
+	packed->base.count_objects = odb_source_packed_count_objects;
 
 	if (!is_absolute_path(parent->base.path))
 		chdir_notify_register(NULL, odb_source_packed_reparent, packed);
diff --git a/packfile.c b/packfile.c
index b8d6054c16..2da6bbe2b5 100644
--- a/packfile.c
+++ b/packfile.c
@@ -866,37 +866,6 @@ struct packfile_list_entry *packfile_store_get_packs(struct odb_source_packed *s
 	return store->packs.head;
 }
 
-int packfile_store_count_objects(struct odb_source_packed *store,
-				 enum odb_count_objects_flags flags UNUSED,
-				 unsigned long *out)
-{
-	struct packfile_list_entry *e;
-	struct multi_pack_index *m;
-	unsigned long count = 0;
-	int ret;
-
-	m = get_multi_pack_index(&store->files->base);
-	if (m)
-		count += m->num_objects + m->num_objects_in_base;
-
-	for (e = packfile_store_get_packs(store); e; e = e->next) {
-		if (e->pack->multi_pack_index)
-			continue;
-		if (open_pack_index(e->pack)) {
-			ret = -1;
-			goto out;
-		}
-
-		count += e->pack->num_objects;
-	}
-
-	*out = count;
-	ret = 0;
-
-out:
-	return ret;
-}
-
 unsigned long unpack_object_header_buffer(const unsigned char *buf,
 		unsigned long len, enum object_type *type, size_t *sizep)
 {
diff --git a/packfile.h b/packfile.h
index 0097de0b27..0613fd3c63 100644
--- a/packfile.h
+++ b/packfile.h
@@ -141,16 +141,6 @@ enum kept_pack_type {
 	KEPT_PACK_IN_CORE_OPEN = (1 << 2),
 };
 
-/*
- * Count the number objects contained in the given packfile store. If
- * successful, the number of objects will be written to the `out` pointer.
- *
- * Return 0 on success, a negative error code otherwise.
- */
-int packfile_store_count_objects(struct odb_source_packed *store,
-				 enum odb_count_objects_flags flags,
-				 unsigned long *out);
-
 /*
  * Retrieve the cache of kept packs from the given packfile store. Accepts a
  * combination of `kept_pack_type` flags. The cache is computed on demand and

-- 
2.54.0.1136.gdb2ca164c4.dirty


^ permalink raw reply related

* [PATCH v2 11/17] odb/source-packed: wire up `for_each_object()` callback
From: Patrick Steinhardt @ 2026-06-09  8:51 UTC (permalink / raw)
  To: git; +Cc: Karthik Nayak
In-Reply-To: <20260609-pks-odb-source-packed-v2-0-839089132c8b@pks.im>

Move `packfile_store_for_each_object()` and its associated helpers from
"packfile.c" into "odb/source-packed.c" and wire it up as the
`for_each_object()` callback of the "packed" source.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 builtin/cat-file.c     |   4 +-
 builtin/pack-objects.c |   4 +-
 commit-graph.c         |   4 +-
 odb/source-files.c     |   2 +-
 odb/source-packed.c    | 258 ++++++++++++++++++++++++++++++++++++++++++++++++
 packfile.c             | 260 +------------------------------------------------
 packfile.h             |  17 +---
 7 files changed, 269 insertions(+), 280 deletions(-)

diff --git a/builtin/cat-file.c b/builtin/cat-file.c
index 04b64006a5..d997011531 100644
--- a/builtin/cat-file.c
+++ b/builtin/cat-file.c
@@ -916,8 +916,8 @@ static void batch_each_object(struct batch_options *opt,
 
 		for (source = the_repository->objects->sources; source; source = source->next) {
 			struct odb_source_files *files = odb_source_files_downcast(source);
-			int ret = packfile_store_for_each_object(files->packed, &oi,
-								 batch_one_object_oi, &payload, &opts);
+			int ret = odb_source_for_each_object(&files->packed->base, &oi,
+							     batch_one_object_oi, &payload, &opts);
 			if (ret)
 				break;
 		}
diff --git a/builtin/pack-objects.c b/builtin/pack-objects.c
index 50675481e1..5e94805478 100644
--- a/builtin/pack-objects.c
+++ b/builtin/pack-objects.c
@@ -4503,8 +4503,8 @@ static void add_objects_in_unpacked_packs(void)
 		if (!source->local)
 			continue;
 
-		if (packfile_store_for_each_object(files->packed, &oi,
-						   add_object_in_unpacked_pack, NULL, &opts))
+		if (odb_source_for_each_object(&files->packed->base, &oi,
+					       add_object_in_unpacked_pack, NULL, &opts))
 			die(_("cannot open pack index"));
 	}
 }
diff --git a/commit-graph.c b/commit-graph.c
index 9abe62bd5a..1e4038baf3 100644
--- a/commit-graph.c
+++ b/commit-graph.c
@@ -2016,8 +2016,8 @@ static void fill_oids_from_all_packs(struct write_commit_graph_context *ctx)
 	odb_prepare_alternates(ctx->r->objects);
 	for (source = ctx->r->objects->sources; source; source = source->next) {
 		struct odb_source_files *files = odb_source_files_downcast(source);
-		packfile_store_for_each_object(files->packed, &oi, add_packed_commits_oi,
-					       ctx, &opts);
+		odb_source_for_each_object(&files->packed->base, &oi, add_packed_commits_oi,
+					   ctx, &opts);
 	}
 
 	if (ctx->progress_done < ctx->approx_nr_objects)
diff --git a/odb/source-files.c b/odb/source-files.c
index dff69d0e4e..c73a7e5f90 100644
--- a/odb/source-files.c
+++ b/odb/source-files.c
@@ -88,7 +88,7 @@ static int odb_source_files_for_each_object(struct odb_source *source,
 			return ret;
 	}
 
-	ret = packfile_store_for_each_object(files->packed, request, cb, cb_data, opts);
+	ret = odb_source_for_each_object(&files->packed->base, request, cb, cb_data, opts);
 	if (ret)
 		return ret;
 
diff --git a/odb/source-packed.c b/odb/source-packed.c
index 23d7149fe3..a61c809c8c 100644
--- a/odb/source-packed.c
+++ b/odb/source-packed.c
@@ -81,6 +81,263 @@ static int odb_source_packed_read_object_stream(struct odb_read_stream **out,
 	return packfile_read_object_stream(out, oid, e.p, e.offset);
 }
 
+struct odb_source_packed_for_each_object_wrapper_data {
+	struct odb_source_packed *store;
+	const struct object_info *request;
+	odb_for_each_object_cb cb;
+	void *cb_data;
+};
+
+static int odb_source_packed_for_each_object_wrapper(const struct object_id *oid,
+						     struct packed_git *pack,
+						     uint32_t index_pos,
+						     void *cb_data)
+{
+	struct odb_source_packed_for_each_object_wrapper_data *data = cb_data;
+
+	if (data->request) {
+		off_t offset = nth_packed_object_offset(pack, index_pos);
+		struct object_info oi = *data->request;
+
+		if (packed_object_info_with_index_pos(pack, offset,
+						      &index_pos, &oi) < 0) {
+			mark_bad_packed_object(pack, oid);
+			return -1;
+		}
+
+		return data->cb(oid, &oi, data->cb_data);
+	} else {
+		return data->cb(oid, NULL, data->cb_data);
+	}
+}
+
+static int match_hash(unsigned len, const unsigned char *a, const unsigned char *b)
+{
+	do {
+		if (*a != *b)
+			return 0;
+		a++;
+		b++;
+		len -= 2;
+	} while (len > 1);
+	if (len)
+		if ((*a ^ *b) & 0xf0)
+			return 0;
+	return 1;
+}
+
+static int for_each_prefixed_object_in_midx(
+	struct odb_source_packed *store,
+	struct multi_pack_index *m,
+	const struct odb_for_each_object_options *opts,
+	struct odb_source_packed_for_each_object_wrapper_data *data)
+{
+	int ret;
+
+	for (; m; m = m->base_midx) {
+		uint32_t num, i, first = 0;
+		int len = opts->prefix_hex_len > m->source->odb->repo->hash_algo->hexsz ?
+			m->source->odb->repo->hash_algo->hexsz : opts->prefix_hex_len;
+
+		if (!m->num_objects)
+			continue;
+
+		num = m->num_objects + m->num_objects_in_base;
+
+		bsearch_one_midx(opts->prefix, m, &first);
+
+		/*
+		 * At this point, "first" is the location of the lowest
+		 * object with an object name that could match "opts->prefix".
+		 * See if we have 0, 1 or more objects that actually match(es).
+		 */
+		for (i = first; i < num; i++) {
+			const struct object_id *current = NULL;
+			struct object_id oid;
+
+			current = nth_midxed_object_oid(&oid, m, i);
+
+			if (!match_hash(len, opts->prefix->hash, current->hash))
+				break;
+
+			if (data->request) {
+				struct object_info oi = *data->request;
+
+				ret = odb_source_read_object_info(&store->base, current,
+								  &oi, 0);
+				if (ret)
+					goto out;
+
+				ret = data->cb(&oid, &oi, data->cb_data);
+				if (ret)
+					goto out;
+			} else {
+				ret = data->cb(&oid, NULL, data->cb_data);
+				if (ret)
+					goto out;
+			}
+		}
+	}
+
+	ret = 0;
+
+out:
+	return ret;
+}
+
+static int for_each_prefixed_object_in_pack(
+	struct odb_source_packed *store,
+	struct packed_git *p,
+	const struct odb_for_each_object_options *opts,
+	struct odb_source_packed_for_each_object_wrapper_data *data)
+{
+	uint32_t num, i, first = 0;
+	int len = opts->prefix_hex_len > p->repo->hash_algo->hexsz ?
+		p->repo->hash_algo->hexsz : opts->prefix_hex_len;
+	int ret;
+
+	num = p->num_objects;
+	bsearch_pack(opts->prefix, p, &first);
+
+	/*
+	 * At this point, "first" is the location of the lowest object
+	 * with an object name that could match "bin_pfx".  See if we have
+	 * 0, 1 or more objects that actually match(es).
+	 */
+	for (i = first; i < num; i++) {
+		struct object_id oid;
+
+		nth_packed_object_id(&oid, p, i);
+		if (!match_hash(len, opts->prefix->hash, oid.hash))
+			break;
+
+		if (data->request) {
+			struct object_info oi = *data->request;
+
+			ret = odb_source_read_object_info(&store->base, &oid, &oi, 0);
+			if (ret)
+				goto out;
+
+			ret = data->cb(&oid, &oi, data->cb_data);
+			if (ret)
+				goto out;
+		} else {
+			ret = data->cb(&oid, NULL, data->cb_data);
+			if (ret)
+				goto out;
+		}
+	}
+
+	ret = 0;
+
+out:
+	return ret;
+}
+
+static int odb_source_packed_for_each_prefixed_object(
+	struct odb_source_packed *store,
+	const struct odb_for_each_object_options *opts,
+	struct odb_source_packed_for_each_object_wrapper_data *data)
+{
+	struct packfile_list_entry *e;
+	struct multi_pack_index *m;
+	bool pack_errors = false;
+	int ret;
+
+	if (opts->flags)
+		BUG("flags unsupported");
+
+	store->skip_mru_updates = true;
+
+	m = get_multi_pack_index(&store->files->base);
+	if (m) {
+		ret = for_each_prefixed_object_in_midx(store, m, opts, data);
+		if (ret)
+			goto out;
+	}
+
+	for (e = packfile_store_get_packs(store); e; e = e->next) {
+		if (e->pack->multi_pack_index)
+			continue;
+
+		if (open_pack_index(e->pack)) {
+			pack_errors = true;
+			continue;
+		}
+
+		if (!e->pack->num_objects)
+			continue;
+
+		ret = for_each_prefixed_object_in_pack(store, e->pack, opts, data);
+		if (ret)
+			goto out;
+	}
+
+	ret = 0;
+
+out:
+	store->skip_mru_updates = false;
+	if (!ret && pack_errors)
+		ret = -1;
+	return ret;
+}
+
+static int odb_source_packed_for_each_object(struct odb_source *source,
+					     const struct object_info *request,
+					     odb_for_each_object_cb cb,
+					     void *cb_data,
+					     const struct odb_for_each_object_options *opts)
+{
+	struct odb_source_packed *packed = odb_source_packed_downcast(source);
+	struct odb_source_packed_for_each_object_wrapper_data data = {
+		.store = packed,
+		.request = request,
+		.cb = cb,
+		.cb_data = cb_data,
+	};
+	struct packfile_list_entry *e;
+	int pack_errors = 0, ret;
+
+	if (opts->prefix)
+		return odb_source_packed_for_each_prefixed_object(packed, opts, &data);
+
+	packed->skip_mru_updates = true;
+
+	for (e = packfile_store_get_packs(packed); e; e = e->next) {
+		struct packed_git *p = e->pack;
+
+		if ((opts->flags & ODB_FOR_EACH_OBJECT_LOCAL_ONLY) && !p->pack_local)
+			continue;
+		if ((opts->flags & ODB_FOR_EACH_OBJECT_PROMISOR_ONLY) &&
+		    !p->pack_promisor)
+			continue;
+		if ((opts->flags & ODB_FOR_EACH_OBJECT_SKIP_IN_CORE_KEPT_PACKS) &&
+		    p->pack_keep_in_core)
+			continue;
+		if ((opts->flags & ODB_FOR_EACH_OBJECT_SKIP_ON_DISK_KEPT_PACKS) &&
+		    p->pack_keep)
+			continue;
+		if (open_pack_index(p)) {
+			pack_errors = 1;
+			continue;
+		}
+
+		ret = for_each_object_in_pack(p, odb_source_packed_for_each_object_wrapper,
+					      &data, opts->flags);
+		if (ret)
+			goto out;
+	}
+
+	ret = 0;
+
+out:
+	packed->skip_mru_updates = false;
+
+	if (!ret && pack_errors)
+		ret = -1;
+	return ret;
+}
+
 void (*report_garbage)(unsigned seen_bits, const char *path);
 
 static void report_helper(const struct string_list *list,
@@ -291,6 +548,7 @@ struct odb_source_packed *odb_source_packed_new(struct odb_source_files *parent)
 	packed->base.reprepare = odb_source_packed_reprepare;
 	packed->base.read_object_info = odb_source_packed_read_object_info;
 	packed->base.read_object_stream = odb_source_packed_read_object_stream;
+	packed->base.for_each_object = odb_source_packed_for_each_object;
 
 	if (!is_absolute_path(parent->base.path))
 		chdir_notify_register(NULL, odb_source_packed_reparent, packed);
diff --git a/packfile.c b/packfile.c
index 42c84397eb..b8d6054c16 100644
--- a/packfile.c
+++ b/packfile.c
@@ -1362,8 +1362,8 @@ static void add_delta_base_cache(struct packed_git *p, off_t base_offset,
 	hashmap_add(&delta_base_cache, &ent->ent);
 }
 
-static int packed_object_info_with_index_pos(struct packed_git *p, off_t obj_offset,
-					     uint32_t *maybe_index_pos, struct object_info *oi)
+int packed_object_info_with_index_pos(struct packed_git *p, off_t obj_offset,
+				      uint32_t *maybe_index_pos, struct object_info *oi)
 {
 	struct pack_window *w_curs = NULL;
 	size_t size;
@@ -2068,262 +2068,6 @@ int for_each_object_in_pack(struct packed_git *p,
 	return r;
 }
 
-struct odb_source_packed_for_each_object_wrapper_data {
-	struct odb_source_packed *store;
-	const struct object_info *request;
-	odb_for_each_object_cb cb;
-	void *cb_data;
-};
-
-static int packfile_store_for_each_object_wrapper(const struct object_id *oid,
-						  struct packed_git *pack,
-						  uint32_t index_pos,
-						  void *cb_data)
-{
-	struct odb_source_packed_for_each_object_wrapper_data *data = cb_data;
-
-	if (data->request) {
-		off_t offset = nth_packed_object_offset(pack, index_pos);
-		struct object_info oi = *data->request;
-
-		if (packed_object_info_with_index_pos(pack, offset,
-						      &index_pos, &oi) < 0) {
-			mark_bad_packed_object(pack, oid);
-			return -1;
-		}
-
-		return data->cb(oid, &oi, data->cb_data);
-	} else {
-		return data->cb(oid, NULL, data->cb_data);
-	}
-}
-
-static int match_hash(unsigned len, const unsigned char *a, const unsigned char *b)
-{
-	do {
-		if (*a != *b)
-			return 0;
-		a++;
-		b++;
-		len -= 2;
-	} while (len > 1);
-	if (len)
-		if ((*a ^ *b) & 0xf0)
-			return 0;
-	return 1;
-}
-
-static int for_each_prefixed_object_in_midx(
-	struct odb_source_packed *store,
-	struct multi_pack_index *m,
-	const struct odb_for_each_object_options *opts,
-	struct odb_source_packed_for_each_object_wrapper_data *data)
-{
-	int ret;
-
-	for (; m; m = m->base_midx) {
-		uint32_t num, i, first = 0;
-		int len = opts->prefix_hex_len > m->source->odb->repo->hash_algo->hexsz ?
-			m->source->odb->repo->hash_algo->hexsz : opts->prefix_hex_len;
-
-		if (!m->num_objects)
-			continue;
-
-		num = m->num_objects + m->num_objects_in_base;
-
-		bsearch_one_midx(opts->prefix, m, &first);
-
-		/*
-		 * At this point, "first" is the location of the lowest
-		 * object with an object name that could match "opts->prefix".
-		 * See if we have 0, 1 or more objects that actually match(es).
-		 */
-		for (i = first; i < num; i++) {
-			const struct object_id *current = NULL;
-			struct object_id oid;
-
-			current = nth_midxed_object_oid(&oid, m, i);
-
-			if (!match_hash(len, opts->prefix->hash, current->hash))
-				break;
-
-			if (data->request) {
-				struct object_info oi = *data->request;
-
-				ret = odb_source_read_object_info(&store->base, current,
-								  &oi, 0);
-				if (ret)
-					goto out;
-
-				ret = data->cb(&oid, &oi, data->cb_data);
-				if (ret)
-					goto out;
-			} else {
-				ret = data->cb(&oid, NULL, data->cb_data);
-				if (ret)
-					goto out;
-			}
-		}
-	}
-
-	ret = 0;
-
-out:
-	return ret;
-}
-
-static int for_each_prefixed_object_in_pack(
-	struct odb_source_packed *store,
-	struct packed_git *p,
-	const struct odb_for_each_object_options *opts,
-	struct odb_source_packed_for_each_object_wrapper_data *data)
-{
-	uint32_t num, i, first = 0;
-	int len = opts->prefix_hex_len > p->repo->hash_algo->hexsz ?
-		p->repo->hash_algo->hexsz : opts->prefix_hex_len;
-	int ret;
-
-	num = p->num_objects;
-	bsearch_pack(opts->prefix, p, &first);
-
-	/*
-	 * At this point, "first" is the location of the lowest object
-	 * with an object name that could match "bin_pfx".  See if we have
-	 * 0, 1 or more objects that actually match(es).
-	 */
-	for (i = first; i < num; i++) {
-		struct object_id oid;
-
-		nth_packed_object_id(&oid, p, i);
-		if (!match_hash(len, opts->prefix->hash, oid.hash))
-			break;
-
-		if (data->request) {
-			struct object_info oi = *data->request;
-
-			ret = odb_source_read_object_info(&store->base, &oid, &oi, 0);
-			if (ret)
-				goto out;
-
-			ret = data->cb(&oid, &oi, data->cb_data);
-			if (ret)
-				goto out;
-		} else {
-			ret = data->cb(&oid, NULL, data->cb_data);
-			if (ret)
-				goto out;
-		}
-	}
-
-	ret = 0;
-
-out:
-	return ret;
-}
-
-static int packfile_store_for_each_prefixed_object(
-	struct odb_source_packed *store,
-	const struct odb_for_each_object_options *opts,
-	struct odb_source_packed_for_each_object_wrapper_data *data)
-{
-	struct packfile_list_entry *e;
-	struct multi_pack_index *m;
-	bool pack_errors = false;
-	int ret;
-
-	if (opts->flags)
-		BUG("flags unsupported");
-
-	store->skip_mru_updates = true;
-
-	m = get_multi_pack_index(&store->files->base);
-	if (m) {
-		ret = for_each_prefixed_object_in_midx(store, m, opts, data);
-		if (ret)
-			goto out;
-	}
-
-	for (e = packfile_store_get_packs(store); e; e = e->next) {
-		if (e->pack->multi_pack_index)
-			continue;
-
-		if (open_pack_index(e->pack)) {
-			pack_errors = true;
-			continue;
-		}
-
-		if (!e->pack->num_objects)
-			continue;
-
-		ret = for_each_prefixed_object_in_pack(store, e->pack, opts, data);
-		if (ret)
-			goto out;
-	}
-
-	ret = 0;
-
-out:
-	store->skip_mru_updates = false;
-	if (!ret && pack_errors)
-		ret = -1;
-	return ret;
-}
-
-int packfile_store_for_each_object(struct odb_source_packed *store,
-				   const struct object_info *request,
-				   odb_for_each_object_cb cb,
-				   void *cb_data,
-				   const struct odb_for_each_object_options *opts)
-{
-	struct odb_source_packed_for_each_object_wrapper_data data = {
-		.store = store,
-		.request = request,
-		.cb = cb,
-		.cb_data = cb_data,
-	};
-	struct packfile_list_entry *e;
-	int pack_errors = 0, ret;
-
-	if (opts->prefix)
-		return packfile_store_for_each_prefixed_object(store, opts, &data);
-
-	store->skip_mru_updates = true;
-
-	for (e = packfile_store_get_packs(store); e; e = e->next) {
-		struct packed_git *p = e->pack;
-
-		if ((opts->flags & ODB_FOR_EACH_OBJECT_LOCAL_ONLY) && !p->pack_local)
-			continue;
-		if ((opts->flags & ODB_FOR_EACH_OBJECT_PROMISOR_ONLY) &&
-		    !p->pack_promisor)
-			continue;
-		if ((opts->flags & ODB_FOR_EACH_OBJECT_SKIP_IN_CORE_KEPT_PACKS) &&
-		    p->pack_keep_in_core)
-			continue;
-		if ((opts->flags & ODB_FOR_EACH_OBJECT_SKIP_ON_DISK_KEPT_PACKS) &&
-		    p->pack_keep)
-			continue;
-		if (open_pack_index(p)) {
-			pack_errors = 1;
-			continue;
-		}
-
-		ret = for_each_object_in_pack(p, packfile_store_for_each_object_wrapper,
-					      &data, opts->flags);
-		if (ret)
-			goto out;
-	}
-
-	ret = 0;
-
-out:
-	store->skip_mru_updates = false;
-
-	if (!ret && pack_errors)
-		ret = -1;
-	return ret;
-}
-
 static int extend_abbrev_len(const struct object_id *a,
 			     const struct object_id *b,
 			     unsigned *out)
diff --git a/packfile.h b/packfile.h
index dd97684e70..0097de0b27 100644
--- a/packfile.h
+++ b/packfile.h
@@ -227,21 +227,6 @@ int for_each_object_in_pack(struct packed_git *p,
 			    each_packed_object_fn, void *data,
 			    enum odb_for_each_object_flags flags);
 
-/*
- * Iterate through all packed objects in the given packfile store and invoke
- * the callback function for each of them. If an object info request is given,
- * then the object info will be read for every individual object and passed to
- * the callback as if `packfile_store_read_object_info()` was called for the
- * object.
- *
- * The flags parameter is a combination of `odb_for_each_object_flags`.
- */
-int packfile_store_for_each_object(struct odb_source_packed *store,
-				   const struct object_info *request,
-				   odb_for_each_object_cb cb,
-				   void *cb_data,
-				   const struct odb_for_each_object_options *opts);
-
 int packfile_store_find_abbrev_len(struct odb_source_packed *store,
 				   const struct object_id *oid,
 				   unsigned min_len,
@@ -354,6 +339,8 @@ extern int do_check_packed_object_crc;
  */
 int packed_object_info(struct packed_git *pack,
 		       off_t offset, struct object_info *);
+int packed_object_info_with_index_pos(struct packed_git *p, off_t obj_offset,
+				      uint32_t *maybe_index_pos, struct object_info *oi);
 
 void mark_bad_packed_object(struct packed_git *, const struct object_id *);
 const struct packed_git *has_packed_and_bad(struct repository *, const struct object_id *);

-- 
2.54.0.1136.gdb2ca164c4.dirty


^ permalink raw reply related

* [PATCH v2 10/17] odb/source-packed: wire up `read_object_stream()` callback
From: Patrick Steinhardt @ 2026-06-09  8:51 UTC (permalink / raw)
  To: git; +Cc: Karthik Nayak
In-Reply-To: <20260609-pks-odb-source-packed-v2-0-839089132c8b@pks.im>

Wire up the `read_object_stream()` callback for the packed source and
call it in the "files" source via the `odb_source_read_object_stream()`
interface.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 odb/source-files.c  |  2 +-
 odb/source-packed.c | 16 ++++++++++++++++
 packfile.c          | 12 ------------
 packfile.h          |  4 ----
 4 files changed, 17 insertions(+), 17 deletions(-)

diff --git a/odb/source-files.c b/odb/source-files.c
index 8cae35d25e..dff69d0e4e 100644
--- a/odb/source-files.c
+++ b/odb/source-files.c
@@ -67,7 +67,7 @@ static int odb_source_files_read_object_stream(struct odb_read_stream **out,
 					       const struct object_id *oid)
 {
 	struct odb_source_files *files = odb_source_files_downcast(source);
-	if (!packfile_store_read_object_stream(out, files->packed, oid) ||
+	if (!odb_source_read_object_stream(out, &files->packed->base, oid) ||
 	    !odb_source_read_object_stream(out, &files->loose->base, oid))
 		return 0;
 	return -1;
diff --git a/odb/source-packed.c b/odb/source-packed.c
index f71a194739..23d7149fe3 100644
--- a/odb/source-packed.c
+++ b/odb/source-packed.c
@@ -2,9 +2,11 @@
 #include "abspath.h"
 #include "chdir-notify.h"
 #include "dir.h"
+#include "git-zlib.h"
 #include "mergesort.h"
 #include "midx.h"
 #include "odb/source-packed.h"
+#include "odb/streaming.h"
 #include "packfile.h"
 
 int find_pack_entry(struct odb_source_packed *store,
@@ -66,6 +68,19 @@ static int odb_source_packed_read_object_info(struct odb_source *source,
 	return 0;
 }
 
+static int odb_source_packed_read_object_stream(struct odb_read_stream **out,
+						struct odb_source *source,
+						const struct object_id *oid)
+{
+	struct odb_source_packed *packed = odb_source_packed_downcast(source);
+	struct pack_entry e;
+
+	if (!find_pack_entry(packed, oid, &e))
+		return -1;
+
+	return packfile_read_object_stream(out, oid, e.p, e.offset);
+}
+
 void (*report_garbage)(unsigned seen_bits, const char *path);
 
 static void report_helper(const struct string_list *list,
@@ -275,6 +290,7 @@ struct odb_source_packed *odb_source_packed_new(struct odb_source_files *parent)
 	packed->base.close = odb_source_packed_close;
 	packed->base.reprepare = odb_source_packed_reprepare;
 	packed->base.read_object_info = odb_source_packed_read_object_info;
+	packed->base.read_object_stream = odb_source_packed_read_object_stream;
 
 	if (!is_absolute_path(parent->base.path))
 		chdir_notify_register(NULL, odb_source_packed_reparent, packed);
diff --git a/packfile.c b/packfile.c
index 29530532ba..42c84397eb 100644
--- a/packfile.c
+++ b/packfile.c
@@ -2658,15 +2658,3 @@ int packfile_read_object_stream(struct odb_read_stream **out,
 
 	return 0;
 }
-
-int packfile_store_read_object_stream(struct odb_read_stream **out,
-				      struct odb_source_packed *store,
-				      const struct object_id *oid)
-{
-	struct pack_entry e;
-
-	if (!find_pack_entry(store, oid, &e))
-		return -1;
-
-	return packfile_read_object_stream(out, oid, e.p, e.offset);
-}
diff --git a/packfile.h b/packfile.h
index 25d458beb0..dd97684e70 100644
--- a/packfile.h
+++ b/packfile.h
@@ -124,10 +124,6 @@ static inline void repo_for_each_pack_data_next(struct repo_for_each_pack_data *
 	     ((p) = (eack_pack_data.entry ? eack_pack_data.entry->pack : NULL)); \
 	     repo_for_each_pack_data_next(&eack_pack_data))
 
-int packfile_store_read_object_stream(struct odb_read_stream **out,
-				      struct odb_source_packed *store,
-				      const struct object_id *oid);
-
 /*
  * Open the packfile and add it to the store if it isn't yet known. Returns
  * either the newly opened packfile or the preexisting packfile. Returns a

-- 
2.54.0.1136.gdb2ca164c4.dirty


^ permalink raw reply related

* [PATCH v2 09/17] odb/source-packed: wire up `read_object_info()` callback
From: Patrick Steinhardt @ 2026-06-09  8:51 UTC (permalink / raw)
  To: git; +Cc: Karthik Nayak
In-Reply-To: <20260609-pks-odb-source-packed-v2-0-839089132c8b@pks.im>

Move the logic to read object info from a "packed" source into
"odb/source-packed.c" and wire it up as the `read_object_info()`
callback.

Note that we also move around the supporting `find_pack_entry()`, but we
still have to expose it to other callers that exist in "packfile.c".
This will be fixed in subsequent commits though, where all callers in
"packfile.c" will have been moved into "odb/source-packed.c", and at
that point we'll be able to make `find_pack_entry()` file-local again.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 odb/source-files.c  |  2 +-
 odb/source-packed.c | 60 +++++++++++++++++++++++++++++++++++++++++++
 odb/source-packed.h |  6 +++++
 packfile.c          | 74 ++++++-----------------------------------------------
 packfile.h          | 15 +++--------
 5 files changed, 79 insertions(+), 78 deletions(-)

diff --git a/odb/source-files.c b/odb/source-files.c
index 7b1e0ac565..8cae35d25e 100644
--- a/odb/source-files.c
+++ b/odb/source-files.c
@@ -55,7 +55,7 @@ static int odb_source_files_read_object_info(struct odb_source *source,
 {
 	struct odb_source_files *files = odb_source_files_downcast(source);
 
-	if (!packfile_store_read_object_info(files->packed, oid, oi, flags) ||
+	if (!odb_source_read_object_info(&files->packed->base, oid, oi, flags) ||
 	    !odb_source_read_object_info(&files->loose->base, oid, oi, flags))
 		return 0;
 
diff --git a/odb/source-packed.c b/odb/source-packed.c
index e8e2e5bb48..f71a194739 100644
--- a/odb/source-packed.c
+++ b/odb/source-packed.c
@@ -7,6 +7,65 @@
 #include "odb/source-packed.h"
 #include "packfile.h"
 
+int find_pack_entry(struct odb_source_packed *store,
+		    const struct object_id *oid,
+		    struct pack_entry *e)
+{
+	struct packfile_list_entry *l;
+
+	odb_source_packed_prepare(store);
+	if (store->midx && fill_midx_entry(store->midx, oid, e))
+		return 1;
+
+	for (l = store->packs.head; l; l = l->next) {
+		struct packed_git *p = l->pack;
+
+		if (!p->multi_pack_index && packfile_fill_entry(p, oid, e)) {
+			if (!store->skip_mru_updates)
+				packfile_list_prepend(&store->packs, p);
+			return 1;
+		}
+	}
+
+	return 0;
+}
+
+static int odb_source_packed_read_object_info(struct odb_source *source,
+					      const struct object_id *oid,
+					      struct object_info *oi,
+					      enum object_info_flags flags)
+{
+	struct odb_source_packed *packed = odb_source_packed_downcast(source);
+	struct pack_entry e;
+	int ret;
+
+	/*
+	 * In case the first read didn't surface the object, we have to reload
+	 * packfiles. This may cause us to discover new packfiles that have
+	 * been added since the last time we have prepared the packfile store.
+	 */
+	if (flags & OBJECT_INFO_SECOND_READ)
+		odb_source_reprepare(source);
+
+	if (!find_pack_entry(packed, oid, &e))
+		return 1;
+
+	/*
+	 * We know that the caller doesn't actually need the
+	 * information below, so return early.
+	 */
+	if (!oi)
+		return 0;
+
+	ret = packed_object_info(e.p, e.offset, oi);
+	if (ret < 0) {
+		mark_bad_packed_object(e.p, oid);
+		return -1;
+	}
+
+	return 0;
+}
+
 void (*report_garbage)(unsigned seen_bits, const char *path);
 
 static void report_helper(const struct string_list *list,
@@ -215,6 +274,7 @@ struct odb_source_packed *odb_source_packed_new(struct odb_source_files *parent)
 	packed->base.free = odb_source_packed_free;
 	packed->base.close = odb_source_packed_close;
 	packed->base.reprepare = odb_source_packed_reprepare;
+	packed->base.read_object_info = odb_source_packed_read_object_info;
 
 	if (!is_absolute_path(parent->base.path))
 		chdir_notify_register(NULL, odb_source_packed_reparent, packed);
diff --git a/odb/source-packed.h b/odb/source-packed.h
index 9d4796261a..f430ee0b94 100644
--- a/odb/source-packed.h
+++ b/odb/source-packed.h
@@ -90,4 +90,10 @@ static inline struct odb_source_packed *odb_source_packed_downcast(struct odb_so
  */
 void odb_source_packed_prepare(struct odb_source_packed *source);
 
+struct pack_entry;
+
+int find_pack_entry(struct odb_source_packed *store,
+		    const struct object_id *oid,
+		    struct pack_entry *e);
+
 #endif
diff --git a/packfile.c b/packfile.c
index b35afd7797..29530532ba 100644
--- a/packfile.c
+++ b/packfile.c
@@ -1895,9 +1895,9 @@ int is_pack_valid(struct packed_git *p)
 	return !open_packed_git(p);
 }
 
-static int fill_pack_entry(const struct object_id *oid,
-			   struct pack_entry *e,
-			   struct packed_git *p)
+int packfile_fill_entry(struct packed_git *p,
+			const struct object_id *oid,
+			struct pack_entry *e)
 {
 	off_t offset;
 
@@ -1923,29 +1923,6 @@ static int fill_pack_entry(const struct object_id *oid,
 	return 1;
 }
 
-static int find_pack_entry(struct odb_source_packed *store,
-			   const struct object_id *oid,
-			   struct pack_entry *e)
-{
-	struct packfile_list_entry *l;
-
-	odb_source_packed_prepare(store);
-	if (store->midx && fill_midx_entry(store->midx, oid, e))
-		return 1;
-
-	for (l = store->packs.head; l; l = l->next) {
-		struct packed_git *p = l->pack;
-
-		if (!p->multi_pack_index && fill_pack_entry(oid, e, p)) {
-			if (!store->skip_mru_updates)
-				packfile_list_prepend(&store->packs, p);
-			return 1;
-		}
-	}
-
-	return 0;
-}
-
 int packfile_store_freshen_object(struct odb_source_packed *store,
 				  const struct object_id *oid)
 {
@@ -1962,41 +1939,6 @@ int packfile_store_freshen_object(struct odb_source_packed *store,
 	return 1;
 }
 
-int packfile_store_read_object_info(struct odb_source_packed *store,
-				    const struct object_id *oid,
-				    struct object_info *oi,
-				    enum object_info_flags flags)
-{
-	struct pack_entry e;
-	int ret;
-
-	/*
-	 * In case the first read didn't surface the object, we have to reload
-	 * packfiles. This may cause us to discover new packfiles that have
-	 * been added since the last time we have prepared the packfile store.
-	 */
-	if (flags & OBJECT_INFO_SECOND_READ)
-		odb_source_reprepare(&store->base);
-
-	if (!find_pack_entry(store, oid, &e))
-		return 1;
-
-	/*
-	 * We know that the caller doesn't actually need the
-	 * information below, so return early.
-	 */
-	if (!oi)
-		return 0;
-
-	ret = packed_object_info(e.p, e.offset, oi);
-	if (ret < 0) {
-		mark_bad_packed_object(e.p, oid);
-		return -1;
-	}
-
-	return 0;
-}
-
 static void maybe_invalidate_kept_pack_cache(struct odb_source_packed *store,
 					     unsigned flags)
 {
@@ -2053,7 +1995,7 @@ int has_object_pack(struct repository *r, const struct object_id *oid)
 	odb_prepare_alternates(r->objects);
 	for (source = r->objects->sources; source; source = source->next) {
 		struct odb_source_files *files = odb_source_files_downcast(source);
-		if (!packfile_store_read_object_info(files->packed, oid, NULL, 0))
+		if (!odb_source_read_object_info(&files->packed->base, oid, NULL, 0))
 			return 1;
 	}
 
@@ -2074,7 +2016,7 @@ int has_object_kept_pack(struct repository *r, const struct object_id *oid,
 
 		for (; *cache; cache++) {
 			struct packed_git *p = *cache;
-			if (fill_pack_entry(oid, &e, p))
+			if (packfile_fill_entry(p, oid, &e))
 				return 1;
 		}
 	}
@@ -2208,8 +2150,8 @@ static int for_each_prefixed_object_in_midx(
 			if (data->request) {
 				struct object_info oi = *data->request;
 
-				ret = packfile_store_read_object_info(store, current,
-								      &oi, 0);
+				ret = odb_source_read_object_info(&store->base, current,
+								  &oi, 0);
 				if (ret)
 					goto out;
 
@@ -2259,7 +2201,7 @@ static int for_each_prefixed_object_in_pack(
 		if (data->request) {
 			struct object_info oi = *data->request;
 
-			ret = packfile_store_read_object_info(store, &oid, &oi, 0);
+			ret = odb_source_read_object_info(&store->base, &oid, &oi, 0);
 			if (ret)
 				goto out;
 
diff --git a/packfile.h b/packfile.h
index 9674e573ae..25d458beb0 100644
--- a/packfile.h
+++ b/packfile.h
@@ -128,17 +128,6 @@ int packfile_store_read_object_stream(struct odb_read_stream **out,
 				      struct odb_source_packed *store,
 				      const struct object_id *oid);
 
-/*
- * Try to read the object identified by its ID from the object store and
- * populate the object info with its data. Returns 1 in case the object was
- * not found, 0 if it was and read successfully, and a negative error code in
- * case the object was corrupted.
- */
-int packfile_store_read_object_info(struct odb_source_packed *store,
-				    const struct object_id *oid,
-				    struct object_info *oi,
-				    enum object_info_flags flags);
-
 /*
  * Open the packfile and add it to the store if it isn't yet known. Returns
  * either the newly opened packfile or the preexisting packfile. Returns a
@@ -340,6 +329,10 @@ off_t nth_packed_object_offset(const struct packed_git *, uint32_t n);
  */
 off_t find_pack_entry_one(const struct object_id *oid, struct packed_git *);
 
+int packfile_fill_entry(struct packed_git *p,
+			const struct object_id *oid,
+			struct pack_entry *e);
+
 int is_pack_valid(struct packed_git *);
 void *unpack_entry(struct repository *r, struct packed_git *, off_t, enum object_type *, unsigned long *);
 unsigned long unpack_object_header_buffer(const unsigned char *buf, unsigned long len, enum object_type *type, size_t *sizep);

-- 
2.54.0.1136.gdb2ca164c4.dirty


^ permalink raw reply related

* [PATCH v2 08/17] packfile: use higher-level interface to implement `has_object_pack()`
From: Patrick Steinhardt @ 2026-06-09  8:51 UTC (permalink / raw)
  To: git; +Cc: Karthik Nayak
In-Reply-To: <20260609-pks-odb-source-packed-v2-0-839089132c8b@pks.im>

In `has_object_pack()` we're checking whether a specific object exists
as part of a packfile. This is done by calling the low-level function
`find_pack_entry()`, but this function will eventually be moved into
"odb/source-packed.c" and made file-local.

Refactor the code to use `packfile_store_read_object_info()` instead.
This refactoring is functionally equivalent as that function will call
`find_pack_entry()` itself and then return immediately when it ain't got
no object info pointer as parameter.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 packfile.c | 6 ++----
 1 file changed, 2 insertions(+), 4 deletions(-)

diff --git a/packfile.c b/packfile.c
index 65631f674f..b35afd7797 100644
--- a/packfile.c
+++ b/packfile.c
@@ -2049,14 +2049,12 @@ struct packed_git **packfile_store_get_kept_pack_cache(struct odb_source_packed
 int has_object_pack(struct repository *r, const struct object_id *oid)
 {
 	struct odb_source *source;
-	struct pack_entry e;
 
 	odb_prepare_alternates(r->objects);
 	for (source = r->objects->sources; source; source = source->next) {
 		struct odb_source_files *files = odb_source_files_downcast(source);
-		int ret = find_pack_entry(files->packed, oid, &e);
-		if (ret)
-			return ret;
+		if (!packfile_store_read_object_info(files->packed, oid, NULL, 0))
+			return 1;
 	}
 
 	return 0;

-- 
2.54.0.1136.gdb2ca164c4.dirty


^ permalink raw reply related

* [PATCH v2 07/17] odb/source-packed: wire up `reprepare()` callback
From: Patrick Steinhardt @ 2026-06-09  8:51 UTC (permalink / raw)
  To: git; +Cc: Karthik Nayak
In-Reply-To: <20260609-pks-odb-source-packed-v2-0-839089132c8b@pks.im>

Move the logic to prepare and reprepare the "packed" source into
"odb/source-packed.c" and wire it up as the `reprepare()` callback.

Note that "preparing" a source is not yet generic. Eventually, it would
probably make sense to turn the existing `reprepare()` callback into a
`prepare()` callback with an optional flag to force re-preparing. But
this step will be handled in a separate patch series.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 builtin/grep.c      |   2 +-
 midx.c              |   2 +-
 odb/source-files.c  |   2 +-
 odb/source-packed.c | 157 +++++++++++++++++++++++++++++++++++++++++++++++++++
 odb/source-packed.h |   9 +++
 packfile.c          | 160 +---------------------------------------------------
 packfile.h          |  17 ------
 7 files changed, 172 insertions(+), 177 deletions(-)

diff --git a/builtin/grep.c b/builtin/grep.c
index 6a09571903..8080d1bf5e 100644
--- a/builtin/grep.c
+++ b/builtin/grep.c
@@ -1363,7 +1363,7 @@ int cmd_grep(int argc,
 			odb_prepare_alternates(the_repository->objects);
 			for (source = the_repository->objects->sources; source; source = source->next) {
 				struct odb_source_files *files = odb_source_files_downcast(source);
-				packfile_store_prepare(files->packed);
+				odb_source_packed_prepare(files->packed);
 			}
 		}
 
diff --git a/midx.c b/midx.c
index efbfbb13f4..00bbd137b2 100644
--- a/midx.c
+++ b/midx.c
@@ -102,7 +102,7 @@ static int midx_read_object_offsets(const unsigned char *chunk_start,
 struct multi_pack_index *get_multi_pack_index(struct odb_source *source)
 {
 	struct odb_source_files *files = odb_source_files_downcast(source);
-	packfile_store_prepare(files->packed);
+	odb_source_packed_prepare(files->packed);
 	return files->packed->midx;
 }
 
diff --git a/odb/source-files.c b/odb/source-files.c
index 9b0fa9ccdc..7b1e0ac565 100644
--- a/odb/source-files.c
+++ b/odb/source-files.c
@@ -45,7 +45,7 @@ static void odb_source_files_reprepare(struct odb_source *source)
 {
 	struct odb_source_files *files = odb_source_files_downcast(source);
 	odb_source_reprepare(&files->loose->base);
-	packfile_store_reprepare(files->packed);
+	odb_source_reprepare(&files->packed->base);
 }
 
 static int odb_source_files_read_object_info(struct odb_source *source,
diff --git a/odb/source-packed.c b/odb/source-packed.c
index 74805be1dd..e8e2e5bb48 100644
--- a/odb/source-packed.c
+++ b/odb/source-packed.c
@@ -1,10 +1,166 @@
 #include "git-compat-util.h"
 #include "abspath.h"
 #include "chdir-notify.h"
+#include "dir.h"
+#include "mergesort.h"
 #include "midx.h"
 #include "odb/source-packed.h"
 #include "packfile.h"
 
+void (*report_garbage)(unsigned seen_bits, const char *path);
+
+static void report_helper(const struct string_list *list,
+			  int seen_bits, int first, int last)
+{
+	if (seen_bits == (PACKDIR_FILE_PACK|PACKDIR_FILE_IDX))
+		return;
+
+	for (; first < last; first++)
+		report_garbage(seen_bits, list->items[first].string);
+}
+
+static void report_pack_garbage(struct string_list *list)
+{
+	int baselen = -1, first = 0, seen_bits = 0;
+
+	if (!report_garbage)
+		return;
+
+	string_list_sort(list);
+
+	for (size_t i = 0; i < list->nr; i++) {
+		const char *path = list->items[i].string;
+		if (baselen != -1 &&
+		    strncmp(path, list->items[first].string, baselen)) {
+			report_helper(list, seen_bits, first, i);
+			baselen = -1;
+			seen_bits = 0;
+		}
+		if (baselen == -1) {
+			const char *dot = strrchr(path, '.');
+			if (!dot) {
+				report_garbage(PACKDIR_FILE_GARBAGE, path);
+				continue;
+			}
+			baselen = dot - path + 1;
+			first = i;
+		}
+		if (!strcmp(path + baselen, "pack"))
+			seen_bits |= 1;
+		else if (!strcmp(path + baselen, "idx"))
+			seen_bits |= 2;
+	}
+	report_helper(list, seen_bits, first, list->nr);
+}
+
+struct prepare_pack_data {
+	struct odb_source *source;
+	struct string_list *garbage;
+};
+
+static void prepare_pack(const char *full_name, size_t full_name_len,
+			 const char *file_name, void *_data)
+{
+	struct prepare_pack_data *data = (struct prepare_pack_data *)_data;
+	struct odb_source_files *files = odb_source_files_downcast(data->source);
+	size_t base_len = full_name_len;
+
+	if (strip_suffix_mem(full_name, &base_len, ".idx") &&
+	    !(files->packed->midx &&
+	      midx_contains_pack(files->packed->midx, file_name))) {
+		char *trimmed_path = xstrndup(full_name, full_name_len);
+		packfile_store_load_pack(files->packed,
+					 trimmed_path, data->source->local);
+		free(trimmed_path);
+	}
+
+	if (!report_garbage)
+		return;
+
+	if (!strcmp(file_name, "multi-pack-index") ||
+	    !strcmp(file_name, "multi-pack-index.d"))
+		return;
+	if (starts_with(file_name, "multi-pack-index") &&
+	    (ends_with(file_name, ".bitmap") || ends_with(file_name, ".rev")))
+		return;
+	if (ends_with(file_name, ".idx") ||
+	    ends_with(file_name, ".rev") ||
+	    ends_with(file_name, ".pack") ||
+	    ends_with(file_name, ".bitmap") ||
+	    ends_with(file_name, ".keep") ||
+	    ends_with(file_name, ".promisor") ||
+	    ends_with(file_name, ".mtimes"))
+		string_list_append(data->garbage, full_name);
+	else
+		report_garbage(PACKDIR_FILE_GARBAGE, full_name);
+}
+
+static void prepare_packed_git_one(struct odb_source *source)
+{
+	struct string_list garbage = STRING_LIST_INIT_DUP;
+	struct prepare_pack_data data = {
+		.source = source,
+		.garbage = &garbage,
+	};
+
+	for_each_file_in_pack_dir(source->path, prepare_pack, &data);
+
+	report_pack_garbage(data.garbage);
+	string_list_clear(data.garbage, 0);
+}
+
+DEFINE_LIST_SORT(static, sort_packs, struct packfile_list_entry, next);
+
+static int sort_pack(const struct packfile_list_entry *a,
+		     const struct packfile_list_entry *b)
+{
+	int st;
+
+	/*
+	 * Local packs tend to contain objects specific to our
+	 * variant of the project than remote ones.  In addition,
+	 * remote ones could be on a network mounted filesystem.
+	 * Favor local ones for these reasons.
+	 */
+	st = a->pack->pack_local - b->pack->pack_local;
+	if (st)
+		return -st;
+
+	/*
+	 * Younger packs tend to contain more recent objects,
+	 * and more recent objects tend to get accessed more
+	 * often.
+	 */
+	if (a->pack->mtime < b->pack->mtime)
+		return 1;
+	else if (a->pack->mtime == b->pack->mtime)
+		return 0;
+	return -1;
+}
+
+void odb_source_packed_prepare(struct odb_source_packed *source)
+{
+	if (source->initialized)
+		return;
+
+	prepare_multi_pack_index_one(&source->files->base);
+	prepare_packed_git_one(&source->files->base);
+
+	sort_packs(&source->packs.head, sort_pack);
+	for (struct packfile_list_entry *e = source->packs.head; e; e = e->next)
+		if (!e->next)
+			source->packs.tail = e;
+
+	source->initialized = true;
+}
+
+static void odb_source_packed_reprepare(struct odb_source *source)
+{
+	struct odb_source_packed *packed = odb_source_packed_downcast(source);
+	packed->initialized = false;
+	odb_source_packed_prepare(packed);
+}
+
 static void odb_source_packed_reparent(const char *name UNUSED,
 				       const char *old_cwd,
 				       const char *new_cwd,
@@ -58,6 +214,7 @@ struct odb_source_packed *odb_source_packed_new(struct odb_source_files *parent)
 
 	packed->base.free = odb_source_packed_free;
 	packed->base.close = odb_source_packed_close;
+	packed->base.reprepare = odb_source_packed_reprepare;
 
 	if (!is_absolute_path(parent->base.path))
 		chdir_notify_register(NULL, odb_source_packed_reparent, packed);
diff --git a/odb/source-packed.h b/odb/source-packed.h
index 68e64cabab..9d4796261a 100644
--- a/odb/source-packed.h
+++ b/odb/source-packed.h
@@ -81,4 +81,13 @@ static inline struct odb_source_packed *odb_source_packed_downcast(struct odb_so
 	return container_of(source, struct odb_source_packed, base);
 }
 
+/*
+ * Prepare the source by loading packfiles and multi-pack indices for
+ * all alternates. This becomes a no-op if the source is already prepared.
+ *
+ * It shouldn't typically be necessary to call this function directly, as
+ * functions that access the source know to prepare it.
+ */
+void odb_source_packed_prepare(struct odb_source_packed *source);
+
 #endif
diff --git a/packfile.c b/packfile.c
index e5386145a7..65631f674f 100644
--- a/packfile.c
+++ b/packfile.c
@@ -8,7 +8,6 @@
 #include "pack.h"
 #include "repository.h"
 #include "dir.h"
-#include "mergesort.h"
 #include "packfile.h"
 #include "delta.h"
 #include "hash-lookup.h"
@@ -812,52 +811,6 @@ struct packed_git *packfile_store_load_pack(struct odb_source_packed *store,
 	return p;
 }
 
-void (*report_garbage)(unsigned seen_bits, const char *path);
-
-static void report_helper(const struct string_list *list,
-			  int seen_bits, int first, int last)
-{
-	if (seen_bits == (PACKDIR_FILE_PACK|PACKDIR_FILE_IDX))
-		return;
-
-	for (; first < last; first++)
-		report_garbage(seen_bits, list->items[first].string);
-}
-
-static void report_pack_garbage(struct string_list *list)
-{
-	int i, baselen = -1, first = 0, seen_bits = 0;
-
-	if (!report_garbage)
-		return;
-
-	string_list_sort(list);
-
-	for (i = 0; i < list->nr; i++) {
-		const char *path = list->items[i].string;
-		if (baselen != -1 &&
-		    strncmp(path, list->items[first].string, baselen)) {
-			report_helper(list, seen_bits, first, i);
-			baselen = -1;
-			seen_bits = 0;
-		}
-		if (baselen == -1) {
-			const char *dot = strrchr(path, '.');
-			if (!dot) {
-				report_garbage(PACKDIR_FILE_GARBAGE, path);
-				continue;
-			}
-			baselen = dot - path + 1;
-			first = i;
-		}
-		if (!strcmp(path + baselen, "pack"))
-			seen_bits |= 1;
-		else if (!strcmp(path + baselen, "idx"))
-			seen_bits |= 2;
-	}
-	report_helper(list, seen_bits, first, list->nr);
-}
-
 void for_each_file_in_pack_subdir(const char *objdir,
 				  const char *subdir,
 				  each_file_in_pack_dir_fn fn,
@@ -900,116 +853,9 @@ void for_each_file_in_pack_dir(const char *objdir,
 	for_each_file_in_pack_subdir(objdir, NULL, fn, data);
 }
 
-struct prepare_pack_data {
-	struct odb_source *source;
-	struct string_list *garbage;
-};
-
-static void prepare_pack(const char *full_name, size_t full_name_len,
-			 const char *file_name, void *_data)
-{
-	struct prepare_pack_data *data = (struct prepare_pack_data *)_data;
-	struct odb_source_files *files = odb_source_files_downcast(data->source);
-	size_t base_len = full_name_len;
-
-	if (strip_suffix_mem(full_name, &base_len, ".idx") &&
-	    !(files->packed->midx &&
-	      midx_contains_pack(files->packed->midx, file_name))) {
-		char *trimmed_path = xstrndup(full_name, full_name_len);
-		packfile_store_load_pack(files->packed,
-					 trimmed_path, data->source->local);
-		free(trimmed_path);
-	}
-
-	if (!report_garbage)
-		return;
-
-	if (!strcmp(file_name, "multi-pack-index") ||
-	    !strcmp(file_name, "multi-pack-index.d"))
-		return;
-	if (starts_with(file_name, "multi-pack-index") &&
-	    (ends_with(file_name, ".bitmap") || ends_with(file_name, ".rev")))
-		return;
-	if (ends_with(file_name, ".idx") ||
-	    ends_with(file_name, ".rev") ||
-	    ends_with(file_name, ".pack") ||
-	    ends_with(file_name, ".bitmap") ||
-	    ends_with(file_name, ".keep") ||
-	    ends_with(file_name, ".promisor") ||
-	    ends_with(file_name, ".mtimes"))
-		string_list_append(data->garbage, full_name);
-	else
-		report_garbage(PACKDIR_FILE_GARBAGE, full_name);
-}
-
-static void prepare_packed_git_one(struct odb_source *source)
-{
-	struct string_list garbage = STRING_LIST_INIT_DUP;
-	struct prepare_pack_data data = {
-		.source = source,
-		.garbage = &garbage,
-	};
-
-	for_each_file_in_pack_dir(source->path, prepare_pack, &data);
-
-	report_pack_garbage(data.garbage);
-	string_list_clear(data.garbage, 0);
-}
-
-DEFINE_LIST_SORT(static, sort_packs, struct packfile_list_entry, next);
-
-static int sort_pack(const struct packfile_list_entry *a,
-		     const struct packfile_list_entry *b)
-{
-	int st;
-
-	/*
-	 * Local packs tend to contain objects specific to our
-	 * variant of the project than remote ones.  In addition,
-	 * remote ones could be on a network mounted filesystem.
-	 * Favor local ones for these reasons.
-	 */
-	st = a->pack->pack_local - b->pack->pack_local;
-	if (st)
-		return -st;
-
-	/*
-	 * Younger packs tend to contain more recent objects,
-	 * and more recent objects tend to get accessed more
-	 * often.
-	 */
-	if (a->pack->mtime < b->pack->mtime)
-		return 1;
-	else if (a->pack->mtime == b->pack->mtime)
-		return 0;
-	return -1;
-}
-
-void packfile_store_prepare(struct odb_source_packed *store)
-{
-	if (store->initialized)
-		return;
-
-	prepare_multi_pack_index_one(&store->files->base);
-	prepare_packed_git_one(&store->files->base);
-
-	sort_packs(&store->packs.head, sort_pack);
-	for (struct packfile_list_entry *e = store->packs.head; e; e = e->next)
-		if (!e->next)
-			store->packs.tail = e;
-
-	store->initialized = true;
-}
-
-void packfile_store_reprepare(struct odb_source_packed *store)
-{
-	store->initialized = false;
-	packfile_store_prepare(store);
-}
-
 struct packfile_list_entry *packfile_store_get_packs(struct odb_source_packed *store)
 {
-	packfile_store_prepare(store);
+	odb_source_packed_prepare(store);
 
 	if (store->midx) {
 		struct multi_pack_index *m = store->midx;
@@ -2083,7 +1929,7 @@ static int find_pack_entry(struct odb_source_packed *store,
 {
 	struct packfile_list_entry *l;
 
-	packfile_store_prepare(store);
+	odb_source_packed_prepare(store);
 	if (store->midx && fill_midx_entry(store->midx, oid, e))
 		return 1;
 
@@ -2130,7 +1976,7 @@ int packfile_store_read_object_info(struct odb_source_packed *store,
 	 * been added since the last time we have prepared the packfile store.
 	 */
 	if (flags & OBJECT_INFO_SECOND_READ)
-		packfile_store_reprepare(store);
+		odb_source_reprepare(&store->base);
 
 	if (!find_pack_entry(store, oid, &e))
 		return 1;
diff --git a/packfile.h b/packfile.h
index 9dc3a13112..9674e573ae 100644
--- a/packfile.h
+++ b/packfile.h
@@ -55,23 +55,6 @@ struct packed_git {
 	char pack_name[FLEX_ARRAY]; /* more */
 };
 
-/*
- * Prepare the packfile store by loading packfiles and multi-pack indices for
- * all alternates. This becomes a no-op if the store is already prepared.
- *
- * It shouldn't typically be necessary to call this function directly, as
- * functions that access the store know to prepare it.
- */
-void packfile_store_prepare(struct odb_source_packed *store);
-
-/*
- * Clear the packfile caches and try to look up any new packfiles that have
- * appeared since last preparing the packfiles store.
- *
- * This function must be called under the `odb_read_lock()`.
- */
-void packfile_store_reprepare(struct odb_source_packed *store);
-
 /*
  * Add the pack to the store so that contained objects become accessible via
  * the store. This moves ownership into the store.

-- 
2.54.0.1136.gdb2ca164c4.dirty


^ permalink raw reply related

* [PATCH v2 06/17] odb/source-packed: wire up `close()` callback
From: Patrick Steinhardt @ 2026-06-09  8:50 UTC (permalink / raw)
  To: git; +Cc: Karthik Nayak
In-Reply-To: <20260609-pks-odb-source-packed-v2-0-839089132c8b@pks.im>

Wire up a new `close()` callback for the packed source and call it from
the "files" source via the generic `odb_source_close()` interface.

Signed-off-by: Patrick Steinhardt <ps@pks.im>
---
 odb/source-files.c  |  2 +-
 odb/source-packed.c | 16 ++++++++++++++++
 packfile.c          | 12 ------------
 packfile.h          |  6 ------
 4 files changed, 17 insertions(+), 19 deletions(-)

diff --git a/odb/source-files.c b/odb/source-files.c
index 3608808e7c..9b0fa9ccdc 100644
--- a/odb/source-files.c
+++ b/odb/source-files.c
@@ -38,7 +38,7 @@ static void odb_source_files_close(struct odb_source *source)
 {
 	struct odb_source_files *files = odb_source_files_downcast(source);
 	odb_source_close(&files->loose->base);
-	packfile_store_close(files->packed);
+	odb_source_close(&files->packed->base);
 }
 
 static void odb_source_files_reprepare(struct odb_source *source)
diff --git a/odb/source-packed.c b/odb/source-packed.c
index f81a990cbd..74805be1dd 100644
--- a/odb/source-packed.c
+++ b/odb/source-packed.c
@@ -1,6 +1,7 @@
 #include "git-compat-util.h"
 #include "abspath.h"
 #include "chdir-notify.h"
+#include "midx.h"
 #include "odb/source-packed.h"
 #include "packfile.h"
 
@@ -16,6 +17,20 @@ static void odb_source_packed_reparent(const char *name UNUSED,
 	packed->base.path = path;
 }
 
+static void odb_source_packed_close(struct odb_source *source)
+{
+	struct odb_source_packed *packed = odb_source_packed_downcast(source);
+
+	for (struct packfile_list_entry *e = packed->packs.head; e; e = e->next) {
+		if (e->pack->do_not_close)
+			BUG("want to close pack marked 'do-not-close'");
+		close_pack(e->pack);
+	}
+	if (packed->midx)
+		close_midx(packed->midx);
+	packed->midx = NULL;
+}
+
 static void odb_source_packed_free(struct odb_source *source)
 {
 	struct odb_source_packed *packed = odb_source_packed_downcast(source);
@@ -42,6 +57,7 @@ struct odb_source_packed *odb_source_packed_new(struct odb_source_files *parent)
 	strmap_init(&packed->packs_by_path);
 
 	packed->base.free = odb_source_packed_free;
+	packed->base.close = odb_source_packed_close;
 
 	if (!is_absolute_path(parent->base.path))
 		chdir_notify_register(NULL, odb_source_packed_reparent, packed);
diff --git a/packfile.c b/packfile.c
index 6d492216de..e5386145a7 100644
--- a/packfile.c
+++ b/packfile.c
@@ -2749,18 +2749,6 @@ int parse_pack_header_option(const char *in, unsigned char *out, unsigned int *l
 	return 0;
 }
 
-void packfile_store_close(struct odb_source_packed *store)
-{
-	for (struct packfile_list_entry *e = store->packs.head; e; e = e->next) {
-		if (e->pack->do_not_close)
-			BUG("want to close pack marked 'do-not-close'");
-		close_pack(e->pack);
-	}
-	if (store->midx)
-		close_midx(store->midx);
-	store->midx = NULL;
-}
-
 struct odb_packed_read_stream {
 	struct odb_read_stream base;
 	struct packed_git *pack;
diff --git a/packfile.h b/packfile.h
index e8bc9349f8..9dc3a13112 100644
--- a/packfile.h
+++ b/packfile.h
@@ -55,12 +55,6 @@ struct packed_git {
 	char pack_name[FLEX_ARRAY]; /* more */
 };
 
-/*
- * Close all packfiles associated with this store. The packfiles won't be
- * free'd, so they can be re-opened at a later point in time.
- */
-void packfile_store_close(struct odb_source_packed *store);
-
 /*
  * Prepare the packfile store by loading packfiles and multi-pack indices for
  * all alternates. This becomes a no-op if the store is already prepared.

-- 
2.54.0.1136.gdb2ca164c4.dirty


^ 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