Merge branch 'ps/maintenance-missing-tasks' into seen

Make repository clean-up tasks "gc" can do available to "git
maintenance" front-end.

Comments?

* ps/maintenance-missing-tasks:
  builtin/maintenance: introduce "rerere-gc" task
  builtin/gc: move rerere garbage collection into separate function
  rerere: provide function to collect stale entries
  builtin/maintenance: introduce "worktree-prune" task
  worktree: expose function to retrieve worktree names
  builtin/gc: move pruning of worktrees into a separate function
  builtin/gc: remove global variables where it trivial to do
  builtin/gc: fix indentation of `cmd_gc()` parameters
Junio C Hamano 2025-05-01 14:07:31 -07:00
commit 3224af98ff
9 changed files with 399 additions and 72 deletions

View File

@ -83,3 +83,19 @@ maintenance.reflog-expire.auto::
positive value implies the command should run when the number of
expired reflog entries in the "HEAD" reflog is at least the value of
`maintenance.loose-objects.auto`. The default value is 100.

maintenance.rerere-gc.auto::
This integer config option controls how often the `rerere-gc` task
should be run as part of `git maintenance run --auto`. If zero, then
the `rerere-gc` task will not run with the `--auto` option. A negative
value will force the task to run every time. Otherwise, a positive
value implies the command should run when the number of prunable rerere
entries exceeds the value. The default value is 20.

maintenance.worktree-prune.auto::
This integer config option controls how often the `worktree-prune` task
should be run as part of `git maintenance run --auto`. If zero, then
the `worktree-prune` task will not run with the `--auto` option. A
negative value will force the task to run every time. Otherwise, a
positive value implies the command should run when the number of
prunable worktrees exceeds the value. The default value is 1.

View File

@ -166,6 +166,14 @@ reflog-expire::
The `reflog-expire` task deletes any entries in the reflog older than the
expiry threshold. See linkgit:git-reflog[1] for more information.

rerere-gc::
The `rerere-gc` task invokes garbage collection for stale entries in
the rerere cache. See linkgit:git-rerere[1] for more information.

worktree-prune::
The `worktree-prune` task deletes stale or broken worktrees. See
linkit:git-worktree[1] for more information.

OPTIONS
-------
--auto::

View File

@ -16,6 +16,7 @@
#include "builtin.h"
#include "abspath.h"
#include "date.h"
#include "dir.h"
#include "environment.h"
#include "hex.h"
#include "config.h"
@ -33,6 +34,7 @@
#include "pack-objects.h"
#include "path.h"
#include "reflog.h"
#include "rerere.h"
#include "blob.h"
#include "tree.h"
#include "promisor-remote.h"
@ -43,6 +45,7 @@
#include "hook.h"
#include "setup.h"
#include "trace2.h"
#include "worktree.h"

#define FAILED_RUN "failed to run %s"

@ -52,15 +55,9 @@ static const char * const builtin_gc_usage[] = {
};

static timestamp_t gc_log_expire_time;

static struct strvec repack = STRVEC_INIT;
static struct strvec prune = STRVEC_INIT;
static struct strvec prune_worktrees = STRVEC_INIT;
static struct strvec rerere = STRVEC_INIT;

static struct tempfile *pidfile;
static struct lock_file log_lock;

static struct string_list pack_garbage = STRING_LIST_INIT_DUP;

static void clean_pack_garbage(void)
@ -339,6 +336,99 @@ static int maintenance_task_reflog_expire(struct maintenance_run_opts *opts UNUS
return run_command(&cmd);
}

static int maintenance_task_worktree_prune(struct maintenance_run_opts *opts UNUSED,
struct gc_config *cfg)
{
struct child_process prune_worktrees_cmd = CHILD_PROCESS_INIT;

prune_worktrees_cmd.git_cmd = 1;
strvec_pushl(&prune_worktrees_cmd.args, "worktree", "prune", "--expire", NULL);
strvec_push(&prune_worktrees_cmd.args, cfg->prune_worktrees_expire);

return run_command(&prune_worktrees_cmd);
}

