All of lore.kernel.org
 help / color / mirror / Atom feed
* [PATCH] worktree: record creation time and free-form note
@ 2026-06-02 21:40 Kiesel, Norbert
  2026-06-02 23:52 ` Junio C Hamano
  2026-06-05 15:17 ` Phillip Wood
  0 siblings, 2 replies; 12+ messages in thread
From: Kiesel, Norbert @ 2026-06-02 21:40 UTC (permalink / raw)
  To: git

From 130cd5e4a25e6672b2a97268e1100b6ef03fa552 Mon Sep 17 00:00:00 2001
From: Norbert Kiesel <norbert.kiesel@creditkarma.com>
Date: Mon, 1 Jun 2026 17:03:39 -0700
Subject: [PATCH] worktree: record creation time and free-form note

Add per-worktree metadata so users can answer "what is this worktree
for, and when did I make it?" without resorting to external notes.

When `git worktree add` creates a linked worktree, it now writes a
`created` file containing the unix timestamp. A new `--note <string>`
option to `add`, and a new `git worktree annotate <worktree> [<note>]`
subcommand, store an optional free-form description in a `note` file
next to the other administrative files. Passing `annotate` without a
note clears it. The main worktree carries no metadata and cannot be
annotated.

`git worktree list` learns `--show-created` and `--show-note` for
human-readable output, and `--sort=<key>` (path or created, optionally
prefixed with `-` to reverse) for ordering linked worktrees; the main
worktree always stays first. Worktrees without a recorded timestamp
(those created before this change) display as `created: unknown` and
sort after timestamped ones. Porcelain output unconditionally emits
`created` and `note` lines when the corresponding metadata is present.

Tests cover add/annotate/list behaviour and the legacy-worktree case.
The two existing porcelain assertions in t2402 are taught to strip the
new `created` line so they continue to pass.

Signed-off-by: Norbert Kiesel <norbert.kiesel@creditkarma.com>
---
 Documentation/git-worktree.adoc |  61 ++++++++++++-
 builtin/worktree.c              | 152 +++++++++++++++++++++++++++++++-
 t/meson.build                   |   1 +
 t/t2402-worktree-list.sh        |  10 ++-
 t/t2410-worktree-metadata.sh    | 143 ++++++++++++++++++++++++++++++
 worktree.c                      |  78 ++++++++++++++++
 worktree.h                      |  23 +++++
 7 files changed, 459 insertions(+), 9 deletions(-)
 create mode 100755 t/t2410-worktree-metadata.sh

diff --git a/Documentation/git-worktree.adoc b/Documentation/git-worktree.adoc
index fbf8426cd9..200f3d7772 100644
--- a/Documentation/git-worktree.adoc
+++ b/Documentation/git-worktree.adoc
@@ -10,8 +10,11 @@ SYNOPSIS
 --------
 [synopsis]
 git worktree add [-f] [--detach] [--checkout] [--lock [--reason <string>]]
+ [--note <string>]
  [--orphan] [(-b | -B) <new-branch>] <path> [<commit-ish>]
-git worktree list [-v | --porcelain [-z]]
+git worktree annotate <worktree> [<note>]
+git worktree list [-v | --porcelain [-z]] [--show-created] [--show-note]
+ [--sort=<key>]
 git worktree lock [--reason <string>] <worktree>
 git worktree move <worktree> <new-path>
 git worktree prune [-n] [-v] [--expire <expire>]
@@ -106,6 +109,15 @@ passed to the command. In the event the
repository has a remote and
 command fails with a warning reminding the user to fetch from their remote
 first (or override by using `-f`/`--force`).

+`annotate <worktree> [<note>]`::
+
+Set, replace, or clear a free-form note (description) on a linked worktree.
+Useful for recording what a worktree was created for so it can be identified
+later. With _<note>_, the worktree's note is set or replaced; without a note
+argument, the existing note is cleared. The note for a worktree may also be
+set at creation time with `git worktree add --note <note>`. The main
+worktree cannot be annotated.
+
 `list`::

 List details of each worktree.  The main worktree is listed first,
@@ -114,6 +126,20 @@ whether the worktree is bare, the revision
currently checked out, the
 branch currently checked out (or "detached HEAD" if none), "locked" if
 the worktree is locked, "prunable" if the worktree can be pruned by the
 `prune` command.
++
+Each worktree's creation timestamp is recorded when it is created with
+`git worktree add`. Worktrees created before this feature existed have no
+recorded creation timestamp; for them, `list` reports `created: unknown`
+in human output and omits the `created` line in `--porcelain` output. Pass
+`--show-created` to include creation timestamps in human output. Worktrees
+without a recorded timestamp sort last (or first when reversed) with
+`--sort=created`.
++
+Pass `--show-note` to include any user-provided note in human output. In
+`--porcelain` output, both `created` and `note` lines are emitted whenever
+present. Use `--sort=<key>` (where _<key>_ is `path` or `created`,
+optionally prefixed with `-` to reverse) to order the linked worktrees;
+the main worktree always remains first.

 `lock`::

@@ -286,6 +312,32 @@ _<time>_.
  With `lock` or with `add --lock`, an explanation why the worktree
  is locked.