static int worktree_prune_condition(struct gc_config *cfg)
{
struct strvec worktrees = STRVEC_INIT;
struct strbuf reason = STRBUF_INIT;
timestamp_t expiry_date;
int should_prune = 0;
int limit = 1;

git_config_get_int("maintenance.worktree-prune.auto", &limit);
if (limit <= 0) {
should_prune = limit < 0;
goto out;
}

if (parse_expiry_date(cfg->prune_worktrees_expire, &expiry_date) ||
get_worktree_names(the_repository, &worktrees) < 0)
goto out;

for (size_t i = 0; i < worktrees.nr; i++) {
char *wtpath;

strbuf_reset(&reason);
if (should_prune_worktree(worktrees.v[i], &reason, &wtpath, expiry_date)) {
limit--;

if (!limit) {
should_prune = 1;
goto out;
}
}
free(wtpath);
}

out:
strvec_clear(&worktrees);
strbuf_release(&reason);
return should_prune;
}

static int maintenance_task_rerere_gc(struct maintenance_run_opts *opts UNUSED,
struct gc_config *cfg UNUSED)
{
struct child_process rerere_cmd = CHILD_PROCESS_INIT;
rerere_cmd.git_cmd = 1;
strvec_pushl(&rerere_cmd.args, "rerere", "gc", NULL);
return run_command(&rerere_cmd);
}

static int rerere_gc_condition(struct gc_config *cfg UNUSED)
{
struct strbuf path = STRBUF_INIT;
struct string_list prunable_dirs = STRING_LIST_INIT_DUP;
struct rerere_id *prunable_entries = NULL;
size_t prunable_entries_nr;
int should_gc = 0;
int limit = 20;

git_config_get_int("maintenance.rerere-gc.auto", &limit);
if (limit <= 0) {
should_gc = limit < 0;
goto out;
}

/* Skip garbage collecting the rerere cache in case rerere is disabled. */
repo_git_path_replace(the_repository, &path, "rr-cache");
if (!is_directory(path.buf))
goto out;

if (rerere_collect_stale_entries(the_repository, &prunable_dirs,
&prunable_entries, &prunable_entries_nr) < 0)
goto out;

should_gc = prunable_entries_nr >= limit;

out:
string_list_clear(&prunable_dirs, 0);
free(prunable_entries);
strbuf_release(&path);
return should_gc;
}

static int too_many_loose_objects(struct gc_config *cfg)
{
/*
@ -728,9 +818,9 @@ static void gc_before_repack(struct maintenance_run_opts *opts,
}

int cmd_gc(int argc,
const char **argv,
const char *prefix,
struct repository *repo UNUSED)
const char **argv,
const char *prefix,
struct repository *repo UNUSED)
{
int aggressive = 0;
int quiet = 0;
@ -740,7 +830,6 @@ struct repository *repo UNUSED)
int daemonized = 0;
int keep_largest_pack = -1;
timestamp_t dummy;
struct child_process rerere_cmd = CHILD_PROCESS_INIT;
struct maintenance_run_opts opts = MAINTENANCE_RUN_OPTS_INIT;
struct gc_config cfg = GC_CONFIG_INIT;
const char *prune_expire_sentinel = "sentinel";
@ -779,9 +868,6 @@ struct repository *repo UNUSED)
builtin_gc_usage, builtin_gc_options);

strvec_pushl(&repack, "repack", "-d", "-l", NULL);
strvec_pushl(&prune, "prune", "--expire", NULL);
strvec_pushl(&prune_worktrees, "worktree", "prune", "--expire", NULL);
strvec_pushl(&rerere, "rerere", "gc", NULL);

gc_config(&cfg);

@ -907,34 +993,27 @@ struct repository *repo UNUSED)
if (cfg.prune_expire) {
struct child_process prune_cmd = CHILD_PROCESS_INIT;

strvec_pushl(&prune_cmd.args, "prune", "--expire", NULL);
/* run `git prune` even if using cruft packs */
strvec_push(&prune, cfg.prune_expire);
strvec_push(&prune_cmd.args, cfg.prune_expire);
if (quiet)
strvec_push(&prune, "--no-progress");
strvec_push(&prune_cmd.args, "--no-progress");
if (repo_has_promisor_remote(the_repository))
strvec_push(&prune,
strvec_push(&prune_cmd.args,
"--exclude-promisor-objects");
prune_cmd.git_cmd = 1;
strvec_pushv(&prune_cmd.args, prune.v);

if (run_command(&prune_cmd))
die(FAILED_RUN, prune.v[0]);
die(FAILED_RUN, prune_cmd.args.v[0]);
}
}

if (cfg.prune_worktrees_expire) {
struct child_process prune_worktrees_cmd = CHILD_PROCESS_INIT;
if (cfg.prune_worktrees_expire &&
maintenance_task_worktree_prune(&opts, &cfg))
die(FAILED_RUN, "worktree");

strvec_push(&prune_worktrees, cfg.prune_worktrees_expire);
prune_worktrees_cmd.git_cmd = 1;
strvec_pushv(&prune_worktrees_cmd.args, prune_worktrees.v);
if (run_command(&prune_worktrees_cmd))
die(FAILED_RUN, prune_worktrees.v[0]);
}

rerere_cmd.git_cmd = 1;
strvec_pushv(&rerere_cmd.args, rerere.v);
if (run_command(&rerere_cmd))
die(FAILED_RUN, rerere.v[0]);
if (maintenance_task_rerere_gc(&opts, &cfg))
die(FAILED_RUN, "rerere");

report_garbage = report_pack_garbage;
reprepare_packed_git(the_repository);
@ -1467,6 +1546,8 @@ enum maintenance_task_label {
TASK_COMMIT_GRAPH,
TASK_PACK_REFS,
TASK_REFLOG_EXPIRE,
TASK_WORKTREE_PRUNE,
TASK_RERERE_GC,

/* Leave as final value */
TASK__COUNT
@ -1508,6 +1589,16 @@ static struct maintenance_task tasks[] = {
maintenance_task_reflog_expire,
reflog_expire_condition,
},
[TASK_WORKTREE_PRUNE] = {
"worktree-prune",
maintenance_task_worktree_prune,
worktree_prune_condition,
},
[TASK_RERERE_GC] = {
"rerere-gc",
maintenance_task_rerere_gc,
rerere_gc_condition,
},
};

static int compare_tasks_by_selection(const void *a_, const void *b_)

View File

@ -211,27 +211,24 @@ static void prune_dups(struct string_list *l)

static void prune_worktrees(void)
{
struct strbuf reason = STRBUF_INIT;
struct strbuf main_path = STRBUF_INIT;
struct string_list kept = STRING_LIST_INIT_DUP;
char *path;
DIR *dir;
struct dirent *d;
struct strvec worktrees = STRVEC_INIT;
struct strbuf reason = STRBUF_INIT;

path = repo_git_path(the_repository, "worktrees");
dir = opendir(path);
free(path);
if (!dir)
if (get_worktree_names(the_repository, &worktrees) < 0 ||
!worktrees.nr)
return;
while ((d = readdir_skip_dot_and_dotdot(dir)) != NULL) {

for (size_t i = 0; i < worktrees.nr; i++) {
char *path;

strbuf_reset(&reason);
if (should_prune_worktree(d->d_name, &reason, &path, expire))
prune_worktree(d->d_name, reason.buf);
if (should_prune_worktree(worktrees.v[i], &reason, &path, expire))
prune_worktree(worktrees.v[i], reason.buf);
else if (path)
string_list_append_nodup(&kept, path)->util = xstrdup(d->d_name);
string_list_append_nodup(&kept, path)->util = xstrdup(worktrees.v[i]);
}
closedir(dir);

strbuf_add_absolute_path(&main_path, repo_get_common_dir(the_repository));
/* massage main worktree absolute path to match 'gitdir' content */
@ -242,6 +239,8 @@ static void prune_worktrees(void)

if (!show_only)
delete_worktrees_dir_if_empty();

strvec_clear(&worktrees);
strbuf_release(&reason);
}


View File

@ -1203,8 +1203,8 @@ static void unlink_rr_item(struct rerere_id *id)
strbuf_release(&buf);
}