+`--note <string>`::
+ With `add`, attach a free-form note (description) to the new worktree.
+ The note is stored alongside the worktree's administrative files and
+ can be displayed with `git worktree list --show-note` or in
+ `--porcelain` output. It can be changed later with
+ `git worktree annotate`.
+
+`--show-created`::
+ With `list`, include each worktree's creation timestamp in the
+ human-readable output. Worktrees with no recorded creation time are
+ shown as `created: unknown`. In `--porcelain` output, the creation
+ timestamp is always included (when available) on a `created` line.
+
+`--show-note`::
+ With `list`, include each worktree's note (if set) in the
+ human-readable output. In `--porcelain` output, the note is always
+ included (when set) on a `note` line.
+
+`--sort=<key>`::
+ With `list`, sort linked worktrees by _<key>_, which is one of
+ `path` or `created`. Prefix with `-` to reverse the order, e.g.
+ `--sort=-created` lists newest first. The main worktree is always
+ listed first regardless of sort order. Worktrees with no recorded
+ creation timestamp sort after those that have one (or before, when
+ reversed).
+
 _<worktree>_::
  Worktrees can be identified by path, either relative or absolute.
 +
@@ -462,7 +514,9 @@ are terminated with NUL rather than a newline.
Attributes are listed with a
 label and value separated by a single space.  Boolean attributes (like `bare`
 and `detached`) are listed as a label only, and are present only
 if the value is true.  Some attributes (like `locked`) can be listed as a label
-only or with a value depending upon whether a reason is available.  The first
+only or with a value depending upon whether a reason is available.  Optional
+valued attributes (like `created` and `note`) appear only when the
+corresponding metadata has been recorded for that worktree.  The first
 attribute of a worktree is always `worktree`, an empty line indicates the
 end of the record.  For example:

@@ -474,10 +528,13 @@ bare
 worktree /path/to/linked-worktree
 HEAD abcd1234abcd1234abcd1234abcd1234abcd1234
 branch refs/heads/master
+created 2026-06-01T12:34:56Z
+note investigating login bug

 worktree /path/to/other-linked-worktree
 HEAD 1234abc1234abc1234abc1234abc1234abc1234a
 detached
+created 2026-05-28T08:15:00Z

 worktree /path/to/linked-worktree-locked-no-reason
 HEAD 5678abc5678abc5678abc5678abc5678abc5678c
diff --git a/builtin/worktree.c b/builtin/worktree.c
index d21c43fde3..ac22277d6c 100644
--- a/builtin/worktree.c
+++ b/builtin/worktree.c
@@ -27,13 +27,16 @@
 #include "utf8.h"
 #include "worktree.h"
 #include "quote.h"
+#include "date.h"

 #define BUILTIN_WORKTREE_ADD_USAGE \
  N_("git worktree add [-f] [--detach] [--checkout] [--lock [--reason
<string>]]\n" \
+    "                 [--note <string>]\n" \
     "                 [--orphan] [(-b | -B) <new-branch>] <path>
[<commit-ish>]")

 #define BUILTIN_WORKTREE_LIST_USAGE \