static void prune_one(struct rerere_id *id,
timestamp_t cutoff_resolve, timestamp_t cutoff_noresolve)
static int is_stale(struct rerere_id *id,
timestamp_t cutoff_resolve, timestamp_t cutoff_noresolve)
{
timestamp_t then;
timestamp_t cutoff;
@ -1215,11 +1215,11 @@ static void prune_one(struct rerere_id *id,
else {
then = rerere_created_at(id);
if (!then)
return;
return 0;
cutoff = cutoff_noresolve;
}
if (then < cutoff)
unlink_rr_item(id);

return then < cutoff;
}

/* Does the basename in "path" look plausibly like an rr-cache entry? */
@ -1230,29 +1230,35 @@ static int is_rr_cache_dirname(const char *path)
return !parse_oid_hex(path, &oid, &end) && !*end;
}

void rerere_gc(struct repository *r, struct string_list *rr)
int rerere_collect_stale_entries(struct repository *r,
struct string_list *prunable_dirs,
struct rerere_id **prunable_entries,
size_t *prunable_entries_nr)
{
struct string_list to_remove = STRING_LIST_INIT_DUP;
DIR *dir;
struct dirent *e;
int i;
timestamp_t now = time(NULL);
timestamp_t cutoff_noresolve = now - 15 * 86400;
timestamp_t cutoff_resolve = now - 60 * 86400;
struct strbuf buf = STRBUF_INIT;
size_t prunable_entries_alloc;
struct dirent *e;
DIR *dir = NULL;
int ret;

if (setup_rerere(r, rr, 0) < 0)
return;
*prunable_entries = NULL;
*prunable_entries_nr = 0;
prunable_entries_alloc = 0;

repo_config_get_expiry_in_days(the_repository, "gc.rerereresolved",
repo_config_get_expiry_in_days(r, "gc.rerereresolved",
&cutoff_resolve, now);
repo_config_get_expiry_in_days(the_repository, "gc.rerereunresolved",
repo_config_get_expiry_in_days(r, "gc.rerereunresolved",
&cutoff_noresolve, now);
git_config(git_default_config, NULL);
dir = opendir(repo_git_path_replace(the_repository, &buf, "rr-cache"));
if (!dir)
die_errno(_("unable to open rr-cache directory"));
/* Collect stale conflict IDs ... */

dir = opendir(repo_git_path_replace(r, &buf, "rr-cache"));
if (!dir) {
ret = error_errno(_("unable to open rr-cache directory"));
goto out;
}

while ((e = readdir_skip_dot_and_dotdot(dir))) {
struct rerere_dir *rr_dir;
struct rerere_id id;
@ -1267,23 +1273,53 @@ void rerere_gc(struct repository *r, struct string_list *rr)
for (id.variant = 0, id.collection = rr_dir;
id.variant < id.collection->status_nr;
id.variant++) {
prune_one(&id, cutoff_resolve, cutoff_noresolve);
if (id.collection->status[id.variant])
if (is_stale(&id, cutoff_resolve, cutoff_noresolve)) {
ALLOC_GROW(*prunable_entries, *prunable_entries_nr + 1,
prunable_entries_alloc);
(*prunable_entries)[(*prunable_entries_nr)++] = id;
} else {
now_empty = 0;
}
}
if (now_empty)
string_list_append(&to_remove, e->d_name);
string_list_append(prunable_dirs, e->d_name);
}
closedir(dir);

/* ... and then remove the empty directories */
for (i = 0; i < to_remove.nr; i++)
rmdir(repo_git_path_replace(the_repository, &buf,
"rr-cache/%s", to_remove.items[i].string));
ret = 0;

string_list_clear(&to_remove, 0);
out:
strbuf_release(&buf);
if (dir)
closedir(dir);
return ret;
}

void rerere_gc(struct repository *r, struct string_list *rr)
{
struct string_list prunable_dirs = STRING_LIST_INIT_DUP;
struct rerere_id *prunable_entries;
struct strbuf buf = STRBUF_INIT;
size_t prunable_entries_nr;

if (setup_rerere(r, rr, 0) < 0)
return;

git_config(git_default_config, NULL);

if (rerere_collect_stale_entries(r, &prunable_dirs, &prunable_entries,
&prunable_entries_nr) < 0)
exit(127);

for (size_t i = 0; i < prunable_entries_nr; i++)
unlink_rr_item(&prunable_entries[i]);
for (size_t i = 0; i < prunable_dirs.nr; i++)
rmdir(repo_git_path_replace(r, &buf, "rr-cache/%s",
prunable_dirs.items[i].string));

string_list_clear(&prunable_dirs, 0);
rollback_lock_file(&write_lock);
strbuf_release(&buf);
free(prunable_entries);
}

/*

View File

@ -37,6 +37,20 @@ const char *rerere_path(struct strbuf *buf, const struct rerere_id *,
int rerere_forget(struct repository *, struct pathspec *);
int rerere_remaining(struct repository *, struct string_list *);
void rerere_clear(struct repository *, struct string_list *);

/*
* Collect prunable rerere entries that would be garbage collected via
* `rerere_gc()`. Whether or not an entry is prunable depends on both
* "gc.rerereResolved" and "gc.rerereUnresolved".
*
* Returns 0 on success, a negative error code in case entries could not be
* collected.
*/
int rerere_collect_stale_entries(struct repository *r,
struct string_list *prunable_dirs,
struct rerere_id **prunable_entries,
size_t *prunable_entries_nr);

void rerere_gc(struct repository *, struct string_list *);

#define OPT_RERERE_AUTOUPDATE(v) OPT_UYN(0, "rerere-autoupdate", (v), \

View File

@ -493,6 +493,131 @@ test_expect_success 'reflog-expire task --auto only packs when exceeding limits'
test_subcommand git reflog expire --all <reflog-expire-auto.txt
'

test_expect_worktree_prune () {
negate=
if test "$1" = "!"
then
negate="!"
shift
fi

rm -f "worktree-prune.txt" &&
GIT_TRACE2_EVENT="$(pwd)/worktree-prune.txt" "$@" &&
test_subcommand $negate git worktree prune --expire 3.months.ago <worktree-prune.txt
}

test_expect_success 'worktree-prune task without --auto always prunes' '
test_expect_worktree_prune git maintenance run --task=worktree-prune
'

test_expect_success 'worktree-prune task --auto only prunes with prunable worktree' '
test_expect_worktree_prune ! git maintenance run --auto --task=worktree-prune &&
mkdir .git/worktrees &&
: >.git/worktrees/abc &&
test_expect_worktree_prune git maintenance run --auto --task=worktree-prune
'

test_expect_success 'worktree-prune task with --auto honors maintenance.worktree-prune.auto' '
# A negative value should always prune.
test_expect_worktree_prune git -c maintenance.worktree-prune.auto=-1 maintenance run --auto --task=worktree-prune &&

mkdir .git/worktrees &&
: >.git/worktrees/first &&
: >.git/worktrees/second &&
: >.git/worktrees/third &&

# Zero should never prune.
test_expect_worktree_prune ! git -c maintenance.worktree-prune.auto=0 maintenance run --auto --task=worktree-prune &&
# A positive value should require at least this man prunable worktrees.
test_expect_worktree_prune ! git -c maintenance.worktree-prune.auto=4 maintenance run --auto --task=worktree-prune &&
test_expect_worktree_prune git -c maintenance.worktree-prune.auto=3 maintenance run --auto --task=worktree-prune
'

test_expect_success 'worktree-prune task with --auto honors maintenance.worktree-prune.auto' '
# A negative value should always prune.
test_expect_worktree_prune git -c maintenance.worktree-prune.auto=-1 maintenance run --auto --task=worktree-prune &&

mkdir .git/worktrees &&
: >.git/worktrees/first &&
: >.git/worktrees/second &&
: >.git/worktrees/third &&

# Zero should never prune.
test_expect_worktree_prune ! git -c maintenance.worktree-prune.auto=0 maintenance run --auto --task=worktree-prune &&
# A positive value should require at least this many prunable worktrees.
test_expect_worktree_prune ! git -c maintenance.worktree-prune.auto=4 maintenance run --auto --task=worktree-prune &&
test_expect_worktree_prune git -c maintenance.worktree-prune.auto=3 maintenance run --auto --task=worktree-prune
'

test_expect_success 'worktree-prune task honors gc.worktreePruneExpire' '
git worktree add worktree &&
rm -rf worktree &&

rm -f worktree-prune.txt &&
GIT_TRACE2_EVENT="$(pwd)/worktree-prune.txt" git -c gc.worktreePruneExpire=1.week.ago maintenance run --auto --task=worktree-prune &&
test_subcommand ! git worktree prune --expire 1.week.ago <worktree-prune.txt &&
test_path_is_dir .git/worktrees/worktree &&

rm -f worktree-prune.txt &&
GIT_TRACE2_EVENT="$(pwd)/worktree-prune.txt" git -c gc.worktreePruneExpire=now maintenance run --auto --task=worktree-prune &&
test_subcommand git worktree prune --expire now <worktree-prune.txt &&
test_path_is_missing .git/worktrees/worktree
'

setup_stale_rerere_entry () {
rr=.git/rr-cache/"$(printf "%0$(test_oid hexsz)d" "$1")" &&
mkdir -p "$rr" &&
>"$rr/preimage" &&
>"$rr/postimage" &&

test-tool chmtime ="$((-61 * 86400))" "$rr/preimage" &&
test-tool chmtime ="$((-61 * 86400))" "$rr/postimage"
}

test_expect_rerere_gc () {
negate=
if test "$1" = "!"
then
negate="!"
shift
fi

rm -f "rerere-gc.txt" &&
GIT_TRACE2_EVENT="$(pwd)/rerere-gc.txt" "$@" &&
test_subcommand $negate git rerere gc <rerere-gc.txt
}

test_expect_success 'rerere-gc task without --auto always collects garbage' '
test_expect_rerere_gc git maintenance run --task=rerere-gc
'

test_expect_success 'rerere-gc task with --auto only prunes with prunable entries' '
test_expect_rerere_gc ! git maintenance run --auto --task=rerere-gc &&
for i in $(test_seq 19)
do
setup_stale_rerere_entry $i || return 1
done &&
test_expect_rerere_gc ! git maintenance run --auto --task=rerere-gc &&
setup_stale_rerere_entry 20 &&
test_expect_rerere_gc git maintenance run --auto --task=rerere-gc
'

test_expect_success 'rerere-gc task with --auto honors maintenance.rerere-gc.auto' '
# A negative value should always prune.
test_expect_rerere_gc git -c maintenance.rerere-gc.auto=-1 maintenance run --auto --task=rerere-gc &&

for i in $(test_seq 20)
do
setup_stale_rerere_entry $i || return 1
done &&

# Zero should never prune.
test_expect_rerere_gc ! git -c maintenance.rerere-gc.auto=0 maintenance run --auto --task=rerere-gc &&
# A positive value should require at least this many stale rerere entries.
test_expect_rerere_gc ! git -c maintenance.rerere-gc.auto=21 maintenance run --auto --task=rerere-gc &&
test_expect_rerere_gc git -c maintenance.rerere-gc.auto=10 maintenance run --auto --task=rerere-gc
'

test_expect_success '--auto and --schedule incompatible' '
test_must_fail git maintenance run --auto --schedule=daily 2>err &&
test_grep "at most one" err

View File

@ -988,6 +988,36 @@ done:
return rc;
}

int get_worktree_names(struct repository *repo, struct strvec *out)
{
char *worktrees_dir;
struct dirent *d;
DIR *dir;
int ret;

worktrees_dir = repo_git_path(repo, "worktrees");
dir = opendir(worktrees_dir);
if (!dir) {
if (errno == ENOENT) {
ret = 0;
goto out;
}

ret = -1;
goto out;
}

while ((d = readdir_skip_dot_and_dotdot(dir)) != NULL)
strvec_push(out, d->d_name);

ret = 0;
out:
if (dir)
closedir(dir);
free(worktrees_dir);
return ret;
}

static int move_config_setting(const char *key, const char *value,
const char *from_file, const char *to_file)
{

View File

@ -38,6 +38,14 @@ struct worktree **get_worktrees(void);
*/
struct worktree **get_worktrees_without_reading_head(void);

/*
* Retrieve all worktree names. Not all names may correspond to a fully
* functional worktree. Returns 0 on success, a negative error code on failure.
* Calling the function on a repository that doesn't have any worktrees is not
* considered an error.
*/
int get_worktree_names(struct repository *repo, struct strvec *out);

/*
* Returns 1 if linked worktrees exist, 0 otherwise.
*/