- N_("git worktree list [-v | --porcelain [-z]]")
+ N_("git worktree list [-v | --porcelain [-z]] [--show-created]
[--show-note]\n" \
+    "                  [--sort=<key>]")
 #define BUILTIN_WORKTREE_LOCK_USAGE \
  N_("git worktree lock [--reason <string>] <worktree>")
 #define BUILTIN_WORKTREE_MOVE_USAGE \
@@ -46,6 +49,8 @@
  N_("git worktree repair [<path>...]")
 #define BUILTIN_WORKTREE_UNLOCK_USAGE \
  N_("git worktree unlock <worktree>")
+#define BUILTIN_WORKTREE_ANNOTATE_USAGE \
+ N_("git worktree annotate <worktree> [<note>]")

 #define WORKTREE_ADD_DWIM_ORPHAN_INFER_TEXT \
  _("No possible source branch, inferring '--orphan'")
@@ -66,6 +71,7 @@

 static const char * const git_worktree_usage[] = {
  BUILTIN_WORKTREE_ADD_USAGE,
+ BUILTIN_WORKTREE_ANNOTATE_USAGE,
  BUILTIN_WORKTREE_LIST_USAGE,
  BUILTIN_WORKTREE_LOCK_USAGE,
  BUILTIN_WORKTREE_MOVE_USAGE,
@@ -116,6 +122,11 @@ static const char * const git_worktree_unlock_usage[] = {
  NULL
 };

+static const char * const git_worktree_annotate_usage[] = {
+ BUILTIN_WORKTREE_ANNOTATE_USAGE,
+ NULL
+};
+
 struct add_opts {
  int force;
  int detach;
@@ -124,6 +135,7 @@ struct add_opts {
  int orphan;
  int relative_paths;
  const char *keep_locked;
+ const char *note;
 };

 static int show_only;
@@ -131,6 +143,8 @@ static int verbose;
 static int guess_remote;
 static int use_relative_paths;
 static timestamp_t expire;
+static int show_created;
+static int show_note;

 static int git_worktree_config(const char *var, const char *value,
         const struct config_context *ctx, void *cb)
@@ -544,6 +558,16 @@ static int add_worktree(const char *path, const
char *refname,
  strbuf_addf(&sb, "%s/commondir", sb_repo.buf);
  write_file(sb.buf, "../..");

+ strbuf_reset(&sb);
+ strbuf_addf(&sb, "%s/created", sb_repo.buf);
+ write_file(sb.buf, "%"PRItime, (timestamp_t) time(NULL));
+
+ if (opts->note && *opts->note) {
+ strbuf_reset(&sb);
+ strbuf_addf(&sb, "%s/note", sb_repo.buf);
+ write_file(sb.buf, "%s", opts->note);
+ }
+
  /*
  * Set up the ref store of the worktree and create the HEAD reference.
  */
@@ -815,6 +839,8 @@ static int add(int ac, const char **av, const char *prefix,
  OPT_BOOL(0, "lock", &keep_locked, N_("keep the new working tree locked")),
  OPT_STRING(0, "reason", &lock_reason, N_("string"),
     N_("reason for locking")),
+ OPT_STRING(0, "note", &opts.note, N_("string"),
+    N_("attach a free-form note/description to the worktree")),
  OPT__QUIET(&opts.quiet, N_("suppress progress reporting")),
  OPT_PASSTHRU(0, "track", &opt_track, NULL,
       N_("set up tracking mode (see git-branch(1))"),
@@ -963,6 +989,8 @@ static int add(int ac, const char **av, const char *prefix,
 static void show_worktree_porcelain(struct worktree *wt, int line_terminator)
 {
  const char *reason;
+ const char *note;
+ timestamp_t created;

  printf("worktree %s%c", wt->path, line_terminator);
  if (wt->is_bare)
@@ -975,6 +1003,18 @@ static void show_worktree_porcelain(struct
worktree *wt, int line_terminator)
  printf("branch %s%c", wt->head_ref, line_terminator);
  }

+ created = worktree_created_at(wt);
+ if (created)
+ printf("created %s%c",
+        show_date(created, 0, DATE_MODE(ISO8601_STRICT)),
+        line_terminator);
+
+ note = worktree_note(wt);
+ if (note && *note) {
+ fputs("note ", stdout);
+ write_name_quoted(note, stdout, line_terminator);
+ }
+
  reason = worktree_lock_reason(wt);
  if (reason) {
  fputs("locked", stdout);
@@ -1034,6 +1074,21 @@ static void show_worktree(struct worktree *wt,
struct worktree_display *display,
  else if (reason)
  strbuf_addstr(&sb, " prunable");

+ if (show_created || verbose) {
+ timestamp_t created = worktree_created_at(wt);
+ if (created)
+ strbuf_addf(&sb, "\n\tcreated: %s",
+     show_date(created, 0, DATE_MODE(ISO8601)));
+ else if (show_created && !is_main_worktree(wt))
+ strbuf_addstr(&sb, "\n\tcreated: unknown");
+ }
+
+ if (show_note || verbose) {
+ const char *note = worktree_note(wt);
+ if (note && *note)
+ strbuf_addf(&sb, "\n\tnote: %s", note);
+ }
+
  printf("%s\n", sb.buf);
  strbuf_release(&sb);
 }
@@ -1068,6 +1123,27 @@ static int pathcmp(const void *a_, const void *b_)
  return fspathcmp((*a)->path, (*b)->path);
 }

+static int createdcmp(const void *a_, const void *b_)
+{
+ struct worktree *const *a = a_;
+ struct worktree *const *b = b_;
+ timestamp_t ta = worktree_created_at(*a);
+ timestamp_t tb = worktree_created_at(*b);
+
+ /* Worktrees without a recorded timestamp (legacy) sort after those
with one. */
+ if (!ta && !tb)
+ return fspathcmp((*a)->path, (*b)->path);
+ if (!ta)
+ return 1;
+ if (!tb)
+ return -1;
+ if (ta < tb)
+ return -1;
+ if (ta > tb)
+ return 1;
+ return 0;
+}
+
 static void pathsort(struct worktree **wt)
 {
  int n = 0;
@@ -1078,11 +1154,43 @@ static void pathsort(struct worktree **wt)
  QSORT(wt, n, pathcmp);
 }

+static int sort_worktrees(struct worktree **wt, const char *key)
+{
+ int n = 0, reverse = 0;
+ struct worktree **p = wt;
+ int (*cmp)(const void *, const void *);
+
+ if (*key == '-') {
+ reverse = 1;
+ key++;
+ }
+ if (!strcmp(key, "path"))
+ cmp = pathcmp;
+ else if (!strcmp(key, "created"))
+ cmp = createdcmp;
+ else
+ return -1;
+
+ while (*p++)
+ n++;
+ QSORT(wt, n, cmp);
+ if (reverse) {
+ int i;
+ for (i = 0; i < n / 2; i++) {
+ struct worktree *tmp = wt[i];
+ wt[i] = wt[n - 1 - i];
+ wt[n - 1 - i] = tmp;
+ }
+ }
+ return 0;
+}
+
 static int list(int ac, const char **av, const char *prefix,
  struct repository *repo UNUSED)
 {
  int porcelain = 0;
  int line_terminator = '\n';
+ const char *sort_key = NULL;

  struct option options[] = {
  OPT_BOOL(0, "porcelain", &porcelain, N_("machine-readable output")),
@@ -1091,6 +1199,12 @@ static int list(int ac, const char **av, const
char *prefix,
  N_("add 'prunable' annotation to missing worktrees older than <time>")),
  OPT_SET_INT('z', NULL, &line_terminator,
      N_("terminate records with a NUL character"), '\0'),
+ OPT_BOOL(0, "show-created", &show_created,
+ N_("show worktree creation timestamps")),
+ OPT_BOOL(0, "show-note", &show_note,
+ N_("show worktree notes")),
+ OPT_STRING(0, "sort", &sort_key, N_("key"),
+    N_("sort worktrees by key (path, created); prefix with - to reverse")),
  OPT_END()
  };

@@ -1107,8 +1221,13 @@ static int list(int ac, const char **av, const
char *prefix,
  int path_maxwidth = 0, abbrev = DEFAULT_ABBREV, i;
  struct worktree_display *display = NULL;

- /* sort worktrees by path but keep main worktree at top */
- pathsort(worktrees + 1);
+ /* sort worktrees but keep main worktree at top */
+ if (sort_key) {
+ if (sort_worktrees(worktrees + 1, sort_key))
+ die(_("unknown sort key '%s'"), sort_key);
+ } else {
+ pathsort(worktrees + 1);
+ }

  if (!porcelain)
  measure_widths(worktrees, &abbrev,
@@ -1200,6 +1319,32 @@ static int unlock_worktree(int ac, const char
**av, const char *prefix,
  return ret;
 }

+static int annotate_worktree(int ac, const char **av, const char *prefix,
+      struct repository *repo UNUSED)
+{
+ struct option options[] = {
+ OPT_END()
+ };
+ struct worktree **worktrees, *wt;
+ int ret;
+
+ ac = parse_options(ac, av, prefix, options, git_worktree_annotate_usage, 0);
+ if (ac < 1 || ac > 2)
+ usage_with_options(git_worktree_annotate_usage, options);
+
+ worktrees = get_worktrees();
+ wt = find_worktree(worktrees, prefix, av[0]);
+ if (!wt)
+ die(_("'%s' is not a working tree"), av[0]);
+ if (is_main_worktree(wt))
+ die(_("The main working tree cannot be annotated"));
+
+ ret = set_worktree_note(wt, ac == 2 ? av[1] : NULL);
+
+ free_worktrees(worktrees);
+ return ret;
+}
+
 static void validate_no_submodules(const struct worktree *wt)
 {
  struct index_state istate = INDEX_STATE_INIT(the_repository);
@@ -1469,6 +1614,7 @@ int cmd_worktree(int ac,
  parse_opt_subcommand_fn *fn = NULL;
  struct option options[] = {
  OPT_SUBCOMMAND("add", &fn, add),
+ OPT_SUBCOMMAND("annotate", &fn, annotate_worktree),
  OPT_SUBCOMMAND("prune", &fn, prune),
  OPT_SUBCOMMAND("list", &fn, list),
  OPT_SUBCOMMAND("lock", &fn, lock_worktree),
diff --git a/t/meson.build b/t/meson.build
index 2af8d01279..7b6e8435d7 100644
--- a/t/meson.build
+++ b/t/meson.build
@@ -308,6 +308,7 @@ integration_tests = [
   't2405-worktree-submodule.sh',
   't2406-worktree-repair.sh',
   't2407-worktree-heads.sh',
+  't2410-worktree-metadata.sh',
   't2500-untracked-overwriting.sh',
   't2501-cwd-empty.sh',
   't3000-ls-files-others.sh',
diff --git a/t/t2402-worktree-list.sh b/t/t2402-worktree-list.sh
index e0c6abd2f5..8422340443 100755
--- a/t/t2402-worktree-list.sh
+++ b/t/t2402-worktree-list.sh
@@ -71,7 +71,8 @@ test_expect_success '"list" all worktrees --porcelain' '
  echo "HEAD $(git rev-parse HEAD)" >>expect &&
  echo "detached" >>expect &&
  echo >>expect &&
- git worktree list --porcelain >actual &&
+ git worktree list --porcelain >actual.raw &&
+ grep -v "^created " actual.raw >actual &&
  test_cmp expect actual
 '

@@ -86,7 +87,7 @@ test_expect_success '"list" all worktrees --porcelain -z' '
  "$(git -C here rev-parse --show-toplevel)" \
  "$(git rev-parse HEAD)" >>expect &&
  git worktree list --porcelain -z >_actual &&
- nul_to_q <_actual >actual &&
+ nul_to_q <_actual | tr Q "\n" | grep -v "^created " | tr "\n" Q >actual &&
  test_cmp expect actual
 '

@@ -220,7 +221,7 @@ test_expect_success '"list" all worktrees from bare main' '
 '

 test_expect_success '"list" all worktrees --porcelain from bare main' '
- test_when_finished "rm -rf there actual expect && git -C bare1
worktree prune" &&
+ test_when_finished "rm -rf there actual actual.raw expect && git -C
bare1 worktree prune" &&
  git -C bare1 worktree add --detach ../there main &&
  echo "worktree $(pwd)/bare1" >expect &&
  echo "bare" >>expect &&
@@ -229,7 +230,8 @@ test_expect_success '"list" all worktrees
--porcelain from bare main' '
  echo "HEAD $(git -C there rev-parse HEAD)" >>expect &&
  echo "detached" >>expect &&
  echo >>expect &&
- git -C bare1 worktree list --porcelain >actual &&
+ git -C bare1 worktree list --porcelain >actual.raw &&
+ grep -v "^created " actual.raw >actual &&
  test_cmp expect actual
 '

diff --git a/t/t2410-worktree-metadata.sh b/t/t2410-worktree-metadata.sh
new file mode 100755
index 0000000000..3f8b508593
--- /dev/null
+++ b/t/t2410-worktree-metadata.sh
@@ -0,0 +1,143 @@
+#!/bin/sh
+
+test_description='git worktree creation timestamp and note metadata'
+
+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main
+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME
+
+. ./test-lib.sh
+
+test_expect_success 'setup' '
+ test_commit init
+'
+
+test_expect_success 'add writes created file' '
+ test_when_finished "git worktree remove -f wt1 && git worktree prune" &&
+ git worktree add wt1 &&
+ test_path_is_file .git/worktrees/wt1/created &&
+ # contents should be a positive integer (unix timestamp)
+ created=$(cat .git/worktrees/wt1/created) &&
+ test "$created" -gt 0
+'
+
+test_expect_success 'add --note writes note file' '
+ test_when_finished "git worktree remove -f wt2 && git worktree prune" &&
+ git worktree add --note "investigating bug" wt2 &&
+ test_path_is_file .git/worktrees/wt2/note &&
+ echo "investigating bug" >expect &&
+ test_cmp expect .git/worktrees/wt2/note
+'
+
+test_expect_success 'add without --note does not create note file' '
+ test_when_finished "git worktree remove -f wt3 && git worktree prune" &&
+ git worktree add wt3 &&
+ test_path_is_missing .git/worktrees/wt3/note
+'
+
+test_expect_success 'annotate sets a note on an existing worktree' '
+ test_when_finished "git worktree remove -f wt4 && git worktree prune" &&
+ git worktree add wt4 &&
+ git worktree annotate wt4 "later note" &&
+ echo "later note" >expect &&
+ test_cmp expect .git/worktrees/wt4/note
+'
+
+test_expect_success 'annotate replaces an existing note' '
+ test_when_finished "git worktree remove -f wt5 && git worktree prune" &&
+ git worktree add --note "old" wt5 &&
+ git worktree annotate wt5 "new" &&
+ echo "new" >expect &&
+ test_cmp expect .git/worktrees/wt5/note
+'
+
+test_expect_success 'annotate with no text clears the note' '
+ test_when_finished "git worktree remove -f wt6 && git worktree prune" &&
+ git worktree add --note "to delete" wt6 &&
+ test_path_is_file .git/worktrees/wt6/note &&
+ git worktree annotate wt6 &&
+ test_path_is_missing .git/worktrees/wt6/note
+'
+
+test_expect_success 'annotate refuses to operate on the main worktree' '
+ test_must_fail git worktree annotate . "should fail" 2>err &&
+ grep -i "main working tree" err
+'
+
+test_expect_success 'list --show-note displays note in human output' '
+ test_when_finished "git worktree remove -f wt7 && git worktree prune" &&
+ git worktree add --note "release branch" wt7 &&
+ git worktree list --show-note >actual &&
+ grep "note: release branch" actual
+'
+
+test_expect_success 'list --show-created displays created timestamp' '
+ test_when_finished "git worktree remove -f wt8 && git worktree prune" &&
+ git worktree add wt8 &&
+ git worktree list --show-created >actual &&
+ grep "created: " actual
+'
+
+test_expect_success 'list --show-created shows unknown for legacy worktrees' '
+ test_when_finished "git worktree remove -f wt9 && git worktree prune" &&
+ git worktree add wt9 &&
+ rm .git/worktrees/wt9/created &&
+ git worktree list --show-created >actual &&
+ grep "created: unknown" actual
+'
+
+test_expect_success 'list --porcelain always includes created and note' '
+ test_when_finished "git worktree remove -f wtp && git worktree prune" &&
+ git worktree add --note "porcelain test" wtp &&
+ git worktree list --porcelain >actual &&
+ grep "^created " actual &&
+ grep "^note porcelain test" actual
+'
+
+test_expect_success 'list --sort=created orders by creation time' '
+ test_when_finished "git worktree remove -f a && git worktree remove
-f b && git worktree remove -f c && git worktree prune" &&
+ git worktree add a &&
+ git worktree add b &&
+ git worktree add c &&
+ echo 1000 >.git/worktrees/a/created &&
+ echo 2000 >.git/worktrees/b/created &&
+ echo 3000 >.git/worktrees/c/created &&
+ git worktree list --sort=created --porcelain >actual &&
+ grep "^worktree " actual | sed -n "2,4p" >linked &&
+ awk "NR==1" linked | grep -q "/a$" &&
+ awk "NR==2" linked | grep -q "/b$" &&
+ awk "NR==3" linked | grep -q "/c$"
+'
+
+test_expect_success 'list --sort=-created reverses order' '
+ test_when_finished "git worktree remove -f a && git worktree remove
-f b && git worktree remove -f c && git worktree prune" &&
+ git worktree add a &&
+ git worktree add b &&
+ git worktree add c &&
+ echo 1000 >.git/worktrees/a/created &&
+ echo 2000 >.git/worktrees/b/created &&
+ echo 3000 >.git/worktrees/c/created &&
+ git worktree list --sort=-created --porcelain >actual &&
+ grep "^worktree " actual | sed -n "2,4p" >linked &&
+ awk "NR==1" linked | grep -q "/c$" &&
+ awk "NR==2" linked | grep -q "/b$" &&
+ awk "NR==3" linked | grep -q "/a$"
+'
+
+test_expect_success 'list --sort=created places legacy worktrees last' '
+ test_when_finished "git worktree remove -f early && git worktree
remove -f legacy && git worktree prune" &&
+ git worktree add early &&
+ echo 1000 >.git/worktrees/early/created &&
+ git worktree add legacy &&
+ rm .git/worktrees/legacy/created &&
+ git worktree list --sort=created --porcelain >actual &&
+ grep "^worktree " actual | sed -n "2,3p" >linked &&
+ awk "NR==1" linked | grep -q "/early$" &&
+ awk "NR==2" linked | grep -q "/legacy$"
+'
+
+test_expect_success 'list --sort with unknown key fails' '
+ test_must_fail git worktree list --sort=bogus 2>err &&
+ grep -i "unknown sort key" err
+'
+
+test_done
diff --git a/worktree.c b/worktree.c
index 97eddc3916..7989e694b7 100644
--- a/worktree.c
+++ b/worktree.c
@@ -14,6 +14,8 @@
 #include "dir.h"
 #include "wt-status.h"
 #include "config.h"
+#include "date.h"
+#include "wrapper.h"

 void free_worktree(struct worktree *worktree)
 {
@@ -24,6 +26,7 @@ void free_worktree(struct worktree *worktree)
  free(worktree->head_ref);
  free(worktree->lock_reason);
  free(worktree->prune_reason);
+ free(worktree->note);
  free(worktree);
 }

@@ -324,6 +327,81 @@ const char *worktree_lock_reason(struct worktree *wt)
  return wt->lock_reason;
 }

+timestamp_t worktree_created_at(struct worktree *wt)
+{
+ if (is_main_worktree(wt))
+ return 0;
+
+ if (!wt->created_at_valid) {
+ struct strbuf path = STRBUF_INIT;
+ struct strbuf buf = STRBUF_INIT;
+
+ strbuf_addstr(&path, worktree_git_path(wt, "created"));
+ if (file_exists(path.buf) &&
+     strbuf_read_file(&buf, path.buf, 0) >= 0) {
+ char *end;
+ timestamp_t t;
+ strbuf_trim(&buf);
+ t = parse_timestamp(buf.buf, &end, 10);
+ if (end != buf.buf && *end == '\0')
+ wt->created_at = t;
+ }
+ wt->created_at_valid = 1;
+ strbuf_release(&path);
+ strbuf_release(&buf);
+ }
+
+ return wt->created_at;
+}
+
+const char *worktree_note(struct worktree *wt)
+{
+ if (is_main_worktree(wt))
+ return NULL;
+
+ if (!wt->note_valid) {
+ struct strbuf path = STRBUF_INIT;
+
+ strbuf_addstr(&path, worktree_git_path(wt, "note"));
+ if (file_exists(path.buf)) {
+ struct strbuf note = STRBUF_INIT;
+ if (strbuf_read_file(&note, path.buf, 0) < 0)
+ die_errno(_("failed to read '%s'"), path.buf);
+ strbuf_trim_trailing_newline(&note);
+ wt->note = strbuf_detach(&note, NULL);
+ } else
+ wt->note = NULL;
+ wt->note_valid = 1;
+ strbuf_release(&path);
+ }
+
+ return wt->note;
+}
+
+int set_worktree_note(struct worktree *wt, const char *text)
+{
+ char *path;
+ int ret = 0;
+
+ if (is_main_worktree(wt))
+ return error(_("cannot set note on the main worktree"));
+
+ path = repo_common_path(wt->repo, "worktrees/%s/note", wt->id);
+ if (!text || !*text) {
+ if (file_exists(path) && unlink(path))
+ ret = error_errno(_("failed to remove '%s'"), path);
+ } else {
+ write_file(path, "%s", text);
+ }
+
+ /* invalidate cache so a follow-up worktree_note() re-reads */
+ FREE_AND_NULL(wt->note);
+ wt->note_valid = 0;
+
+ free(path);
+ return ret;
+}
+
 const char *worktree_prune_reason(struct worktree *wt, timestamp_t expire)
 {
  struct strbuf reason = STRBUF_INIT;
diff --git a/worktree.h b/worktree.h
index 1075409f9a..0fcdb8bd1b 100644
--- a/worktree.h
+++ b/worktree.h
@@ -13,12 +13,16 @@ struct worktree {
  char *head_ref; /* NULL if HEAD is broken or detached */
  char *lock_reason; /* private - use worktree_lock_reason */
  char *prune_reason;     /* private - use worktree_prune_reason */
+ char *note; /* private - use worktree_note */
  struct object_id head_oid;
+ timestamp_t created_at; /* private - use worktree_created_at; 0 if unknown */
  int is_detached;
  int is_bare;
  int is_current; /* does `path` match `repo->worktree` */
  int lock_reason_valid; /* private */
  int prune_reason_valid; /* private */
+ int note_valid;        /* private */
+ int created_at_valid;  /* private */
 };

 /*
@@ -96,6 +100,25 @@ int is_main_worktree(const struct worktree *wt);
  */
 const char *worktree_lock_reason(struct worktree *wt);

+/*
+ * Return the worktree's recorded creation timestamp, or 0 if no timestamp
+ * was recorded (e.g. a worktree created before this metadata existed, or
+ * the main worktree which never carries the file).
+ */
+timestamp_t worktree_created_at(struct worktree *wt);
+
+/*
+ * Return the user-supplied note/description for the given worktree, or NULL
+ * if none was set.
+ */
+const char *worktree_note(struct worktree *wt);
+
+/*
+ * Write or replace the worktree's note. Pass NULL or "" to delete the note.
+ * Returns 0 on success, -1 on failure. Not valid for the main worktree.
+ */
+int set_worktree_note(struct worktree *wt, const char *text);
+
 /*
  * Return the reason string if the given worktree should be pruned, otherwise
  * NULL if it should not be pruned. `expire` defines a grace period to prune
--

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

* Re: [PATCH] worktree: record creation time and free-form note
  2026-06-02 21:40 [PATCH] worktree: record creation time and free-form note Kiesel, Norbert
@ 2026-06-02 23:52 ` Junio C Hamano
  2026-06-02 23:57   ` Junio C Hamano
  2026-06-03  0:03   ` Kiesel, Norbert
  2026-06-05 15:17 ` Phillip Wood
  1 sibling, 2 replies; 12+ messages in thread
From: Junio C Hamano @ 2026-06-02 23:52 UTC (permalink / raw)
  To: Kiesel, Norbert; +Cc: git

"Kiesel, Norbert" <norbert.kiesel@creditkarma.com> writes:

> From 130cd5e4a25e6672b2a97268e1100b6ef03fa552 Mon Sep 17 00:00:00 2001
> From: Norbert Kiesel <norbert.kiesel@creditkarma.com>
> Date: Mon, 1 Jun 2026 17:03:39 -0700
> Subject: [PATCH] worktree: record creation time and free-form note
>
> Add per-worktree metadata so users can answer "what is this worktree
> for, and when did I make it?" without resorting to external notes.

Although I am not personally interested in this topic all that much,
let me point out that we have $GIT_DIR/description file that may be
useful for something like this.  It has been the canonical place for
the main repository to identify itself long before secondary worktrees
were invented and $GIT_COMMON_DIR/worktrees/$worktree/description would
be a natural extension of the concept, I'd presume.

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

* Re: [PATCH] worktree: record creation time and free-form note
  2026-06-02 23:52 ` Junio C Hamano
@ 2026-06-02 23:57   ` Junio C Hamano
  2026-06-03  0:03   ` Kiesel, Norbert
  1 sibling, 0 replies; 12+ messages in thread
From: Junio C Hamano @ 2026-06-02 23:57 UTC (permalink / raw)
  To: Kiesel, Norbert; +Cc: git

Junio C Hamano <gitster@pobox.com> writes:

> "Kiesel, Norbert" <norbert.kiesel@creditkarma.com> writes:
>
>> From 130cd5e4a25e6672b2a97268e1100b6ef03fa552 Mon Sep 17 00:00:00 2001
>> From: Norbert Kiesel <norbert.kiesel@creditkarma.com>
>> Date: Mon, 1 Jun 2026 17:03:39 -0700
>> Subject: [PATCH] worktree: record creation time and free-form note

Ah, I forgot to mention another thing.  Please do not add these four
lines to your message body.  The information belongs to the e-mail
header, and as long as your e-mail software is configured correctly
there shouldn't be a need to use From: or override the time when the
patch was made public with Date: in-body header.

Thanks.

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

* Re: [PATCH] worktree: record creation time and free-form note
  2026-06-02 23:52 ` Junio C Hamano
  2026-06-02 23:57   ` Junio C Hamano
@ 2026-06-03  0:03   ` Kiesel, Norbert
  2026-06-03 22:51     ` Kiesel, Norbert
  1 sibling, 1 reply; 12+ messages in thread
From: Kiesel, Norbert @ 2026-06-03  0:03 UTC (permalink / raw)
  To: Junio C Hamano; +Cc: git

Yes, I could change my PR to use $GIT_COMMON_DIR/worktrees/$worktree/description
instead of the currently used $GIT_COMMON_DIR/worktrees/$worktree/note.

Give me a day, and I can create the updated diff.

Best,
  Norbert

On Tue, Jun 2, 2026 at 4:52 PM Junio C Hamano <gitster@pobox.com> wrote:
>
> "Kiesel, Norbert" <norbert.kiesel@creditkarma.com> writes:
>
> > From 130cd5e4a25e6672b2a97268e1100b6ef03fa552 Mon Sep 17 00:00:00 2001
> > From: Norbert Kiesel <norbert.kiesel@creditkarma.com>
> > Date: Mon, 1 Jun 2026 17:03:39 -0700
> > Subject: [PATCH] worktree: record creation time and free-form note
> >
> > Add per-worktree metadata so users can answer "what is this worktree
> > for, and when did I make it?" without resorting to external notes.
>
> Although I am not personally interested in this topic all that much,
> let me point out that we have $GIT_DIR/description file that may be
> useful for something like this.  It has been the canonical place for
> the main repository to identify itself long before secondary worktrees
> were invented and $GIT_COMMON_DIR/worktrees/$worktree/description would
> be a natural extension of the concept, I'd presume.



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

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

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

* Re: [PATCH] worktree: record creation time and free-form note
  2026-06-03  0:03   ` Kiesel, Norbert
@ 2026-06-03 22:51     ` Kiesel, Norbert
  2026-06-04  1:14       ` Junio C Hamano
  2026-06-08 16:59       ` Junio C Hamano
  0 siblings, 2 replies; 12+ messages in thread
From: Kiesel, Norbert @ 2026-06-03 22:51 UTC (permalink / raw)
  To: Junio C Hamano; +Cc: git

Hi Junio,

I looked at the usage of `.git/description` and I could not find any
usage.  We do have
Git branch descriptions which are stored in .git/config, but that does
not seem to be
usable to store the worktree description or the worktree creation timestamp.

So are you ok if I send the PR again, just using "description" instead
of "note"?

Best,
  Norbert

On Tue, Jun 2, 2026 at 5:03 PM Kiesel, Norbert
<norbert.kiesel@creditkarma.com> wrote:
>
> Yes, I could change my PR to use $GIT_COMMON_DIR/worktrees/$worktree/description
> instead of the currently used $GIT_COMMON_DIR/worktrees/$worktree/note.
>
> Give me a day, and I can create the updated diff.
>
> Best,
>   Norbert
>
> On Tue, Jun 2, 2026 at 4:52 PM Junio C Hamano <gitster@pobox.com> wrote:
> >
> > "Kiesel, Norbert" <norbert.kiesel@creditkarma.com> writes:
> >
> > > From 130cd5e4a25e6672b2a97268e1100b6ef03fa552 Mon Sep 17 00:00:00 2001
> > > From: Norbert Kiesel <norbert.kiesel@creditkarma.com>
> > > Date: Mon, 1 Jun 2026 17:03:39 -0700
> > > Subject: [PATCH] worktree: record creation time and free-form note
> > >
> > > Add per-worktree metadata so users can answer "what is this worktree
> > > for, and when did I make it?" without resorting to external notes.
> >
> > Although I am not personally interested in this topic all that much,
> > let me point out that we have $GIT_DIR/description file that may be
> > useful for something like this.  It has been the canonical place for
> > the main repository to identify itself long before secondary worktrees
> > were invented and $GIT_COMMON_DIR/worktrees/$worktree/description would
> > be a natural extension of the concept, I'd presume.
>
>
>
> --
> Norbert Kiesel | Staff Software Engineer | Credit Karma
> norbert.kiesel@creditkarma.com | www.creditkarma.com
>
> This email may contain confidential and privileged information. Any
> review, use, distribution, or disclosure by anyone other than the
> intended recipient(s) is prohibited. If you are not the intended
> recipient, please contact the sender by reply email and delete all
> copies of this message.



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

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

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

* Re: [PATCH] worktree: record creation time and free-form note
  2026-06-03 22:51     ` Kiesel, Norbert
@ 2026-06-04  1:14       ` Junio C Hamano
  2026-06-08 16:59       ` Junio C Hamano
  1 sibling, 0 replies; 12+ messages in thread
From: Junio C Hamano @ 2026-06-04  1:14 UTC (permalink / raw)
  To: Kiesel, Norbert; +Cc: git

"Kiesel, Norbert" <norbert.kiesel@creditkarma.com> writes:

> Hi Junio,
>
> I looked at the usage of `.git/description` and I could not find any
> usage.  We do have
> Git branch descriptions which are stored in .git/config, but that does
> not seem to be
> usable to store the worktree description or the worktree creation timestamp.
>
> So are you ok if I send the PR again, just using "description" instead
> of "note"?

Not really.  Not adding "note" and reusing "description" merely
removes one smaller problem I immediately see.

As I said a few times, I do not get why such a "feature" is needed.
Also, be it a "note" or "description", people notice typoes in the
message and go in to edit with their editor, which would update the
mtime of the file, so if that is the timestamp you are using for
anything real, I am not sure how well it would work in practice.

I'll let the others to figure out the merit of the feature and will
come back next week to see what concensus they reached.

Thanks.

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

* Re: [PATCH] worktree: record creation time and free-form note
  2026-06-02 21:40 [PATCH] worktree: record creation time and free-form note Kiesel, Norbert
  2026-06-02 23:52 ` Junio C Hamano
@ 2026-06-05 15:17 ` Phillip Wood
  2026-06-05 16:13   ` Kiesel, Norbert
                     ` (2 more replies)
  1 sibling, 3 replies; 12+ messages in thread
From: Phillip Wood @ 2026-06-05 15:17 UTC (permalink / raw)
  To: Kiesel, Norbert, git

Hi Norbert

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

A couple of thoughts related to this

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

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

Thanks

Phillip

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


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

* Re: [PATCH] worktree: record creation time and free-form note
  2026-06-05 15:17 ` Phillip Wood
@ 2026-06-05 16:13   ` Kiesel, Norbert
  2026-06-05 16:50   ` Kristoffer Haugsbakk
  2026-06-05 16:57   ` Chris Torek
  2 siblings, 0 replies; 12+ messages in thread
From: Kiesel, Norbert @ 2026-06-05 16:13 UTC (permalink / raw)
  To: phillip.wood; +Cc: git

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

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

Best,
  Norbert


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


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

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

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

* Re: [PATCH] worktree: record creation time and free-form note
  2026-06-05 15:17 ` Phillip Wood
  2026-06-05 16:13   ` Kiesel, Norbert
@ 2026-06-05 16:50   ` Kristoffer Haugsbakk
  2026-06-05 16:57   ` Chris Torek
  2 siblings, 0 replies; 12+ messages in thread
From: Kristoffer Haugsbakk @ 2026-06-05 16:50 UTC (permalink / raw)
  To: Phillip Wood, Kiesel, Norbert, git

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

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

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

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

***

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

>[snip]

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

* Re: [PATCH] worktree: record creation time and free-form note
  2026-06-05 15:17 ` Phillip Wood
  2026-06-05 16:13   ` Kiesel, Norbert
  2026-06-05 16:50   ` Kristoffer Haugsbakk
@ 2026-06-05 16:57   ` Chris Torek
  2026-06-08 16:12     ` Kiesel, Norbert
  2 siblings, 1 reply; 12+ messages in thread
From: Chris Torek @ 2026-06-05 16:57 UTC (permalink / raw)
  To: phillip.wood; +Cc: Kiesel, Norbert, git

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	[flat|nested] 12+ messages in thread

* Re: [PATCH] worktree: record creation time and free-form note
  2026-06-05 16:57   ` Chris Torek
@ 2026-06-08 16:12     ` Kiesel, Norbert
  0 siblings, 0 replies; 12+ messages in thread
From: Kiesel, Norbert @ 2026-06-08 16:12 UTC (permalink / raw)
  To: git, Junio C Hamano; +Cc: phillip.wood, Chris Torek, kristofferhaugsbakk

Hi team,
I updated my proposed extension in a couple of ways you suggested, and
also added some more test code.

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



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

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

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

* Re: [PATCH] worktree: record creation time and free-form note
  2026-06-03 22:51     ` Kiesel, Norbert
  2026-06-04  1:14       ` Junio C Hamano
@ 2026-06-08 16:59       ` Junio C Hamano
  1 sibling, 0 replies; 12+ messages in thread
From: Junio C Hamano @ 2026-06-08 16:59 UTC (permalink / raw)
  To: Kiesel, Norbert; +Cc: git

"Kiesel, Norbert" <norbert.kiesel@creditkarma.com> writes:

> I looked at the usage of `.git/description` and I could not find any
> usage.

GitWeb shows it.

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

end of thread, other threads:[~2026-06-08 16:59 UTC | newest]

Thread overview: 12+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-06-02 21:40 [PATCH] worktree: record creation time and free-form note Kiesel, Norbert
2026-06-02 23:52 ` Junio C Hamano
2026-06-02 23:57   ` Junio C Hamano
2026-06-03  0:03   ` Kiesel, Norbert
2026-06-03 22:51     ` Kiesel, Norbert
2026-06-04  1:14       ` Junio C Hamano
2026-06-08 16:59       ` Junio C Hamano
2026-06-05 15:17 ` Phillip Wood
2026-06-05 16:13   ` Kiesel, Norbert
2026-06-05 16:50   ` Kristoffer Haugsbakk
2026-06-05 16:57   ` Chris Torek
2026-06-08 16:12     ` Kiesel, Norbert

This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.