{"thread":{"id":"65662","subject":"[PATCH 0/9] Add support for an external command for fetching notes","startedAt":"2026-05-19T16:30:52Z","lastAt":"2026-05-22T06:08:56Z","messageCount":29,"participants":["Siddh Raman Pant","Junio C Hamano","brian m. carlson","Johannes Sixt","Oswald Buddenhagen","Jeff King"],"isPatch":true,"patchVersion":1,"patchTotal":9},"messages":[{"id":"543690","messageId":"cover.1779207350.git.siddh.raman.pant@oracle.com","threadId":"65662","inReplyTo":null,"subject":"[PATCH 0/9] Add support for an external command for fetching notes","fromName":"Siddh Raman Pant","fromEmail":"siddh.raman.pant@oracle.com","sentAt":"2026-05-19T16:30:29Z","receivedAt":"2026-05-19T16:30:52Z","isPatch":true,"body":"Hi,\n\nThis series teaches the notes display machinery to obtain note text from a\nlong-lived external helper configured by `notes.externalCommand`.\n\nThe motivation is mentioned in the main commit message (PATCH 7/9).\n\nThe helper protocol is intentionally narrow. Git starts the command once,\nsends one commit object ID per request, and expects either:\n\n\t<object-id> missing\n\t<object-id> ok <n>\n\t<n bytes of UTF-8 note text>\n\nwith the documented trailing newlines. The command is read only from protected\nconfiguration, so an untrusted repository cannot make ordinary note display run\narbitrary commands. If the helper cannot be started, times out, exits, or sends\nan invalid response, Git warns once, disables it for the rest of the process,\nand continues without external notes.\n\nUsers can control from command line too with `--external-notes` and\n`--no-external-notes`. The semantics are close to `--notes=<ref>`:\n`--external-notes` implies naming an explicit notes source by itself, while\n`--external-notes --notes` combines it with the default notes refs, and\n`--external-notes --notes=<ref>` combines it with specific notes refs. The\nseries also adds `notes.externalCommandName`, `notes.externalCommandTimeoutMs`,\nand the opt-in `notes.externalCommandForGrep` knob for installations that want\nexternal notes to participate in `--grep` matching.\n\nBecause this puts an external process on the log-formatting path, the series\nalso adds the small support pieces needed to keep that boundary bounded:\ntimeout/deadline variants of the read helpers, a timeout-aware command\nfinisher, and cleanup that escalates if the helper does not exit promptly.\n\nTesting: https://github.com/siddhpant/git/actions/runs/26107938855\n\nThanks,\nSiddh\n\nSiddh Raman Pant (9):\n  Documentation/git-range-diff: add missing notes options in synopsis\n  notes: convert raw arg in format_display_notes() to bool\n  wrapper: add sleep_nanosec\n  run-command: add support for timeout in command finisher\n  wrapper: add support for timeout and deadline in read helpers\n  t3301: cover generic displayed notes behavior\n  notes: support an external command to display notes\n  Documentation: document external notes command options\n  t: add tests for external notes command\n\n Documentation/config/notes.adoc             |  57 +++\n Documentation/git-format-patch.adoc         |  11 +-\n Documentation/git-range-diff.adoc           |   8 +-\n Documentation/pretty-options.adoc           |   9 +\n Makefile                                    |   2 +\n builtin/log.c                               |  17 +-\n builtin/name-rev.c                          |   9 +-\n builtin/range-diff.c                        |   2 +\n contrib/completion/git-completion.bash      |   4 +-\n log-tree.c                                  |  10 +-\n meson.build                                 |   1 +\n notes-external.c                            | 330 ++++++++++++++\n notes-external.h                            |  19 +\n notes.c                                     | 244 ++++++++---\n notes.h                                     |  32 +-\n revision.c                                  |  32 +-\n run-command.c                               |  92 +++-\n run-command.h                               |  13 +\n strbuf.c                                    |  26 +-\n strbuf.h                                    |   4 +\n t/helper/meson.build                        |   1 +\n t/helper/test-external-notes                |  64 +++\n t/helper/test-notes-external-config-reset.c |  20 +\n t/helper/test-tool.c                        |   1 +\n t/helper/test-tool.h                        |   1 +\n t/lib-notes.sh                              |  19 +\n t/t3206-range-diff.sh                       |  68 +++\n t/t3301-notes.sh                            | 461 ++++++++++++++++++++\n t/t6120-describe.sh                         |  17 +\n wrapper.c                                   | 188 +++++++-\n wrapper.h                                   |  24 +\n 31 files changed, 1702 insertions(+), 84 deletions(-)\n create mode 100644 notes-external.c\n create mode 100644 notes-external.h\n create mode 100755 t/helper/test-external-notes\n create mode 100644 t/helper/test-notes-external-config-reset.c\n create mode 100644 t/lib-notes.sh\n\n-- \n2.53.0\n\n"},{"id":"543691","messageId":"290fe06d81e956253d3a06fc1e16848e0b86b603.1779207350.git.siddh.raman.pant@oracle.com","threadId":"65662","inReplyTo":"cover.1779207350.git.siddh.raman.pant@oracle.com","subject":"[PATCH 1/9] Documentation/git-range-diff: add missing notes options in synopsis","fromName":"Siddh Raman Pant","fromEmail":"siddh.raman.pant@oracle.com","sentAt":"2026-05-19T16:30:30Z","receivedAt":"2026-05-19T16:31:04Z","isPatch":true,"body":"Signed-off-by: Siddh Raman Pant <siddh.raman.pant@oracle.com>\n---\n Documentation/git-range-diff.adoc | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)\n\ndiff --git a/Documentation/git-range-diff.adoc b/Documentation/git-range-diff.adoc\nindex 880557084533..5cc5e2ed5673 100644\n--- a/Documentation/git-range-diff.adoc\n+++ b/Documentation/git-range-diff.adoc\n@@ -11,7 +11,7 @@ SYNOPSIS\n git range-diff [--color=[<when>]] [--no-color] [<diff-options>]\n \t[--no-dual-color] [--creation-factor=<factor>]\n \t[--left-only | --right-only] [--diff-merges=<format>]\n-\t[--remerge-diff]\n+\t[--remerge-diff] [--no-notes | --notes[=<ref>]]\n \t( <range1> <range2> | <rev1>...<rev2> | <base> <rev1> <rev2> )\n \t[[--] <path>...]\n \n-- \n2.53.0\n\n"},{"id":"543692","messageId":"f58c8c522814dce9257f64733e9fbc9bd9f446c0.1779207350.git.siddh.raman.pant@oracle.com","threadId":"65662","inReplyTo":"cover.1779207350.git.siddh.raman.pant@oracle.com","subject":"[PATCH 4/9] run-command: add support for timeout in command finisher","fromName":"Siddh Raman Pant","fromEmail":"siddh.raman.pant@oracle.com","sentAt":"2026-05-19T16:30:33Z","receivedAt":"2026-05-19T16:31:12Z","isPatch":true,"body":"A called command may not respond to the initial signal and will get\nstuck in finish_command() -> wait_or_whine().\n\nSo let's add timeout support into the finisher so that if a deadline\noccurs, we can send a force-kill signal.\n\nThe force-kill signal is in the argument because a program may trap a\nsignal, so it is the responsibility of caller to pass the correct kill\nsignal.\n\nAssisted-by: Codex:gpt-5.5-xhigh-fast\nSigned-off-by: Siddh Raman Pant <siddh.raman.pant@oracle.com>\n---\n run-command.c | 92 +++++++++++++++++++++++++++++++++++++++++++++++----\n run-command.h | 13 ++++++++\n 2 files changed, 98 insertions(+), 7 deletions(-)\n\ndiff --git a/run-command.c b/run-command.c\nindex c146a56532a1..60b84610d1f0 100644\n--- a/run-command.c\n+++ b/run-command.c\n@@ -554,16 +554,63 @@ static inline void set_cloexec(int fd)\n \t\tfcntl(fd, F_SETFD, flags | FD_CLOEXEC);\n }\n \n-static int wait_or_whine(pid_t pid, const char *argv0, int in_signal)\n+#define NS_IN_10MS 10000000ULL\t/* 10 ms = 10^-2 s = 10^(9-2) ns = 10^7 ns */\n+\n+/* If timeout_ns == 0, no timeout happens (the timeout path is not taken). */\n+static int wait_or_whine_timeout(pid_t pid, const char *argv0, int in_signal,\n+\t\t\t\t uint64_t timeout_ns)\n {\n \tint status, code = -1;\n \tpid_t waiting;\n \tint failed_errno = 0;\n+\tint flags = timeout_ns ? WNOHANG : 0;\n+\tbool timed_out = false;\n+\tuint64_t deadline_ns = getnanotime() + timeout_ns;\n+\n+\twhile(1) {\n+\t\tuint64_t current_time_ns, remaining_ns;\n+\t\twaiting = waitpid(pid, &status, flags);\n+\n+\t\t/* Retry if interrupted. */\n+\t\tif (waiting < 0 && errno == EINTR)\n+\t\t\tcontinue;\n+\n+\t\t/* Break if exited. */\n+\t\tif (waiting)\n+\t\t\tbreak;\n+\n+\t\t/* If no timeout is specified, retry till it exits. */\n+\t\tif (!timeout_ns)\n+\t\t\tcontinue;\n \n-\twhile ((waiting = waitpid(pid, &status, 0)) < 0 && errno == EINTR)\n-\t\t;\t/* nothing */\n+\t\tcurrent_time_ns = getnanotime();\n+\n+\t\t/* If we are past the deadline, set errno and break. */\n+\t\tif (deadline_ns <= current_time_ns) {\n+\t\t\terrno = ETIMEDOUT;\n+\t\t\ttimed_out = true;\n+\t\t\tbreak;\n+\t\t}\n+\n+\t\t/**\n+\t\t * Retry after a sleep(min(remaining, default_chunk)).\n+\t\t *\n+\t\t * We don't blindly sleep for the entire remaining time because\n+\t\t * the process can exit early.\n+\t\t *\n+\t\t * The subtraction of uint64_t is safe here since we have\n+\t\t * already established that deadline_ns > current_time_ns.\n+\t\t */\n+\t\tremaining_ns = deadline_ns - current_time_ns;\n+\t\tsleep_nanosec(remaining_ns < NS_IN_10MS ?\n+\t\t\t      remaining_ns : NS_IN_10MS);\n+\t}\n \n-\tif (waiting < 0) {\n+\tif (timed_out) {\n+\t\tfailed_errno = errno;\n+\t\tif (!in_signal)\n+\t\t\terror_errno(\"waitpid for %s timed out\", argv0);\n+\t} else if (waiting < 0) {\n \t\tfailed_errno = errno;\n \t\tif (!in_signal)\n \t\t\terror_errno(\"waitpid for %s failed\", argv0);\n@@ -587,13 +634,28 @@ static int wait_or_whine(pid_t pid, const char *argv0, int in_signal)\n \t\t\terror(\"waitpid is confused (%s)\", argv0);\n \t}\n \n-\tif (!in_signal)\n+\t/**\n+\t * Signal handlers use the cleanup list while reaping children, so only\n+\t * non-signal waiters (in_signal != 0) should update it.\n+\t *\n+\t * In case of a timeout, we keep the child registered since it is\n+\t * actually not reaped so removing would be wrong. It is the\n+\t * responsibility of the caller to detect the timeout and do cleanup,\n+\t * like sending a kill signal using this function without a timeout.\n+\t */\n+\tif (!in_signal && !timed_out)\n \t\tclear_child_for_cleanup(pid);\n \n \terrno = failed_errno;\n \treturn code;\n }\n \n+/* Non-timeout wrapper for compatibility. */\n+static int wait_or_whine(pid_t pid, const char *argv0, int in_signal)\n+{\n+\treturn wait_or_whine_timeout(pid, argv0, in_signal, 0);\n+}\n+\n static void trace_add_env(struct strbuf *dst, const char *const *deltaenv)\n {\n \tstruct string_list envs = STRING_LIST_INIT_DUP;\n@@ -989,15 +1051,31 @@ int start_command(struct child_process *cmd)\n \treturn 0;\n }\n \n-int finish_command(struct child_process *cmd)\n+/* See comment in the header file for executive summary. */\n+int finish_command_with_timeout(struct child_process *cmd, uint64_t timeout_ns,\n+\t\t\t\tint signal_on_timeout)\n {\n-\tint ret = wait_or_whine(cmd->pid, cmd->args.v[0], 0);\n+\tint ret = wait_or_whine_timeout(cmd->pid, cmd->args.v[0], 0,\n+\t\t\t\t\ttimeout_ns);\n+\n+\tif (timeout_ns && ret < 0 && errno == ETIMEDOUT) {\n+\t\tkill(cmd->pid, signal_on_timeout);\n+\t\tret = wait_or_whine(cmd->pid, cmd->args.v[0], 0);\n+\t}\n+\n \ttrace2_child_exit(cmd, ret);\n \tchild_process_clear(cmd);\n \tinvalidate_lstat_cache();\n \treturn ret;\n }\n \n+/* Non-timeout wrapper for compatibility. */\n+int finish_command(struct child_process *cmd)\n+{\n+\treturn finish_command_with_timeout(cmd, 0, 0);\n+}\n+\n+\n int finish_command_in_signal(struct child_process *cmd)\n {\n \tint ret = wait_or_whine(cmd->pid, cmd->args.v[0], 1);\ndiff --git a/run-command.h b/run-command.h\nindex 8ca496d7bdeb..cb1c8ba4ec01 100644\n--- a/run-command.h\n+++ b/run-command.h\n@@ -215,6 +215,19 @@ int start_command(struct child_process *);\n  */\n int finish_command(struct child_process *);\n \n+/**\n+ * Wait for the completion of a sub-process that was started with\n+ * start_command(), but uptil a given timeout duration timeout_ns.\n+ *\n+ * If it has not exited after timeout_ns, signal_on_timeout is sent to the\n+ * process. We don't enforce a timeout for the second wait after sending\n+ * the signal (as the process cleanup needs to happen), so it will block there.\n+ *\n+ * If timeout_ns == 0, no timeout happens and signal_on_timeout is ignored.\n+ */\n+int finish_command_with_timeout(struct child_process *cmd, uint64_t timeout_ns,\n+\t\t\t\tint signal_on_timeout);\n+\n int finish_command_in_signal(struct child_process *);\n \n /**\n-- \n2.53.0\n\n"},{"id":"543693","messageId":"c5c1005bae14c963ca1f717c3a82200e4ec7a5f5.1779207350.git.siddh.raman.pant@oracle.com","threadId":"65662","inReplyTo":"cover.1779207350.git.siddh.raman.pant@oracle.com","subject":"[PATCH 5/9] wrapper: add support for timeout and deadline in read helpers","fromName":"Siddh Raman Pant","fromEmail":"siddh.raman.pant@oracle.com","sentAt":"2026-05-19T16:30:34Z","receivedAt":"2026-05-19T16:31:21Z","isPatch":true,"body":"Add read helpers which allow a caller to enforce a timeout per read,\nand a deadline for the read in case multiple reads have to be done\nunder a common timeout.\n\nAssisted-by: Codex:gpt-5.5-xhigh-fast\nSigned-off-by: Siddh Raman Pant <siddh.raman.pant@oracle.com>\n---\n strbuf.c  |  26 +++++++++-\n strbuf.h  |   4 ++\n wrapper.c | 139 ++++++++++++++++++++++++++++++++++++++++++++++++++----\n wrapper.h |  23 +++++++++\n 4 files changed, 182 insertions(+), 10 deletions(-)\n\ndiff --git a/strbuf.c b/strbuf.c\nindex 3e04addc22fe..b3fc7c624aa2 100644\n--- a/strbuf.c\n+++ b/strbuf.c\n@@ -749,13 +749,15 @@ int strbuf_getline_nul(struct strbuf *sb, FILE *fp)\n \treturn strbuf_getdelim(sb, fp, '\\0');\n }\n \n-int strbuf_getwholeline_fd(struct strbuf *sb, int fd, int term)\n+static int strbuf_getwholeline_fd_with(struct strbuf *sb, int fd, int term,\n+\t\t\t\t       xread_cb_t xread_cb,\n+\t\t\t\t       void *cb_data)\n {\n \tstrbuf_reset(sb);\n \n \twhile (1) {\n \t\tchar ch;\n-\t\tssize_t len = xread(fd, &ch, 1);\n+\t\tssize_t len = xread_cb(fd, &ch, 1, cb_data);\n \t\tif (len <= 0)\n \t\t\treturn EOF;\n \t\tstrbuf_addch(sb, ch);\n@@ -765,6 +767,26 @@ int strbuf_getwholeline_fd(struct strbuf *sb, int fd, int term)\n \treturn 0;\n }\n \n+int strbuf_getwholeline_fd_deadline(struct strbuf *sb, int fd, int term,\n+\t\t\t\t    uint64_t deadline_ns)\n+{\n+\treturn strbuf_getwholeline_fd_with(sb, fd, term, xread_deadline_fn,\n+\t\t\t\t\t   &deadline_ns);\n+}\n+\n+int strbuf_getwholeline_fd_timeout(struct strbuf *sb, int fd, int term,\n+\t\t\t\t   int timeout_ms)\n+{\n+\treturn strbuf_getwholeline_fd_with(sb, fd, term, xread_timeout_fn,\n+\t\t\t\t\t   &timeout_ms);\n+}\n+\n+/* Non-timeout version for compatibility. */\n+int strbuf_getwholeline_fd(struct strbuf *sb, int fd, int term)\n+{\n+\treturn strbuf_getwholeline_fd_timeout(sb, fd, term, 0);\n+}\n+\n ssize_t strbuf_read_file(struct strbuf *sb, const char *path, size_t hint)\n {\n \tint fd;\ndiff --git a/strbuf.h b/strbuf.h\nindex 06e284f9cca4..f896da1277a6 100644\n--- a/strbuf.h\n+++ b/strbuf.h\n@@ -535,6 +535,10 @@ int strbuf_appendwholeline(struct strbuf *sb, FILE *file, int term);\n  * descriptor.\n  */\n int strbuf_getwholeline_fd(struct strbuf *sb, int fd, int term);\n+int strbuf_getwholeline_fd_timeout(struct strbuf *sb, int fd, int term,\n+\t\t\t\t   int timeout_ms);\n+int strbuf_getwholeline_fd_deadline(struct strbuf *sb, int fd, int term,\n+\t\t\t\t    uint64_t deadline_ns);\n \n /**\n  * Set the buffer to the path of the current working directory.\ndiff --git a/wrapper.c b/wrapper.c\nindex 1349255f1eb4..3e0d65724e47 100644\n--- a/wrapper.c\n+++ b/wrapper.c\n@@ -9,6 +9,7 @@\n #include \"parse.h\"\n #include \"gettext.h\"\n #include \"strbuf.h\"\n+#include \"trace.h\"\n #include \"trace2.h\"\n #include <time.h>\n \n@@ -221,28 +222,129 @@ static int handle_nonblock(int fd, short poll_events, int err)\n \treturn 1;\n }\n \n-/*\n- * xread() is the same a read(), but it automatically restarts read()\n- * operations with a recoverable error (EAGAIN and EINTR). xread()\n+static int wait_for_fd(int fd, short poll_events, int timeout_ms)\n+{\n+\tstruct pollfd pfd;\n+\n+\tif (timeout_ms < 0) {\n+\t\t/* Negative timeout makes no sense. */\n+\t\terrno = EINVAL;\n+\t\treturn -1;\n+\t}\n+\n+\tpfd.fd = fd;\n+\tpfd.events = poll_events;\n+\n+\twhile(1) {\n+\t\tint ret = poll(&pfd, 1, timeout_ms);\n+\n+\t\tif (ret <= 0) {\n+\t\t\t/* Retry if interrupted. */\n+\t\t\tif (ret < 0 && errno == EINTR)\n+\t\t\t\tcontinue;\n+\n+\t\t\t/* Set errno if timeout happened. */\n+\t\t\tif (ret == 0)\n+\t\t\t\terrno = ETIMEDOUT;\n+\n+\t\t\treturn -1;\n+\t\t}\n+\n+\t\t/* Invalid FD passed. */\n+\t\tif (pfd.revents & POLLNVAL) {\n+\t\t\terrno = EBADF;\n+\t\t\treturn -1;\n+\t\t}\n+\n+\t\t/* Some error happened. */\n+\t\tif (pfd.revents & POLLERR) {\n+\t\t\terrno = EIO;\n+\t\t\treturn -1;\n+\t\t}\n+\n+\t\t/* HangUp => We are ready to consume output till EOF. */\n+\t\tif (pfd.revents & (poll_events | POLLHUP))\n+\t\t\treturn 0;\n+\t}\n+}\n+\n+/**\n+ * xread_timeout() is the same as read(), but it automatically restarts read()\n+ * operations with a recoverable error (EAGAIN and EINTR). xread_timeout()\n  * DOES NOT GUARANTEE that \"len\" bytes is read even if the data is available.\n+ *\n+ * Fails with ETIMEDOUT when no bytes become available within timeout_ms\n+ * milliseconds. A zero timeout disables timeout handling, so reads can\n+ * block until the file descriptor is readable. Negative timeouts are invalid.\n  */\n-ssize_t xread(int fd, void *buf, size_t len)\n+ssize_t xread_timeout(int fd, void *buf, size_t len, int timeout_ms)\n {\n \tssize_t nr;\n+\n \tif (len > MAX_IO_SIZE)\n \t\tlen = MAX_IO_SIZE;\n+\n \twhile (1) {\n+\t\tif (timeout_ms && wait_for_fd(fd, POLLIN, timeout_ms))\n+\t\t\treturn -1;\n+\n \t\tnr = read(fd, buf, len);\n+\n \t\tif (nr < 0) {\n \t\t\tif (errno == EINTR)\n \t\t\t\tcontinue;\n-\t\t\tif (handle_nonblock(fd, POLLIN, errno))\n-\t\t\t\tcontinue;\n+\n+\t\t\tif (timeout_ms) {\n+\t\t\t\tif (errno == EAGAIN || errno == EWOULDBLOCK)\n+\t\t\t\t\tcontinue;\n+\t\t\t} else {\n+\t\t\t\tif (handle_nonblock(fd, POLLIN, errno))\n+\t\t\t\t\tcontinue;\n+\t\t\t}\n \t\t}\n+\n \t\treturn nr;\n \t}\n }\n \n+/* Non-timeout version for compatibility. */\n+ssize_t xread(int fd, void *buf, size_t len)\n+{\n+\treturn xread_timeout(fd, buf, len, 0);\n+}\n+\n+static int remaining_timeout_ms(uint64_t deadline_ns)\n+{\n+\tuint64_t now, remaining_ns;\n+\n+\tif (!deadline_ns)\n+\t\treturn 0;\n+\n+\tnow = getnanotime();\n+\tif (now >= deadline_ns) {\n+\t\terrno = ETIMEDOUT;\n+\t\treturn -1;\n+\t}\n+\n+\tremaining_ns = deadline_ns - now;\n+\treturn (int)((remaining_ns + 999999ULL) / 1000000ULL);\n+}\n+\n+/* (deadline_ns = 0) disables the deadline and short-circuits to xread(). */\n+ssize_t xread_deadline(int fd, void *buf, size_t len, uint64_t deadline_ns)\n+{\n+\tint timeout_ms;\n+\n+\tif (deadline_ns == 0)\n+\t\treturn xread(fd, buf, len);\n+\n+\ttimeout_ms = remaining_timeout_ms(deadline_ns);\n+\tif (timeout_ms < 0)\n+\t\treturn -1;\n+\n+\treturn xread_timeout(fd, buf, len, timeout_ms);\n+}\n+\n /*\n  * xwrite() is the same a write(), but it automatically restarts write()\n  * operations with a recoverable error (EAGAIN and EINTR). xwrite() DOES NOT\n@@ -284,13 +386,15 @@ ssize_t xpread(int fd, void *buf, size_t len, off_t offset)\n \t}\n }\n \n-ssize_t read_in_full(int fd, void *buf, size_t count)\n+static ssize_t read_in_full_with(int fd, void *buf, size_t count,\n+\t\t\t\t xread_cb_t xread_cb,\n+\t\t\t\t void *cb_data)\n {\n \tchar *p = buf;\n \tssize_t total = 0;\n \n \twhile (count > 0) {\n-\t\tssize_t loaded = xread(fd, p, count);\n+\t\tssize_t loaded = xread_cb(fd, p, count, cb_data);\n \t\tif (loaded < 0)\n \t\t\treturn -1;\n \t\tif (loaded == 0)\n@@ -303,6 +407,25 @@ ssize_t read_in_full(int fd, void *buf, size_t count)\n \treturn total;\n }\n \n+ssize_t read_in_full_deadline(int fd, void *buf, size_t count,\n+\t\t\t      uint64_t deadline_ns)\n+{\n+\treturn read_in_full_with(fd, buf, count, xread_deadline_fn,\n+\t\t\t\t &deadline_ns);\n+}\n+\n+ssize_t read_in_full_timeout(int fd, void *buf, size_t count, int timeout_ms)\n+{\n+\treturn read_in_full_with(fd, buf, count, xread_timeout_fn,\n+\t\t\t\t &timeout_ms);\n+}\n+\n+/* Non-timeout version for compatibility. */\n+ssize_t read_in_full(int fd, void *buf, size_t count)\n+{\n+\treturn read_in_full_timeout(fd, buf, count, 0);\n+}\n+\n ssize_t write_in_full(int fd, const void *buf, size_t count)\n {\n \tconst char *p = buf;\ndiff --git a/wrapper.h b/wrapper.h\nindex c39992893a81..f8592599216a 100644\n--- a/wrapper.h\n+++ b/wrapper.h\n@@ -15,6 +15,8 @@ const char *mmap_os_err(void);\n void *xmmap_gently(void *start, size_t length, int prot, int flags, int fd, off_t offset);\n int xopen(const char *path, int flags, ...);\n ssize_t xread(int fd, void *buf, size_t len);\n+ssize_t xread_timeout(int fd, void *buf, size_t len, int timeout_ms);\n+ssize_t xread_deadline(int fd, void *buf, size_t len, uint64_t deadline_ns);\n ssize_t xwrite(int fd, const void *buf, size_t len);\n ssize_t xpread(int fd, void *buf, size_t len, off_t offset);\n int xdup(int fd);\n@@ -44,9 +46,30 @@ int git_mkstemps_mode(char *pattern, int suffix_len, int mode);\n int git_mkstemp_mode(char *pattern, int mode);\n \n ssize_t read_in_full(int fd, void *buf, size_t count);\n+ssize_t read_in_full_timeout(int fd, void *buf, size_t count, int timeout_ms);\n+ssize_t read_in_full_deadline(int fd, void *buf, size_t count,\n+\t\t\t      uint64_t deadline_ns);\n ssize_t write_in_full(int fd, const void *buf, size_t count);\n ssize_t pread_in_full(int fd, void *buf, size_t count, off_t offset);\n \n+typedef ssize_t xread_cb_t(int fd, void *buf, size_t len, const void *cb_data);\n+\n+static inline ssize_t xread_timeout_fn(int fd, void *buf, size_t len,\n+\t\t\t\t       const void *cb_data)\n+{\n+\tconst int *timeout_ms = cb_data;\n+\n+\treturn xread_timeout(fd, buf, len, *timeout_ms);\n+}\n+\n+static inline ssize_t xread_deadline_fn(int fd, void *buf, size_t len,\n+\t\t\t\t\tconst void *cb_data)\n+{\n+\tconst uint64_t *deadline_ns = cb_data;\n+\n+\treturn xread_deadline(fd, buf, len, *deadline_ns);\n+}\n+\n static inline ssize_t write_str_in_full(int fd, const char *str)\n {\n \treturn write_in_full(fd, str, strlen(str));\n-- \n2.53.0\n\n"},{"id":"543694","messageId":"70f22e5318ec25acc43fd7818a781391a31e2fd5.1779207350.git.siddh.raman.pant@oracle.com","threadId":"65662","inReplyTo":"cover.1779207350.git.siddh.raman.pant@oracle.com","subject":"[PATCH 6/9] t3301: cover generic displayed notes behavior","fromName":"Siddh Raman Pant","fromEmail":"siddh.raman.pant@oracle.com","sentAt":"2026-05-19T16:30:35Z","receivedAt":"2026-05-19T16:31:23Z","isPatch":true,"body":"Displayed notes already participate in common log behavior.\nAdd explicit coverage for raw notes formatting, --no-notes\nsuppression, explicit notes refs, and --grep matching before\nteaching external notes to feed the same display path.\n\nAssisted-by: Codex:gpt-5.5-xhigh-fast\nSigned-off-by: Siddh Raman Pant <siddh.raman.pant@oracle.com>\n---\n t/t3301-notes.sh | 24 ++++++++++++++++++++++++\n 1 file changed, 24 insertions(+)\n\ndiff --git a/t/t3301-notes.sh b/t/t3301-notes.sh\nindex d6c50460d086..27439010dfbc 100755\n--- a/t/t3301-notes.sh\n+++ b/t/t3301-notes.sh\n@@ -885,6 +885,30 @@ test_expect_success '--show-notes=ref accumulates' '\n \ttest_cmp expect-both-reversed actual\n '\n \n+test_expect_success 'displayed notes honor raw notes formatting' '\n+\tgit show -s --format=%N >actual &&\n+\ttest_grep \"^order test$\" actual &&\n+\t! grep \"Notes\" actual\n+'\n+\n+test_expect_success 'displayed notes are suppressed by --no-notes' '\n+\tgit log --no-notes -1 >actual &&\n+\ttest_cmp expect-not-other actual\n+'\n+\n+test_expect_success 'explicit notes ref replaces default displayed notes' '\n+\tgit log --notes=other -1 >actual &&\n+\ttest_cmp expect-other actual\n+'\n+\n+test_expect_success 'displayed notes are used for grep matching' '\n+\tcommit=$(git rev-parse HEAD) &&\n+\tgit log --grep=\"order test\" -1 >actual &&\n+\ttest_grep \"^commit $commit$\" actual &&\n+\tgit log --no-notes --grep=\"order test\" -1 >actual &&\n+\ttest_must_be_empty actual\n+'\n+\n test_expect_success 'Allow notes on non-commits (trees, blobs, tags)' '\n \ttest_config core.notesRef refs/notes/other &&\n \techo \"Note on a tree\" >expect &&\n-- \n2.53.0\n\n"},{"id":"543695","messageId":"9619077369f1a567bd505b1de1e4f672a5cd1950.1779207350.git.siddh.raman.pant@oracle.com","threadId":"65662","inReplyTo":"cover.1779207350.git.siddh.raman.pant@oracle.com","subject":"[PATCH 7/9] notes: support an external command to display notes","fromName":"Siddh Raman Pant","fromEmail":"siddh.raman.pant@oracle.com","sentAt":"2026-05-19T16:30:36Z","receivedAt":"2026-05-19T16:31:29Z","isPatch":true,"body":"git notes is a very very helpful feature to show user-supplied\ninformation about a commit alongside its message transparently.\n\nFor distributed teams working on large git repos (huge number of\nbranches/refs, files, etc.) and using the notes feature to mark\ninformation on git commits, a TOCTOU race can happen due to very\nlarge size of the repo and notes ref:\n\t- Person A updates a note for commit X.\n\t- Person A pushes the notes but it takes some time.\n\t- Person B fetches notes and doesn't find the updated note.\n\t- Person B can come to know of it only when he overwrites it\n\t  and encounters a push failure.\n\nThis problem excaberates on scale.\n\nOne solution to this is a realtime fetch or faster updation via\nexternal means, but unfortunately we lose the coherence in the\ndisplay of information, and the user would end up reinventing\ngit log.\n\nSo let's add support for an external command to display the notes.\n\nWe split the addition of documentation and tests from this commit for\neasier review. The new help text added in Documentation/ in the next\ncommit should make the usage clear.\n\nAssisted-by: Codex:gpt-5.5-xhigh-fast\nSigned-off-by: Siddh Raman Pant <siddh.raman.pant@oracle.com>\n---\n Makefile             |   2 +\n builtin/log.c        |  17 ++-\n builtin/name-rev.c   |   9 +-\n builtin/range-diff.c |   2 +\n log-tree.c           |   7 +-\n meson.build          |   1 +\n notes-external.c     | 330 +++++++++++++++++++++++++++++++++++++++++++\n notes-external.h     |  19 +++\n notes.c              | 242 ++++++++++++++++++++++++-------\n notes.h              |  32 ++++-\n revision.c           |  32 ++++-\n 11 files changed, 633 insertions(+), 60 deletions(-)\n create mode 100644 notes-external.c\n create mode 100644 notes-external.h\n\ndiff --git a/Makefile b/Makefile\nindex c739ae78d0ef..a919bdd75f01 100644\n--- a/Makefile\n+++ b/Makefile\n@@ -838,6 +838,7 @@ TEST_BUILTINS_OBJS += test-match-trees.o\n TEST_BUILTINS_OBJS += test-mergesort.o\n TEST_BUILTINS_OBJS += test-mktemp.o\n TEST_BUILTINS_OBJS += test-name-hash.o\n+TEST_BUILTINS_OBJS += test-notes-external-config-reset.o\n TEST_BUILTINS_OBJS += test-online-cpus.o\n TEST_BUILTINS_OBJS += test-pack-deltas.o\n TEST_BUILTINS_OBJS += test-pack-mtimes.o\n@@ -1209,6 +1210,7 @@ LIB_OBJS += negotiator/default.o\n LIB_OBJS += negotiator/noop.o\n LIB_OBJS += negotiator/skipping.o\n LIB_OBJS += notes-cache.o\n+LIB_OBJS += notes-external.o\n LIB_OBJS += notes-merge.o\n LIB_OBJS += notes-utils.o\n LIB_OBJS += notes.o\ndiff --git a/builtin/log.c b/builtin/log.c\nindex 8c0939dd42ad..bed4c1576f2d 100644\n--- a/builtin/log.c\n+++ b/builtin/log.c\n@@ -1337,9 +1337,24 @@ static void get_notes_args(struct strvec *arg, struct rev_info *rev)\n \t\t   (rev->notes_opt.use_default_notes == -1 &&\n \t\t    !rev->notes_opt.extra_notes_refs.nr)) {\n \t\tstrvec_push(arg, \"--notes\");\n-\t} else {\n+\t} else if (rev->notes_opt.extra_notes_refs.nr) {\n \t\tfor_each_string_list(&rev->notes_opt.extra_notes_refs, get_notes_refs, arg);\n+\t} else if (rev->notes_opt.use_external_notes <= 0) {\n+\t\t/*\n+\t\t * rev->show_notes can stay set after\n+\t\t * --external-notes --no-external-notes.\n+\t\t *\n+\t\t * Since range-diff's child log starts with\n+\t\t * --show-notes-by-default, explicitly suppress\n+\t\t * notes when no notes source remains.\n+\t\t */\n+\t\tstrvec_push(arg, \"--no-notes\");\n \t}\n+\n+\tif (rev->notes_opt.use_external_notes > 0)\n+\t\tstrvec_push(arg, \"--external-notes\");\n+\telse if (rev->notes_opt.use_external_notes == 0)\n+\t\tstrvec_push(arg, \"--no-external-notes\");\n }\n \n static void generate_shortlog_cover_letter(struct shortlog *log,\ndiff --git a/builtin/name-rev.c b/builtin/name-rev.c\nindex 60cbbfb4b7d1..95d5e076e24b 100644\n--- a/builtin/name-rev.c\n+++ b/builtin/name-rev.c\n@@ -277,6 +277,7 @@ struct name_ref_data {\n struct pretty_format {\n \tstruct pretty_print_context ctx;\n \tstruct userformat_want want;\n+\tbool show_external_notes;\n };\n \n enum command_type {\n@@ -525,9 +526,9 @@ static const char *get_format_rev(const struct commit *c,\n \tif (format_ctx->want.notes) {\n \t\tstruct strbuf notebuf = STRBUF_INIT;\n \n-\t\tformat_display_notes(&c->object.oid, &notebuf,\n-\t\t\t\t     get_log_output_encoding(),\n-\t\t\t\t     format_ctx->ctx.fmt == CMIT_FMT_USERFORMAT);\n+\t\tformat_display_notes(c, &notebuf, get_log_output_encoding(),\n+\t\t\t\t     format_ctx->ctx.fmt == CMIT_FMT_USERFORMAT,\n+\t\t\t\t     format_ctx->show_external_notes);\n \t\tformat_ctx->ctx.notes_message = strbuf_detach(&notebuf, NULL);\n \t}\n \n@@ -878,6 +879,8 @@ int cmd_format_rev(int argc,\n \t\t\tenable_ref_display_notes(&format_notes_opt,\n \t\t\t\t\t\t &ignore_show_notes,\n \t\t\t\t\t\t n->string);\n+\t\tformat_pp.show_external_notes =\n+\t\t\tdisplay_notes_use_external(&format_notes_opt);\n \t\tload_display_notes(&format_notes_opt);\n \t}\n \ndiff --git a/builtin/range-diff.c b/builtin/range-diff.c\nindex e54c0f7fe156..41c27250404a 100644\n--- a/builtin/range-diff.c\n+++ b/builtin/range-diff.c\n@@ -56,6 +56,8 @@ int cmd_range_diff(int argc,\n \t\tOPT_PASSTHRU_ARGV(0, \"notes\", &log_arg,\n \t\t\t\t  N_(\"notes\"), N_(\"passed to 'git log'\"),\n \t\t\t\t  PARSE_OPT_OPTARG),\n+\t\tOPT_PASSTHRU_ARGV(0, \"external-notes\", &log_arg, NULL,\n+\t\t\t\t  N_(\"passed to 'git log'\"), PARSE_OPT_NOARG),\n \t\tOPT_PASSTHRU_ARGV(0, \"diff-merges\", &diff_merges_arg,\n \t\t\t\t  N_(\"style\"), N_(\"passed to 'git log'\"), 0),\n \t\tOPT_CALLBACK(0, \"max-memory\", &range_diff_opts.max_memory,\ndiff --git a/log-tree.c b/log-tree.c\nindex 4503a42dde6b..3289a085f66b 100644\n--- a/log-tree.c\n+++ b/log-tree.c\n@@ -856,9 +856,12 @@ void show_log(struct rev_info *opt)\n \tif (opt->show_notes) {\n \t\tstruct strbuf notebuf = STRBUF_INIT;\n \t\tbool raw = (opt->commit_format == CMIT_FMT_USERFORMAT);\n+\t\tconst struct display_notes_opt *notes_opt = &opt->notes_opt;\n+\n+\t\tformat_display_notes(commit, &notebuf,\n+\t\t\t\t     get_log_output_encoding(), raw,\n+\t\t\t\t     display_notes_use_external(notes_opt));\n \n-\t\tformat_display_notes(&commit->object.oid, &notebuf,\n-\t\t\t\t     get_log_output_encoding(), raw);\n \t\tctx.notes_message = strbuf_detach(&notebuf, NULL);\n \t}\n \ndiff --git a/meson.build b/meson.build\nindex de917bcf1146..21cdbc15aa18 100644\n--- a/meson.build\n+++ b/meson.build\n@@ -397,6 +397,7 @@ libgit_sources = [\n   'notes-merge.c',\n   'notes-utils.c',\n   'notes.c',\n+  'notes-external.c',\n   'object-file-convert.c',\n   'object-file.c',\n   'object-name.c',\ndiff --git a/notes-external.c b/notes-external.c\nnew file mode 100644\nindex 000000000000..7d6b2c6060e0\n--- /dev/null\n+++ b/notes-external.c\n@@ -0,0 +1,330 @@\n+#include \"git-compat-util.h\"\n+#include \"gettext.h\"\n+#include \"hex.h\"\n+#include \"notes-external.h\"\n+#include \"run-command.h\"\n+#include \"sigchain.h\"\n+#include \"strbuf.h\"\n+#include \"trace.h\"\n+\n+#define convert_ms_to_ns(ms) (uint64_t)(ms) * 1000000ULL\n+#define convert_ns_to_ms(ns) (uint64_t)(ns) / 1000000ULL\n+#define EXTERNAL_NOTES_TERMINATE_GRACE_NS 100000000ULL\t/* 100 ms = 10^8 ns */\n+#define EXTERNAL_NOTES_READ_CHUNK_SIZE 16384\t/* (16 * 1024) bytes */\n+\n+#ifdef GIT_WINDOWS_NATIVE\n+#define EXTERNAL_NOTES_FORCE_KILL_SIGNAL SIGTERM\n+#else\n+#define EXTERNAL_NOTES_FORCE_KILL_SIGNAL SIGKILL\n+#endif\n+\n+/* ------------------------------------------------------------------------- */\n+\n+/* Configuration helpers. */\n+\n+static char *external_notes_command;\n+static char *external_notes_command_name_value;\n+static uint64_t external_notes_read_timeout_ns = convert_ms_to_ns(100);\n+static int external_notes_command_failed;\n+static int external_notes_for_grep;\n+static bool external_notes_started;\n+\n+void set_external_notes_command(const char *command)\n+{\n+\tFREE_AND_NULL(external_notes_command);\n+\tif (command && *command)\n+\t\texternal_notes_command = xstrdup(command);\n+}\n+\n+void set_external_notes_command_name(const char *name)\n+{\n+\tFREE_AND_NULL(external_notes_command_name_value);\n+\tif (name && *name)\n+\t\texternal_notes_command_name_value = xstrdup(name);\n+}\n+\n+void set_external_notes_command_timeout_ms(int timeout_ms)\n+{\n+\tif (timeout_ms < 0)\n+\t\tBUG(\"negative notes.externalCommandTimeoutMs\");\n+\n+\texternal_notes_read_timeout_ns = convert_ms_to_ns(timeout_ms);\n+}\n+\n+void reset_external_notes_command(void)\n+{\n+\tif (external_notes_started)\n+\t\tBUG(\"cannot reset external notes config while cmd is running\");\n+\n+\tFREE_AND_NULL(external_notes_command);\n+\tFREE_AND_NULL(external_notes_command_name_value);\n+\texternal_notes_read_timeout_ns = convert_ms_to_ns(100);\n+\texternal_notes_command_failed = 0;\n+\texternal_notes_for_grep = 0;\n+}\n+\n+int external_notes_command_configured(void)\n+{\n+\treturn external_notes_command && !external_notes_command_failed;\n+}\n+\n+const char *external_notes_command_name(void)\n+{\n+\treturn external_notes_command_name_value ?\n+\t\texternal_notes_command_name_value : \"external\";\n+}\n+\n+int external_notes_command_timeout_ms(void)\n+{\n+\treturn (int)convert_ns_to_ms(external_notes_read_timeout_ns);\n+}\n+\n+void set_external_notes_for_grep(int enabled)\n+{\n+\texternal_notes_for_grep = enabled;\n+}\n+\n+int external_notes_for_grep_enabled(void)\n+{\n+\treturn external_notes_for_grep;\n+}\n+\n+/* ------------------------------------------------------------------------- */\n+\n+/* Process management helpers. */\n+\n+static struct child_process external_notes_process = CHILD_PROCESS_INIT;\n+static FILE *external_notes_in;\n+static int external_notes_out_fd = -1;\n+\n+static void mute_routine(const char *msg UNUSED, va_list params UNUSED)\n+{\n+\t/* do nothing */\n+}\n+\n+static void close_external_notes_process_pipes(struct child_process *process)\n+{\n+\tsigchain_push(SIGPIPE, SIG_IGN);\n+\n+\tif (external_notes_in) {\n+\t\tfclose(external_notes_in);\n+\t\texternal_notes_in = NULL;\n+\t} else {\n+\t\tclose(process->in);\n+\t}\n+\n+\tif (external_notes_out_fd >= 0) {\n+\t\tclose(external_notes_out_fd);\n+\t\texternal_notes_out_fd = -1;\n+\t} else {\n+\t\tclose(process->out);\n+\t}\n+\n+\tsigchain_pop(SIGPIPE);\n+}\n+\n+/* We set this as callback later, so can't have void argument. */\n+static void cleanup_external_notes_process(struct child_process *process)\n+{\n+\treport_fn old_error = NULL;\n+\n+\t/**\n+\t * The helper may still be sleeping with its pipes open, or may not\n+\t * exit promptly after EOF. Ask it to stop, then use a bounded wait\n+\t * that escalates if it ignores the signal.\n+\t */\n+\tkill(process->pid, SIGTERM);\n+\told_error = get_error_routine();\n+\tset_error_routine(mute_routine);\n+\n+\tclose_external_notes_process_pipes(process);\n+\tfinish_command_with_timeout(process, EXTERNAL_NOTES_TERMINATE_GRACE_NS,\n+\t\t\t\t    EXTERNAL_NOTES_FORCE_KILL_SIGNAL);\n+\n+\tif (old_error)\n+\t\tset_error_routine(old_error);\n+\n+\texternal_notes_started = false;\n+}\n+\n+static void stop_external_notes_process(void)\n+{\n+\tif (!external_notes_started)\n+\t\treturn;\n+\n+\texternal_notes_process.clean_on_exit = 0;\n+\tcleanup_external_notes_process(&external_notes_process);\n+\tchild_process_init(&external_notes_process);\n+}\n+\n+static int fail_external_notes_command(void)\n+{\n+\tif (!external_notes_command_failed)\n+\t\twarning(_(\"notes.externalCommand failed: %s\"),\n+\t\t\texternal_notes_command);\n+\n+\texternal_notes_command_failed = 1;\n+\tstop_external_notes_process();\n+\treturn -1;\n+}\n+\n+static int start_external_notes_command(void)\n+{\n+\tstruct child_process *cmd = &external_notes_process;\n+\n+\tif (external_notes_started)\n+\t\treturn 0;\n+\n+\tif (!external_notes_command || external_notes_command_failed)\n+\t\treturn -1;\n+\n+\tchild_process_init(cmd);\n+\tstrvec_push(&cmd->args, external_notes_command);\n+\tcmd->use_shell = 1;\n+\tcmd->in = -1;\n+\tcmd->out = -1;\n+\tcmd->clean_on_exit = 1;\n+\tcmd->clean_on_exit_handler = cleanup_external_notes_process;\n+\tcmd->trace2_child_class = \"notes-external\";\n+\n+\tif (start_command(cmd))\n+\t\treturn fail_external_notes_command();\n+\n+\texternal_notes_in = xfdopen(cmd->in, \"wb\");\n+\texternal_notes_out_fd = cmd->out;\n+\texternal_notes_started = true;\n+\treturn 0;\n+}\n+\n+/* ------------------------------------------------------------------------- */\n+\n+/* Command parser. Essentially the main() function of this file. */\n+int format_external_note(const struct object_id *object_oid,\n+\t\t\t struct strbuf *note_buf)\n+{\n+\tstruct strbuf status = STRBUF_INIT;\n+\tchar commit_id_hex_str[GIT_MAX_HEXSZ + 1];\n+\tconst char *arg;\n+\tchar *end;\n+\tchar ch;\n+\tunsigned long len;\n+\tuint64_t deadline_ns;\n+\tbool input_fail;\n+\tint ret = 0;\n+\n+\tif (start_external_notes_command())\n+\t\treturn -1;\n+\n+\t/* Fetch the commit ID hex. */\n+\toid_to_hex_r(commit_id_hex_str, object_oid);\n+\n+\t/* Pass the input to the external command. */\n+\tsigchain_push(SIGPIPE, SIG_IGN);\n+\tinput_fail = fprintf(external_notes_in, \"%s\\n\", commit_id_hex_str) < 0\n+\t\t     || fflush(external_notes_in) != 0;\n+\tsigchain_pop(SIGPIPE);\n+\n+\tif (input_fail)\n+\t\tgoto out_fail;\n+\n+\tif (external_notes_read_timeout_ns == 0)\n+\t\tdeadline_ns = 0;\n+\telse\n+\t\tdeadline_ns = getnanotime() + external_notes_read_timeout_ns;\n+\n+\t/**\n+\t * The output for each commit is either of the two:\n+\t * \t\"{commit id} missing\\n\"\n+\t * \t\"{commit id} ok {num_bytes}\\n{str_of_num_bytes}\\n\"\n+\t *\n+\t * We can have \"\\r\\n\" instead of \"\\n\" due to Windows.\n+\t */\n+\n+\t/* Read the first line with its delimiter. */\n+\tif (strbuf_getwholeline_fd_deadline(&status, external_notes_out_fd,\n+\t\t\t\t\t    '\\n', deadline_ns) == EOF)\n+\t\tgoto out_fail;\n+\n+\t/* Reject EOF-terminated partial lines. */\n+\tif (!status.len || status.buf[status.len - 1] != '\\n')\n+\t\tgoto out_fail;\n+\n+\t/**\n+\t * Strip LF and then optional CR so both LF and CRLF protocol lines\n+\t * are accepted.\n+\t */\n+\tstrbuf_setlen(&status, status.len - 1);\n+\tstrbuf_strip_suffix(&status, \"\\r\");\n+\n+\t/* Check if line starts with the commit ID. */\n+\tif (!skip_prefix(status.buf, commit_id_hex_str, &arg))\n+\t\tgoto out_fail;\n+\n+\tif (*arg++ != ' ')  /* After commit ID there should be a space. */\n+\t\tgoto out_fail;\n+\n+\tif (strcmp(arg, \"missing\") == 0)  /* No note available. */\n+\t\tgoto out_success;  /* Ending newline is already ensured. */\n+\n+\tif (!skip_prefix(arg, \"ok \", &arg))  /* Neither missing nor ok. */\n+\t\tgoto out_fail;\n+\n+\t/* We are in \"ok\" case. */\n+\n+\t/* The next thing is length of the note. It must be unsigned digits. */\n+\tif (!isdigit(*arg))\n+\t\tgoto out_fail;\n+\n+\t/* Get the length of note. */\n+\terrno = 0;\n+\tlen = strtoul(arg, &end, 10);\n+\tif (errno != 0 || *end != '\\0' || end == arg)\n+\t\tgoto out_fail;\n+\n+\t/* Ending newline is already ensured. */\n+\n+\t/* Read the trailing note in bounded-chunks. */\n+\twhile (note_buf->len < len) {\n+\t\tssize_t got;\n+\t\tsize_t remaining = len - note_buf->len;\n+\t\tsize_t want = remaining < EXTERNAL_NOTES_READ_CHUNK_SIZE ?\n+\t\t\t      remaining : EXTERNAL_NOTES_READ_CHUNK_SIZE;\n+\n+\t\tstrbuf_grow(note_buf, want);\n+\n+\t\tgot = read_in_full_deadline(external_notes_out_fd,\n+\t\t\t\t\t    note_buf->buf + note_buf->len,\n+\t\t\t\t\t    want, deadline_ns);\n+\t\tif (got < 0 || (size_t)got != want)\n+\t\t\tgoto out_fail;\n+\n+\t\tstrbuf_setlen(note_buf, note_buf->len + (size_t)got);\n+\t}\n+\n+\t/* Ensure the ending newline (LF/CRLF) after the note. */\n+\tif (xread_deadline(external_notes_out_fd, &ch, 1, deadline_ns) != 1)\n+\t\tgoto out_fail;\n+\n+\tif (ch != '\\n') {  /* Not a LF. */\n+\t\tif (ch != '\\r')  /* Not a CRLF. */\n+\t\t\tgoto out_fail;\n+\n+\t\t/* We have '\\r', let's read the next char. */\n+\t\tif (xread_deadline(external_notes_out_fd, &ch, 1,\n+\t\t\t\t   deadline_ns) != 1)\n+\t\t\tgoto out_fail;\n+\n+\t\tif (ch != '\\n')  /* Not a CRLF. */\n+\t\t\tgoto out_fail;\n+\t}\n+\n+\tgoto out_success;\n+\n+out_fail:\n+\tret = fail_external_notes_command();\n+out_success:\n+\tstrbuf_release(&status);\n+\treturn ret;\n+}\n+\n+/* ------------------------------------------------------------------------- */\ndiff --git a/notes-external.h b/notes-external.h\nnew file mode 100644\nindex 000000000000..e49a50b09063\n--- /dev/null\n+++ b/notes-external.h\n@@ -0,0 +1,19 @@\n+#ifndef NOTES_EXTERNAL_H\n+#define NOTES_EXTERNAL_H\n+\n+struct object_id;\n+struct strbuf;\n+\n+void set_external_notes_command(const char *command);\n+void set_external_notes_command_name(const char *name);\n+void set_external_notes_command_timeout_ms(int timeout_ms);\n+void set_external_notes_for_grep(int enabled);\n+void reset_external_notes_command(void);\n+int external_notes_command_configured(void);\n+const char *external_notes_command_name(void);\n+int external_notes_command_timeout_ms(void);\n+int external_notes_for_grep_enabled(void);\n+int format_external_note(const struct object_id *object_oid,\n+\t\t\t struct strbuf *out);\n+\n+#endif  /* NOTES_EXTERNAL_H */\ndiff --git a/notes.c b/notes.c\nindex 201f1df3dc29..0ff8ba94afc5 100644\n--- a/notes.c\n+++ b/notes.c\n@@ -3,9 +3,12 @@\n \n #include \"git-compat-util.h\"\n #include \"config.h\"\n+#include \"commit.h\"\n #include \"environment.h\"\n+#include \"gettext.h\"\n #include \"hex.h\"\n #include \"notes.h\"\n+#include \"notes-external.h\"\n #include \"object-file.h\"\n #include \"object-name.h\"\n #include \"odb.h\"\n@@ -983,18 +986,56 @@ void string_list_add_refs_from_colon_sep(struct string_list *list,\n \tfree(globs_copy);\n }\n \n+struct notes_display_config_data {\n+\tint load_refs;\n+\tint load_command;\n+};\n+\n static int notes_display_config(const char *k, const char *v,\n-\t\t\t\tconst struct config_context *ctx UNUSED,\n+\t\t\t\tconst struct config_context *ctx,\n \t\t\t\tvoid *cb)\n {\n-\tint *load_refs = cb;\n+\tstruct notes_display_config_data *data = cb;\n \n-\tif (*load_refs && !strcmp(k, \"notes.displayref\")) {\n+\tif (data->load_refs && !strcmp(k, \"notes.displayref\")) {\n \t\tif (!v)\n \t\t\treturn config_error_nonbool(k);\n \t\tstring_list_add_refs_by_glob(&display_notes_refs, v);\n \t}\n \n+\tif (data->load_command && !strcmp(k, \"notes.externalcommand\")) {\n+\t\tif (!v)\n+\t\t\treturn config_error_nonbool(k);\n+\n+\t\tset_external_notes_command(v);\n+\t}\n+\n+\tif (data->load_command && !strcmp(k, \"notes.externalcommandname\")) {\n+\t\tif (!v)\n+\t\t\treturn config_error_nonbool(k);\n+\n+\t\tif (strchr(v, '\\n') || strchr(v, '\\r'))\n+\t\t\treturn error(_(\"notes.externalCommandName must not contain a newline\"));\n+\n+\t\tset_external_notes_command_name(v);\n+\t}\n+\n+\tif (data->load_command && !strcmp(k, \"notes.externalcommandtimeoutms\")) {\n+\t\tint timeout_ms;\n+\n+\t\tif (!v)\n+\t\t\treturn config_error_nonbool(k);\n+\n+\t\ttimeout_ms = git_config_int(k, v, ctx->kvi);\n+\t\tif (timeout_ms < 0)\n+\t\t\treturn error(_(\"notes.externalCommandTimeoutMs must be non-negative\"));\n+\n+\t\tset_external_notes_command_timeout_ms(timeout_ms);\n+\t}\n+\n+\tif (data->load_command && !strcmp(k, \"notes.externalcommandforgrep\"))\n+\t\tset_external_notes_for_grep(git_config_bool(k, v));\n+\n \treturn 0;\n }\n \n@@ -1075,6 +1116,7 @@ void init_display_notes(struct display_notes_opt *opt)\n {\n \tmemset(opt, 0, sizeof(*opt));\n \topt->use_default_notes = -1;\n+\topt->use_external_notes = -1;\n \tstring_list_init_dup(&opt->extra_notes_refs);\n }\n \n@@ -1086,6 +1128,7 @@ void release_display_notes(struct display_notes_opt *opt)\n void enable_default_display_notes(struct display_notes_opt *opt, int *show_notes)\n {\n \topt->use_default_notes = 1;\n+\topt->default_notes_suppressed_by_external = 0;\n \t*show_notes = 1;\n }\n \n@@ -1102,31 +1145,85 @@ void enable_ref_display_notes(struct display_notes_opt *opt, int *show_notes,\n void disable_display_notes(struct display_notes_opt *opt, int *show_notes)\n {\n \topt->use_default_notes = -1;\n+\topt->use_external_notes = -1;\n+\topt->default_notes_suppressed_by_external = 0;\n \tstring_list_clear(&opt->extra_notes_refs, 0);\n \t*show_notes = 0;\n }\n \n+/*\n+ * Resolve the default-notes tri-state in one place. Callers must not test\n+ * use_default_notes directly unless they specifically need the unresolved\n+ * command-line state.\n+ */\n+static bool display_notes_use_default(const struct display_notes_opt *opt)\n+{\n+\t/* Options aren't specified, default to true. */\n+\tif (!opt)\n+\t\treturn true;\n+\n+\t/* Explicitly enabled. */\n+\tif (opt->use_default_notes > 0)\n+\t\treturn true;\n+\n+\t/* Undefined and no explicit notes-ref specified, default to true. */\n+\tif (opt->use_default_notes == -1 && !opt->extra_notes_refs.nr)\n+\t\treturn true;\n+\n+\treturn false;\n+}\n+\n+/*\n+ * Resolve the external-notes tri-state. The unset value follows the resolved\n+ * default-notes decision, which means \"git log\" runs the helper by default\n+ * but \"git log --notes=<ref>\" does not.\n+ */\n+bool display_notes_use_external(const struct display_notes_opt *opt)\n+{\n+\t/* Options aren't specified, default to true. */\n+\tif (!opt)\n+\t\treturn true;\n+\n+\t/* Explicitly enabled. */\n+\tif (opt->use_external_notes > 0)\n+\t\treturn true;\n+\n+\t/* Undefined and to use default notes set, default to true. */\n+\tif (opt->use_external_notes < 0 && display_notes_use_default(opt))\n+\t\treturn true;\n+\n+\treturn false;\n+}\n+\n void load_display_notes(struct display_notes_opt *opt)\n {\n \tchar *display_ref_env;\n-\tint load_config_refs = 0;\n+\tstruct notes_display_config_data config = { 0, 0 };\n+\tstruct notes_display_config_data protected_config = { 0, 0 };\n+\tbool use_default_notes = display_notes_use_default(opt);\n+\tbool use_external_notes = display_notes_use_external(opt);\n+\n \tdisplay_notes_refs.strdup_strings = 1;\n+\treset_external_notes_command();\n \n \tassert(!display_notes_trees);\n \n-\tif (!opt || opt->use_default_notes > 0 ||\n-\t    (opt->use_default_notes == -1 && !opt->extra_notes_refs.nr)) {\n+\tif (use_default_notes) {\n \t\tstring_list_append_nodup(&display_notes_refs, default_notes_ref(the_repository));\n \t\tdisplay_ref_env = getenv(GIT_NOTES_DISPLAY_REF_ENVIRONMENT);\n \t\tif (display_ref_env) {\n \t\t\tstring_list_add_refs_from_colon_sep(&display_notes_refs,\n \t\t\t\t\t\t\t    display_ref_env);\n-\t\t\tload_config_refs = 0;\n+\t\t\tconfig.load_refs = 0;\n \t\t} else\n-\t\t\tload_config_refs = 1;\n+\t\t\tconfig.load_refs = 1;\n \t}\n \n-\trepo_config(the_repository, notes_display_config, &load_config_refs);\n+\tif (use_external_notes)\n+\t\tprotected_config.load_command = 1;\n+\n+\trepo_config(the_repository, notes_display_config, &config);\n+\tgit_protected_config(notes_display_config, &protected_config);\n \n \tif (opt) {\n \t\tstruct string_list_item *item;\n@@ -1266,47 +1363,31 @@ void free_notes(struct notes_tree *t)\n }\n \n /*\n- * Fill the given strbuf with the notes associated with the given object.\n+ * Append one already-loaded note message to the given strbuf.\n  *\n- * If the given notes_tree structure is not initialized, it will be auto-\n- * initialized to the default value (see documentation for init_notes() above).\n- * If the given notes_tree is NULL, the internal/default notes_tree will be\n- * used instead.\n+ * Notes read from refs and notes obtained from notes.externalCommand both use\n+ * this helper so they share the same encoding, header, and indentation rules.\n  *\n  * (raw == true) gives the %N userformat; otherwise, the note message is given\n  * for human consumption.\n  */\n-static void format_note(struct notes_tree *t, const struct object_id *object_oid,\n-\t\t\tstruct strbuf *sb, const char *output_encoding, bool raw)\n+static void format_note_data(const char *ref, const char *msg, size_t msglen,\n+\t\t\t     struct strbuf *sb, const char *output_encoding,\n+\t\t\t     bool raw, bool literal_ref)\n {\n \tstatic const char utf8[] = \"utf-8\";\n-\tconst struct object_id *oid;\n-\tchar *msg, *msg_p;\n-\tunsigned long linelen, msglen;\n-\tenum object_type type;\n-\n-\tif (!t)\n-\t\tt = &default_notes_tree;\n-\tif (!t->initialized)\n-\t\tinit_notes(t, NULL, NULL, 0);\n-\n-\toid = get_note(t, object_oid);\n-\tif (!oid)\n-\t\treturn;\n-\n-\tif (!(msg = odb_read_object(the_repository->objects, oid, &type, &msglen)) ||\n-\t    type != OBJ_BLOB) {\n-\t\tfree(msg);\n-\t\treturn;\n-\t}\n+\tchar *reencoded = NULL;\n+\tconst char *msg_p, *msg_end;\n \n+\t/* Convert the note text from UTF-8 to the requested output encoding. */\n \tif (output_encoding && *output_encoding &&\n \t    !is_encoding_utf8(output_encoding)) {\n-\t\tchar *reencoded = reencode_string(msg, output_encoding, utf8);\n+\t\tsize_t reencoded_len;\n+\t\treencoded = reencode_string_len(msg, msglen, output_encoding,\n+\t\t\t\t\t\t utf8, &reencoded_len);\n \t\tif (reencoded) {\n-\t\t\tfree(msg);\n \t\t\tmsg = reencoded;\n-\t\t\tmsglen = strlen(msg);\n+\t\t\tmsglen = reencoded_len;\n \t\t}\n \t}\n \n@@ -1314,37 +1395,100 @@ static void format_note(struct notes_tree *t, const struct object_id *object_oid\n \tif (msglen && msg[msglen - 1] == '\\n')\n \t\tmsglen--;\n \n+\t/* Raw mode is the %N userformat, so it omits the \"Notes\" header. */\n \tif (!raw) {\n-\t\tconst char *ref = t->ref;\n-\t\tif (!ref || !strcmp(ref, GIT_NOTES_DEFAULT_REF)) {\n+\t\tif (!ref)\n \t\t\tstrbuf_addstr(sb, \"\\nNotes:\\n\");\n-\t\t} else {\n-\t\t\tskip_prefix(ref, \"refs/\", &ref);\n-\t\t\tskip_prefix(ref, \"notes/\", &ref);\n+\t\telse if (!literal_ref && !strcmp(ref, GIT_NOTES_DEFAULT_REF))\n+\t\t\tstrbuf_addstr(sb, \"\\nNotes:\\n\");\n+\t\telse {\n+\t\t\tif (!literal_ref) {\n+\t\t\t\tskip_prefix(ref, \"refs/\", &ref);\n+\t\t\t\tskip_prefix(ref, \"notes/\", &ref);\n+\t\t\t}\n \t\t\tstrbuf_addf(sb, \"\\nNotes (%s):\\n\", ref);\n \t\t}\n \t}\n \n-\tfor (msg_p = msg; msg_p < msg + msglen; msg_p += linelen + 1) {\n-\t\tlinelen = strchrnul(msg_p, '\\n') - msg_p;\n+\tmsg_end = msg + msglen;\n+\tfor (msg_p = msg; msg_p < msg_end; ) {\n+\t\tconst char *eol = memchr(msg_p, '\\n', msg_end - msg_p);\n+\t\tsize_t linelen = eol ? eol - msg_p : msg_end - msg_p;\n \n+\t\t/* Human output indents note body lines under the header. */\n \t\tif (!raw)\n \t\t\tstrbuf_addstr(sb, \"    \");\n+\n \t\tstrbuf_add(sb, msg_p, linelen);\n \t\tstrbuf_addch(sb, '\\n');\n+\n+\t\tmsg_p += linelen;\n+\t\tif (msg_p < msg_end)\n+\t\t\tmsg_p++;\n+\t}\n+\n+\tfree(reencoded);\n+}\n+\n+/*\n+ * Fill the given strbuf with the notes associated with the given object.\n+ *\n+ * If the given notes_tree structure is not initialized, it will be auto-\n+ * initialized to the default value (see documentation for init_notes() above).\n+ * If the given notes_tree is NULL, the internal/default notes_tree will be\n+ * used instead.\n+ */\n+static void format_note_from_tree(struct notes_tree *t,\n+\t\t\t\t  const struct object_id *object_oid,\n+\t\t\t\t  struct strbuf *sb,\n+\t\t\t\t  const char *output_encoding, bool raw)\n+{\n+\tconst struct object_id *oid;\n+\tchar *msg;\n+\tunsigned long msglen;\n+\tenum object_type type;\n+\n+\tif (!t)\n+\t\tt = &default_notes_tree;\n+\tif (!t->initialized)\n+\t\tinit_notes(t, NULL, NULL, 0);\n+\n+\toid = get_note(t, object_oid);\n+\tif (!oid)\n+\t\treturn;\n+\n+\tif (!(msg = odb_read_object(the_repository->objects, oid, &type, &msglen)) ||\n+\t    type != OBJ_BLOB) {\n+\t\tfree(msg);\n+\t\treturn;\n \t}\n \n+\tformat_note_data(t->ref, msg, msglen, sb, output_encoding, raw, false);\n+\n \tfree(msg);\n }\n \n-void format_display_notes(const struct object_id *object_oid,\n-\t\t\t  struct strbuf *sb, const char *output_encoding, bool raw)\n+void format_display_notes(const struct commit *commit,\n+\t\t\t  struct strbuf *sb, const char *output_encoding,\n+\t\t\t  bool raw, bool show_external)\n {\n \tint i;\n+\tconst struct object_id *commit_oid = &commit->object.oid;\n+\n \tassert(display_notes_trees);\n \tfor (i = 0; display_notes_trees[i]; i++)\n-\t\tformat_note(display_notes_trees[i], object_oid, sb,\n-\t\t\t    output_encoding, raw);\n+\t\tformat_note_from_tree(display_notes_trees[i], commit_oid, sb,\n+\t\t\t\t      output_encoding, raw);\n+\n+\tif (show_external && external_notes_command_configured()) {\n+\t\tstruct strbuf out = STRBUF_INIT;\n+\n+\t\tif (format_external_note(commit_oid, &out) == 0 && out.len)\n+\t\t\tformat_note_data(external_notes_command_name(),\n+\t\t\t\t\t out.buf, out.len, sb,\n+\t\t\t\t\t output_encoding, raw, true);\n+\t\tstrbuf_release(&out);\n+\t}\n }\n \n int copy_note(struct notes_tree *t,\ndiff --git a/notes.h b/notes.h\nindex f6410b31e1c9..748af70e34af 100644\n--- a/notes.h\n+++ b/notes.h\n@@ -6,6 +6,7 @@\n struct object_id;\n struct repository;\n struct strbuf;\n+struct commit;\n \n /*\n  * Function type for combining two notes annotating the same object.\n@@ -264,6 +265,20 @@ struct display_notes_opt {\n \t */\n \tint use_default_notes;\n \n+\t/*\n+\t * Less than `0` is \"unset\", which means external notes are shown iff\n+\t * the default notes are shown. Otherwise, treat it like a boolean.\n+\t */\n+\tint use_external_notes;\n+\n+\t/*\n+\t * Tracks the synthetic \"default notes off\" state introduced by\n+\t * `--external-notes`, so a later deprecated `--show-notes=<ref>`\n+\t * can still preserve its historical additive behavior without\n+\t * overriding an explicit `--no-standard-notes`.\n+\t */\n+\tint default_notes_suppressed_by_external;\n+\n \t/*\n \t * A list of globs (in the same style as notes.displayRef) where\n \t * notes should be loaded from.\n@@ -304,16 +319,27 @@ void disable_display_notes(struct display_notes_opt *opt, int *show_notes);\n void load_display_notes(struct display_notes_opt *opt);\n \n /*\n- * Append notes for the given 'object_sha1' from all trees set up by\n+ * Return true if notes.externalCommand should be used for 'opt'.\n+ *\n+ * 'opt' may be NULL.\n+ */\n+bool display_notes_use_external(const struct display_notes_opt *opt);\n+\n+/*\n+ * Append notes for the given commit from all trees set up by\n  * load_display_notes() to 'sb'.\n  *\n  * If 'raw' is false the note will be indented by 4 places and\n  * a 'Notes (refname):' header added.\n  *\n+ * If 'show_external' is true then notes.externalCommand will be used to append\n+ * the note from external source.\n+ *\n  * You *must* call load_display_notes() before using this function.\n  */\n-void format_display_notes(const struct object_id *object_oid,\n-\t\t\t  struct strbuf *sb, const char *output_encoding, bool raw);\n+void format_display_notes(const struct commit *commit,\n+\t\t\t  struct strbuf *sb, const char *output_encoding,\n+\t\t\t  bool raw, bool show_external);\n \n /*\n  * Load the notes tree from each ref listed in 'refs'.  The output is\ndiff --git a/revision.c b/revision.c\nindex cd9fcefa0a88..84d9af961988 100644\n--- a/revision.c\n+++ b/revision.c\n@@ -6,6 +6,7 @@\n #include \"environment.h\"\n #include \"gettext.h\"\n #include \"hex.h\"\n+#include \"notes-external.h\"\n #include \"object-name.h\"\n #include \"object-file.h\"\n #include \"odb.h\"\n@@ -2583,18 +2584,40 @@ static int handle_revision_opt(struct rev_info *revs, int argc, const char **arg\n \t} else if (skip_prefix(arg, \"--show-notes=\", &optarg) ||\n \t\t   skip_prefix(arg, \"--notes=\", &optarg)) {\n \t\tif (starts_with(arg, \"--show-notes=\") &&\n-\t\t    revs->notes_opt.use_default_notes < 0)\n+\t\t    (revs->notes_opt.use_default_notes < 0 ||\n+\t\t     revs->notes_opt.default_notes_suppressed_by_external)) {\n \t\t\trevs->notes_opt.use_default_notes = 1;\n+\t\t\trevs->notes_opt.default_notes_suppressed_by_external = 0;\n+\t\t}\n \t\tenable_ref_display_notes(&revs->notes_opt, &revs->show_notes, optarg);\n \t\trevs->show_notes_given = 1;\n \t} else if (!strcmp(arg, \"--no-notes\")) {\n \t\tdisable_display_notes(&revs->notes_opt, &revs->show_notes);\n \t\trevs->show_notes_given = 1;\n+\t} else if (!strcmp(arg, \"--external-notes\")) {\n+\t\trevs->notes_opt.use_external_notes = 1;\n+\t\trevs->show_notes = 1;\n+\t\trevs->show_notes_given = 1;\n+\t\t/*\n+\t\t * `--external-notes` names a note source on its own. If the\n+\t\t * default notes ref is still undecided, settle it to \"off\" so\n+\t\t * this option does not also trigger the \"no explicit notes\n+\t\t * refs\" fallback. A later use of `--notes` or the deprecated\n+\t\t * `--show-notes=<ref>` can still turn the default ref on.\n+\t\t */\n+\t\tif (revs->notes_opt.use_default_notes < 0) {\n+\t\t\trevs->notes_opt.use_default_notes = 0;\n+\t\t\trevs->notes_opt.default_notes_suppressed_by_external = 1;\n+\t\t}\n+\t} else if (!strcmp(arg, \"--no-external-notes\")) {\n+\t\trevs->notes_opt.use_external_notes = 0;\n \t} else if (!strcmp(arg, \"--standard-notes\")) {\n \t\trevs->show_notes_given = 1;\n \t\trevs->notes_opt.use_default_notes = 1;\n+\t\trevs->notes_opt.default_notes_suppressed_by_external = 0;\n \t} else if (!strcmp(arg, \"--no-standard-notes\")) {\n \t\trevs->notes_opt.use_default_notes = 0;\n+\t\trevs->notes_opt.default_notes_suppressed_by_external = 0;\n \t} else if (!strcmp(arg, \"--oneline\")) {\n \t\trevs->verbose_header = 1;\n \t\tget_commit_format(\"oneline\", revs);\n@@ -4105,9 +4128,14 @@ static int commit_match(struct commit *commit, struct rev_info *opt)\n \n \t/* Append \"fake\" message parts as needed */\n \tif (opt->show_notes) {\n+\t\tconst struct display_notes_opt *notes_opt = &opt->notes_opt;\n+\n \t\tif (!buf.len)\n \t\t\tstrbuf_addstr(&buf, message);\n-\t\tformat_display_notes(&commit->object.oid, &buf, encoding, true);\n+\n+\t\tformat_display_notes(commit, &buf, encoding, true,\n+\t\t\t\t     (display_notes_use_external(notes_opt)\n+\t\t\t\t      && external_notes_for_grep_enabled()));\n \t}\n \n \t/*\n-- \n2.53.0\n\n"},{"id":"543696","messageId":"9f83b482a38bf76e29a4d12a0cfe80ae7bfc4bb8.1779207350.git.siddh.raman.pant@oracle.com","threadId":"65662","inReplyTo":"cover.1779207350.git.siddh.raman.pant@oracle.com","subject":"[PATCH 2/9] notes: convert raw arg in format_display_notes() to bool","fromName":"Siddh Raman Pant","fromEmail":"siddh.raman.pant@oracle.com","sentAt":"2026-05-19T16:30:31Z","receivedAt":"2026-05-19T16:31:33Z","isPatch":true,"body":"It's used as a boolean flag, let's not use an int.\n\nSigned-off-by: Siddh Raman Pant <siddh.raman.pant@oracle.com>\n---\n log-tree.c | 3 +--\n notes.c    | 6 +++---\n notes.h    | 2 +-\n revision.c | 2 +-\n 4 files changed, 6 insertions(+), 7 deletions(-)\n\ndiff --git a/log-tree.c b/log-tree.c\nindex 7e048701d0c5..4503a42dde6b 100644\n--- a/log-tree.c\n+++ b/log-tree.c\n@@ -854,10 +854,9 @@ void show_log(struct rev_info *opt)\n \t}\n \n \tif (opt->show_notes) {\n-\t\tint raw;\n \t\tstruct strbuf notebuf = STRBUF_INIT;\n+\t\tbool raw = (opt->commit_format == CMIT_FMT_USERFORMAT);\n \n-\t\traw = (opt->commit_format == CMIT_FMT_USERFORMAT);\n \t\tformat_display_notes(&commit->object.oid, &notebuf,\n \t\t\t\t     get_log_output_encoding(), raw);\n \t\tctx.notes_message = strbuf_detach(&notebuf, NULL);\ndiff --git a/notes.c b/notes.c\nindex 8f315e2a00d2..201f1df3dc29 100644\n--- a/notes.c\n+++ b/notes.c\n@@ -1273,11 +1273,11 @@ void free_notes(struct notes_tree *t)\n  * If the given notes_tree is NULL, the internal/default notes_tree will be\n  * used instead.\n  *\n- * (raw != 0) gives the %N userformat; otherwise, the note message is given\n+ * (raw == true) gives the %N userformat; otherwise, the note message is given\n  * for human consumption.\n  */\n static void format_note(struct notes_tree *t, const struct object_id *object_oid,\n-\t\t\tstruct strbuf *sb, const char *output_encoding, int raw)\n+\t\t\tstruct strbuf *sb, const char *output_encoding, bool raw)\n {\n \tstatic const char utf8[] = \"utf-8\";\n \tconst struct object_id *oid;\n@@ -1338,7 +1338,7 @@ static void format_note(struct notes_tree *t, const struct object_id *object_oid\n }\n \n void format_display_notes(const struct object_id *object_oid,\n-\t\t\t  struct strbuf *sb, const char *output_encoding, int raw)\n+\t\t\t  struct strbuf *sb, const char *output_encoding, bool raw)\n {\n \tint i;\n \tassert(display_notes_trees);\ndiff --git a/notes.h b/notes.h\nindex 6dc6d7b26548..f6410b31e1c9 100644\n--- a/notes.h\n+++ b/notes.h\n@@ -313,7 +313,7 @@ void load_display_notes(struct display_notes_opt *opt);\n  * You *must* call load_display_notes() before using this function.\n  */\n void format_display_notes(const struct object_id *object_oid,\n-\t\t\t  struct strbuf *sb, const char *output_encoding, int raw);\n+\t\t\t  struct strbuf *sb, const char *output_encoding, bool raw);\n \n /*\n  * Load the notes tree from each ref listed in 'refs'.  The output is\ndiff --git a/revision.c b/revision.c\nindex 599b3a66c369..cd9fcefa0a88 100644\n--- a/revision.c\n+++ b/revision.c\n@@ -4107,7 +4107,7 @@ static int commit_match(struct commit *commit, struct rev_info *opt)\n \tif (opt->show_notes) {\n \t\tif (!buf.len)\n \t\t\tstrbuf_addstr(&buf, message);\n-\t\tformat_display_notes(&commit->object.oid, &buf, encoding, 1);\n+\t\tformat_display_notes(&commit->object.oid, &buf, encoding, true);\n \t}\n \n \t/*\n-- \n2.53.0\n\n"},{"id":"543697","messageId":"6a8c2093643a385641ef0b2cde33839dc98d8678.1779207350.git.siddh.raman.pant@oracle.com","threadId":"65662","inReplyTo":"cover.1779207350.git.siddh.raman.pant@oracle.com","subject":"[PATCH 3/9] wrapper: add sleep_nanosec","fromName":"Siddh Raman Pant","fromEmail":"siddh.raman.pant@oracle.com","sentAt":"2026-05-19T16:30:32Z","receivedAt":"2026-05-19T16:31:35Z","isPatch":true,"body":"Signed-off-by: Siddh Raman Pant <siddh.raman.pant@oracle.com>\n---\n wrapper.c | 49 +++++++++++++++++++++++++++++++++++++++++++++++++\n wrapper.h |  1 +\n 2 files changed, 50 insertions(+)\n\ndiff --git a/wrapper.c b/wrapper.c\nindex 16f5a63fbb61..1349255f1eb4 100644\n--- a/wrapper.c\n+++ b/wrapper.c\n@@ -10,6 +10,7 @@\n #include \"gettext.h\"\n #include \"strbuf.h\"\n #include \"trace2.h\"\n+#include <time.h>\n \n #ifdef HAVE_RTLGENRANDOM\n /* This is required to get access to RtlGenRandom. */\n@@ -708,6 +709,54 @@ void sleep_millisec(int millisec)\n \tpoll(NULL, 0, millisec);\n }\n \n+#ifdef GIT_WINDOWS_NATIVE\n+/* No nanosleep() on Windows, so fall-back to using sleep_millisec(). */\n+int sleep_nanosec(uint64_t nanosec)\n+{\n+\tuint64_t ns_in_1ms = 1000000ULL;\t/* 1 ms = 10^6 ns */\n+\n+\tuint64_t millisec = nanosec / ns_in_1ms;\n+\tif (nanosec % ns_in_1ms)\n+\t\tmillisec++;\n+\n+\t/* Chunked sleep if we can't represent in integer. */\n+\twhile (millisec > INT_MAX) {\n+\t\tsleep_millisec(INT_MAX);\n+\t\tmillisec -= INT_MAX;\n+\t}\n+\n+\tsleep_millisec((int)millisec);\n+\n+\treturn 0;\n+}\n+#else\n+/* Not Windows, so use the more exact nanosleep(). */\n+int sleep_nanosec(uint64_t nanosec)\n+{\n+\tint ret;\n+\tstruct timespec duration, remaining;\n+\n+\t/* Construct the duration by dividing the given total (1s = 10^9ns). */\n+\tduration.tv_sec = nanosec / 1000000000ULL;\n+\tduration.tv_nsec = nanosec % 1000000000ULL;\n+\n+\twhile(1) {\n+\t\tret = nanosleep(&duration, &remaining);\n+\n+\t\t/* Continue sleeping if interrupted. */\n+\t\tif (ret == -1 && errno == EINTR) {\n+\t\t\tduration = remaining;\n+\t\t\tcontinue;\n+\t\t}\n+\n+\t\t/* Either success or an error. */\n+\t\tbreak;\n+\t}\n+\n+\treturn ret;\n+}\n+#endif  /* GIT_WINDOWS_NATIVE */\n+\n int xgethostname(char *buf, size_t len)\n {\n \t/*\ndiff --git a/wrapper.h b/wrapper.h\nindex 15ac3bab6e97..c39992893a81 100644\n--- a/wrapper.h\n+++ b/wrapper.h\n@@ -130,6 +130,7 @@ int warn_on_fopen_errors(const char *path);\n int open_nofollow(const char *path, int flags);\n \n void sleep_millisec(int millisec);\n+int sleep_nanosec(uint64_t nanosec);\n \n enum {\n \t/*\n-- \n2.53.0\n\n"},{"id":"543698","messageId":"12aa52077b4111892d2c966a2bff205d5b4ad170.1779207350.git.siddh.raman.pant@oracle.com","threadId":"65662","inReplyTo":"cover.1779207350.git.siddh.raman.pant@oracle.com","subject":"[PATCH 8/9] Documentation: document external notes command options","fromName":"Siddh Raman Pant","fromEmail":"siddh.raman.pant@oracle.com","sentAt":"2026-05-19T16:30:37Z","receivedAt":"2026-05-19T16:31:35Z","isPatch":true,"body":"Assisted-by: Codex:gpt-5.5-xhigh-fast\nSigned-off-by: Siddh Raman Pant <siddh.raman.pant@oracle.com>\n---\n Documentation/config/notes.adoc        | 57 ++++++++++++++++++++++++++\n Documentation/git-format-patch.adoc    | 11 ++++-\n Documentation/git-range-diff.adoc      |  6 +++\n Documentation/pretty-options.adoc      |  9 ++++\n contrib/completion/git-completion.bash |  4 +-\n 5 files changed, 84 insertions(+), 3 deletions(-)\n\ndiff --git a/Documentation/config/notes.adoc b/Documentation/config/notes.adoc\nindex b7e536496f51..b3ef3fa52950 100644\n--- a/Documentation/config/notes.adoc\n+++ b/Documentation/config/notes.adoc\n@@ -34,6 +34,63 @@ The effective value of `core.notesRef` (possibly overridden by\n `GIT_NOTES_REF`) is also implicitly added to the list of refs to be\n displayed.\n \n+`notes.externalCommand`::\n+\tCommand to invoke as a long-lived helper when showing commit messages\n+\twith the `git log` family of commands. Git sends one commit object ID\n+\tper request on the command's standard input:\n++\n+------------\n+<hex-commit-id>\n+------------\n++\n+For each request, the command must respond on its standard output with either\n+`<hex-commit-id> missing` followed by a newline, or `<hex-commit-id> ok <n>`\n+followed by a newline and exactly `<n>` bytes of UTF-8 note text followed by a\n+newline. The command must respond to each request as it is received; Git does\n+not send all commit object IDs before reading responses. Empty note text is not\n+displayed. If Git cannot start or communicate with the command, or the command\n+sends an invalid response, Git warns once and disables it for the rest of the\n+command. External notes are only used while formatting output by default; see\n+`notes.externalCommandForGrep` to include them when matching commits.\n++\n+This setting is only respected in protected configuration (see\n+linkgit:git-config[1]). This prevents untrusted repositories from running\n+arbitrary commands when notes are displayed.\n++\n+This setting does not take effect when:\n++\n+--\n+* the value is empty;\n+* `--no-notes` is given;\n+* `--no-external-notes` is given; or\n+* `--notes=<ref>` is given by itself without `--external-notes` or `--notes`.\n+--\n+\n+`notes.externalCommandName`::\n+\tName to use in the `Notes (<name>):` header for notes returned by\n+\t`notes.externalCommand`. Defaults to `external`. This setting is only\n+\trespected in protected configuration.\n+\n+`notes.externalCommandTimeoutMs`::\n+\tNumber of milliseconds to wait when reading each response from\n+\t`notes.externalCommand`. Defaults to `100`. If the command does not\n+\tproduce the expected response in time, Git warns once and disables it\n+\tfor the rest of the command. A value of `0` disables timeout handling,\n+\tso reads can block until the command writes output or exits. This\n+\tsetting is only\trespected in protected configuration.\n+\n+`notes.externalCommandForGrep`::\n+\tBoolean indicating whether notes returned by `notes.externalCommand`\n+\tare included when matching commits with `--grep`, wherever notes would\n+\tnormally participate in grep matching. Defaults to false. This does\n+\tnot make hidden notes searchable in formats such as `--oneline` or\n+\t`--pretty=%s`; use `--notes` or `--external-notes` if those formats\n+\tshould search notes too. When enabled, revision traversal may invoke\n+\tthe external command for many commits that are not ultimately\n+\tdisplayed, which can be expensive for slow commands. The note output\n+\tcan also change which commits match. This setting is only respected in\n+\tprotected configuration.\n+\n `notes.rewrite.<command>`::\n \tWhen rewriting commits with _<command>_ (currently `amend` or\n \t`rebase`), if this variable is `false`, git will not copy\ndiff --git a/Documentation/git-format-patch.adoc b/Documentation/git-format-patch.adoc\nindex 566238245028..472b37e5237a 100644\n--- a/Documentation/git-format-patch.adoc\n+++ b/Documentation/git-format-patch.adoc\n@@ -26,7 +26,7 @@ SYNOPSIS\n \t\t   [--[no-]cover-letter] [--quiet]\n \t\t   [--commit-list-format=<format-spec>]\n \t\t   [--[no-]encode-email-headers]\n-\t\t   [--no-notes | --notes[=<ref>]]\n+\t\t   [--no-notes | --notes[=<ref>]] [--[no-]external-notes]\n \t\t   [--interdiff=<previous>]\n \t\t   [--range-diff=<previous> [--creation-factor=<percent>]]\n \t\t   [--filename-max-length=<n>]\n@@ -395,6 +395,15 @@ configuration options in linkgit:git-notes[1] to use this workflow).\n The default is `--no-notes`, unless the `format.notes` configuration is\n set.\n \n+--external-notes::\n+--no-external-notes::\n+\tInvoke or do not invoke `notes.externalCommand` to obtain external\n+\tnotes. Like `--notes=<ref>`, `--external-notes` names an explicit\n+\tnote source and by itself does not include the default notes refs.\n+\tUse `--external-notes --notes` to include the default notes refs\n+\ttoo, or combine `--external-notes` with `--notes=<ref>` to include\n+\texternal notes with specific notes refs.\n+\n --signature=<signature>::\n --no-signature::\n \tAdd a signature to each message produced. Per RFC 3676 the signature\ndiff --git a/Documentation/git-range-diff.adoc b/Documentation/git-range-diff.adoc\nindex 5cc5e2ed5673..1de23f300517 100644\n--- a/Documentation/git-range-diff.adoc\n+++ b/Documentation/git-range-diff.adoc\n@@ -12,6 +12,7 @@ git range-diff [--color=[<when>]] [--no-color] [<diff-options>]\n \t[--no-dual-color] [--creation-factor=<factor>]\n \t[--left-only | --right-only] [--diff-merges=<format>]\n \t[--remerge-diff] [--no-notes | --notes[=<ref>]]\n+\t[--[no-]external-notes]\n \t( <range1> <range2> | <rev1>...<rev2> | <base> <rev1> <rev2> )\n \t[[--] <path>...]\n \n@@ -101,6 +102,11 @@ diff.\n \tThis flag is passed to the `git log` program\n \t(see linkgit:git-log[1]) that generates the patches.\n \n+`--external-notes`::\n+`--no-external-notes`::\n+\tThis flag is passed to the `git log` program\n+\t(see linkgit:git-log[1]) that generates the patches.\n+\n `<range1> <range2>`::\n \tCompare the commits specified by the two ranges, where\n \t_<range1>_ is considered an older version of _<range2>_.\ndiff --git a/Documentation/pretty-options.adoc b/Documentation/pretty-options.adoc\nindex 658e462b2533..aad851c92cfd 100644\n--- a/Documentation/pretty-options.adoc\n+++ b/Documentation/pretty-options.adoc\n@@ -93,6 +93,15 @@ being displayed. Examples: \"`--notes=foo`\" will show only notes from\n \t\"`--notes --notes=foo --no-notes --notes=bar`\" will only show notes\n \tfrom `refs/notes/bar`.\n \n+`--external-notes`::\n+`--no-external-notes`::\n+\tInvoke or do not invoke `notes.externalCommand` to obtain external\n+\tnotes. Like `--notes=<ref>`, `--external-notes` names an explicit\n+\tnote source and by itself does not include the default notes refs.\n+\tUse `--external-notes --notes` to include the default notes refs\n+\ttoo, or combine `--external-notes` with `--notes=<ref>` to include\n+\texternal notes with specific notes refs.\n+\n `--show-notes-by-default`::\n \tShow the default notes unless options for displaying specific\n \tnotes are given.\ndiff --git a/contrib/completion/git-completion.bash b/contrib/completion/git-completion.bash\nindex a8e7c6ddbfb2..146444e65860 100644\n--- a/contrib/completion/git-completion.bash\n+++ b/contrib/completion/git-completion.bash\n@@ -2023,7 +2023,7 @@ _git_fetch ()\n \n __git_format_patch_extra_options=\"\n \t--full-index --not --all --no-prefix --src-prefix=\n-\t--dst-prefix= --notes\n+\t--dst-prefix= --notes --external-notes --no-external-notes\n \"\n \n _git_format_patch ()\n@@ -2215,7 +2215,7 @@ __git_log_common_options=\"\n __git_log_gitk_options=\"\n \t--dense --sparse --full-history\n \t--simplify-merges --simplify-by-decoration\n-\t--left-right --notes --no-notes\n+\t--left-right --notes --no-notes --external-notes --no-external-notes\n \"\n # Options that go well for log and shortlog (not gitk)\n __git_log_shortlog_options=\"\n-- \n2.53.0\n\n"},{"id":"543699","messageId":"3a470a117d5e0a7e130eff0d203ed7e3700b5d61.1779207350.git.siddh.raman.pant@oracle.com","threadId":"65662","inReplyTo":"cover.1779207350.git.siddh.raman.pant@oracle.com","subject":"[PATCH 9/9] t: add tests for external notes command","fromName":"Siddh Raman Pant","fromEmail":"siddh.raman.pant@oracle.com","sentAt":"2026-05-19T16:30:38Z","receivedAt":"2026-05-19T16:31:40Z","isPatch":true,"body":"Assisted-by: Codex:gpt-5.5-xhigh-fast\nSigned-off-by: Siddh Raman Pant <siddh.raman.pant@oracle.com>\n---\n t/helper/meson.build                        |   1 +\n t/helper/test-external-notes                |  64 +++\n t/helper/test-notes-external-config-reset.c |  20 +\n t/helper/test-tool.c                        |   1 +\n t/helper/test-tool.h                        |   1 +\n t/lib-notes.sh                              |  19 +\n t/t3206-range-diff.sh                       |  68 +++\n t/t3301-notes.sh                            | 437 ++++++++++++++++++++\n t/t6120-describe.sh                         |  17 +\n 9 files changed, 628 insertions(+)\n create mode 100755 t/helper/test-external-notes\n create mode 100644 t/helper/test-notes-external-config-reset.c\n create mode 100644 t/lib-notes.sh\n\ndiff --git a/t/helper/meson.build b/t/helper/meson.build\nindex 675e64c0101b..739614b90e78 100644\n--- a/t/helper/meson.build\n+++ b/t/helper/meson.build\n@@ -35,6 +35,7 @@ test_tool_sources = [\n   'test-mergesort.c',\n   'test-mktemp.c',\n   'test-name-hash.c',\n+  'test-notes-external-config-reset.c',\n   'test-online-cpus.c',\n   'test-pack-deltas.c',\n   'test-pack-mtimes.c',\ndiff --git a/t/helper/test-external-notes b/t/helper/test-external-notes\nnew file mode 100755\nindex 000000000000..5e9dde3977ab\n--- /dev/null\n+++ b/t/helper/test-external-notes\n@@ -0,0 +1,64 @@\n+#!/bin/sh\n+\n+prefix=${TEST_EXTERNAL_NOTES_PREFIX:-external-notes}\n+response=${TEST_EXTERNAL_NOTES_RESPONSE:-ok}\n+line_ending=${TEST_EXTERNAL_NOTES_LINE_ENDING:-lf}\n+exit_after_response=${TEST_EXTERNAL_NOTES_EXIT_AFTER_RESPONSE:-}\n+exit_delay=${TEST_EXTERNAL_NOTES_EXIT_DELAY:-}\n+delay=${TEST_EXTERNAL_NOTES_DELAY:-}\n+char_delay=${TEST_EXTERNAL_NOTES_CHAR_DELAY:-}\n+ignore_term=${TEST_EXTERNAL_NOTES_IGNORE_TERM:-}\n+\n+newline='\\n'\n+case \"$line_ending\" in\n+crlf)\n+\tnewline='\\r\\n'\n+\t;;\n+none)\n+\tnewline=\n+\t;;\n+esac\n+\n+echo start >>\"$prefix-starts\"\n+\n+test \"$ignore_term\" = true && trap '' TERM\n+\n+emit_output() {\n+\tif test -n \"$char_delay\"\n+\tthen\n+\t\tLC_ALL=C\n+\t\tpayload=$(printf \"$@\"; printf .)\n+\t\tpayload=${payload%.}\n+\n+\t\twhile test -n \"$payload\"\n+\t\tdo\n+\t\t\tchar=${payload%\"${payload#?}\"}\n+\t\t\tprintf '%s' \"$char\" || return 1\n+\t\t\tpayload=${payload#?}\n+\t\t\tsleep \"$char_delay\" || return 1\n+\t\tdone\n+\telse\n+\t\tprintf \"$@\"\n+\tfi\n+}\n+\n+while IFS= read -r commit; do\n+\tif test \"${TEST_EXTERNAL_NOTES_BODY+x}\" = x\n+\tthen\n+\t\tnote=$TEST_EXTERNAL_NOTES_BODY\n+\telse\n+\t\tnote=$commit\n+\tfi\n+\tprintf \"%s\\n\" \"$commit\" >>\"$prefix-requests\"\n+\ttest -z \"$delay\" || sleep \"$delay\"\n+\tif test \"$response\" = missing\n+\tthen\n+\t\temit_output \"%s missing%b\" \"$commit\" \"$newline\"\n+\telse\n+\t\temit_output \"%s ok %d%b%s%b\" \\\n+\t\t\t\"$commit\" \"${#note}\" \"$newline\" \"$note\" \"$newline\"\n+\tfi\n+\ttest \"$exit_after_response\" = true && break\n+done\n+\n+test -z \"$exit_delay\" || sleep \"$exit_delay\"\ndiff --git a/t/helper/test-notes-external-config-reset.c b/t/helper/test-notes-external-config-reset.c\nnew file mode 100644\nindex 000000000000..1c6b26e3b49a\n--- /dev/null\n+++ b/t/helper/test-notes-external-config-reset.c\n@@ -0,0 +1,20 @@\n+#include \"test-tool.h\"\n+#include \"notes-external.h\"\n+\n+int cmd__notes_external_config_reset(int argc, const char **argv UNUSED)\n+{\n+\tif (argc != 1)\n+\t\tdie(\"usage: test-tool notes-external-config-reset\");\n+\n+\tset_external_notes_command(\"helper\");\n+\tset_external_notes_command_name(\"label\");\n+\tset_external_notes_command_timeout_ms(250);\n+\tset_external_notes_for_grep(1);\n+\treset_external_notes_command();\n+\n+\tprintf(\"configured=%d\\n\", external_notes_command_configured());\n+\tprintf(\"name=%s\\n\", external_notes_command_name());\n+\tprintf(\"timeout_ms=%d\\n\", external_notes_command_timeout_ms());\n+\tprintf(\"grep=%d\\n\", external_notes_for_grep_enabled());\n+\treturn 0;\n+}\ndiff --git a/t/helper/test-tool.c b/t/helper/test-tool.c\nindex a7abc618b388..31bb2d1dca47 100644\n--- a/t/helper/test-tool.c\n+++ b/t/helper/test-tool.c\n@@ -45,6 +45,7 @@ static struct test_cmd cmds[] = {\n \t{ \"mergesort\", cmd__mergesort },\n \t{ \"mktemp\", cmd__mktemp },\n \t{ \"name-hash\", cmd__name_hash },\n+\t{ \"notes-external-config-reset\", cmd__notes_external_config_reset },\n \t{ \"online-cpus\", cmd__online_cpus },\n \t{ \"pack-deltas\", cmd__pack_deltas },\n \t{ \"pack-mtimes\", cmd__pack_mtimes },\ndiff --git a/t/helper/test-tool.h b/t/helper/test-tool.h\nindex 7f150fa1eb9a..ff25f0a29cf2 100644\n--- a/t/helper/test-tool.h\n+++ b/t/helper/test-tool.h\n@@ -38,6 +38,7 @@ int cmd__match_trees(int argc, const char **argv);\n int cmd__mergesort(int argc, const char **argv);\n int cmd__mktemp(int argc, const char **argv);\n int cmd__name_hash(int argc, const char **argv);\n+int cmd__notes_external_config_reset(int argc, const char **argv);\n int cmd__online_cpus(int argc, const char **argv);\n int cmd__pack_deltas(int argc, const char **argv);\n int cmd__pack_mtimes(int argc, const char **argv);\ndiff --git a/t/lib-notes.sh b/t/lib-notes.sh\nnew file mode 100644\nindex 000000000000..07422540d58f\n--- /dev/null\n+++ b/t/lib-notes.sh\n@@ -0,0 +1,19 @@\n+# Helpers for scripts testing notes behavior.\n+\n+# notes.externalCommand is run through a shell, so quote the path.\n+external_notes_command=$(\n+\tprintf \"%s\\n\" \"$TEST_DIRECTORY/helper/test-external-notes\" |\n+\tsed \"s/'/'\\\\\\\\''/g; s/^/'/; s/$/'/\"\n+)\n+\n+# The helper above is a shell script. Few Windows CI tests (3 out of 10\n+# in matrix) are spending more than the production default timeout just\n+# starting the shell and exchanging the first response, so tests that\n+# are not about timeout behavior fail. So let us opt into a wider 1s\n+# deadline for Windows instead of 100ms.\n+external_notes_command_timeout_config=\n+if test_have_prereq MINGW\n+then\n+\t_timeout_config=\"notes.externalCommandTimeoutMs=1000\"\n+\texternal_notes_command_timeout_config=\"-c $_timeout_config\"\n+fi\ndiff --git a/t/t3206-range-diff.sh b/t/t3206-range-diff.sh\nindex 1e812df806bb..96adeb9bc4fe 100755\n--- a/t/t3206-range-diff.sh\n+++ b/t/t3206-range-diff.sh\n@@ -6,6 +6,7 @@ GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n \n . ./test-lib.sh\n+. \"$TEST_DIRECTORY\"/lib-notes.sh\n \n # Note that because of the range-diff's heuristics, test_commit does more\n # harm than good.  We need some real history.\n@@ -690,6 +691,37 @@ test_expect_success 'range-diff with --notes=custom does not show default notes'\n \tgrep \"## Notes (custom) ##\" actual\n '\n \n+test_expect_success 'range-diff with --external-notes' '\n+\ttopic_oid=$(git rev-parse topic) &&\n+\tunmodified_oid=$(git rev-parse unmodified) &&\n+\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t$external_notes_command_timeout_config \\\n+\t\trange-diff --no-color --external-notes \\\n+\t\tmain..topic main..unmodified >actual &&\n+\ttest_grep \"## Notes (external) ##\" actual &&\n+\ttest_grep \"^    -    $topic_oid$\" actual &&\n+\ttest_grep \"^    +    $unmodified_oid$\" actual &&\n+\t! grep \"## Notes ##\" actual\n+'\n+\n+test_expect_success 'range-diff with disabled external notes' '\n+\ttest_when_finished \"git notes remove topic unmodified || :\" &&\n+\tgit notes add -m \"topic note\" topic &&\n+\tgit notes add -m \"unmodified note\" unmodified &&\n+\tTEST_EXTERNAL_NOTES_PREFIX=range-diff-external-notes \\\n+\t\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\trange-diff --no-color --external-notes --no-external-notes \\\n+\t\tmain..topic main..unmodified >actual &&\n+\tcat >expect <<-EOF &&\n+\t1:  $(test_oid t1) = 1:  $(test_oid u1) s/5/A/\n+\t2:  $(test_oid t2) = 2:  $(test_oid u2) s/4/A/\n+\t3:  $(test_oid t3) = 3:  $(test_oid u3) s/11/B/\n+\t4:  $(test_oid t4) = 4:  $(test_oid u4) s/12/B/\n+\tEOF\n+\ttest_cmp expect actual &&\n+\ttest_path_is_missing range-diff-external-notes-starts\n+'\n+\n test_expect_success 'format-patch --range-diff does not compare notes by default' '\n \ttest_when_finished \"git notes remove topic unmodified || :\" &&\n \tgit notes add -m \"topic note\" topic &&\n@@ -780,6 +812,42 @@ test_expect_success 'format-patch --range-diff with --notes' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success 'format-patch --range-diff with --external-notes' '\n+\ttopic_oid=$(git rev-parse topic) &&\n+\tunmodified_oid=$(git rev-parse unmodified) &&\n+\ttest_when_finished \"rm -f 000?-*\" &&\n+\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t$external_notes_command_timeout_config \\\n+\t\tformat-patch --external-notes --cover-letter --range-diff=$prev \\\n+\t\tmain..unmodified >actual &&\n+\ttest_line_count = 5 actual &&\n+\ttest_grep \"^Range-diff:$\" 0000-* &&\n+\ttest_grep \"## Notes (external) ##\" 0000-* &&\n+\ttest_grep \"^    -    $topic_oid$\" 0000-* &&\n+\ttest_grep \"^    +    $unmodified_oid$\" 0000-* &&\n+\t! grep \"## Notes ##\" 0000-*\n+'\n+\n+test_expect_success 'format-patch --range-diff with disabled external notes' '\n+\ttest_when_finished \"git notes remove topic unmodified || :\" &&\n+\tgit notes add -m \"topic note\" topic &&\n+\tgit notes add -m \"unmodified note\" unmodified &&\n+\ttest_when_finished \"rm -f 000?-*\" &&\n+\tTEST_EXTERNAL_NOTES_PREFIX=range-diff-external-notes \\\n+\t\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\tformat-patch --external-notes --no-external-notes \\\n+\t\t--cover-letter --range-diff=$prev main..unmodified >actual &&\n+\ttest_line_count = 5 actual &&\n+\ttest_grep \"^Range-diff:$\" 0000-* &&\n+\tgrep \"= 1: .* s/5/A\" 0000-* &&\n+\tgrep \"= 2: .* s/4/A\" 0000-* &&\n+\tgrep \"= 3: .* s/11/B\" 0000-* &&\n+\tgrep \"= 4: .* s/12/B\" 0000-* &&\n+\t! grep \"Notes\" 0000-* &&\n+\t! grep \"note\" 0000-* &&\n+\ttest_path_is_missing range-diff-external-notes-starts\n+'\n+\n test_expect_success 'format-patch --range-diff with format.notes config' '\n \ttest_when_finished \"git notes remove topic unmodified || :\" &&\n \tgit notes add -m \"topic note\" topic &&\ndiff --git a/t/t3301-notes.sh b/t/t3301-notes.sh\nindex 27439010dfbc..5a162dff3917 100755\n--- a/t/t3301-notes.sh\n+++ b/t/t3301-notes.sh\n@@ -6,6 +6,7 @@\n test_description='Test commit notes'\n \n . ./test-lib.sh\n+. \"$TEST_DIRECTORY\"/lib-notes.sh\n \n write_script fake_editor <<\\EOF\n echo \"$MSG\" >\"$1\"\n@@ -16,6 +17,11 @@ export GIT_EDITOR\n \n indent=\"    \"\n \n+run_with_limited_time () (\n+\t{ set +x; } 2>/dev/null\n+\t\"$PERL_PATH\" -e 'alarm shift; exec @ARGV' -- \"$@\"\n+)\n+\n test_expect_success 'cannot annotate non-existing HEAD' '\n \ttest_must_fail env MSG=3 git notes add\n '\n@@ -909,6 +915,437 @@ test_expect_success 'displayed notes are used for grep matching' '\n \ttest_must_be_empty actual\n '\n \n+test_expect_success 'notes.externalCommand shows external notes from protected config' '\n+\tcommit=$(git rev-parse HEAD) &&\n+\tparent=$(git rev-parse HEAD^) &&\n+\trm -f external-notes-starts external-notes-requests &&\n+\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t$external_notes_command_timeout_config \\\n+\t\tlog -2 >actual &&\n+\ttest_line_count = 1 external-notes-starts &&\n+\t{\n+\t\tprintf \"%s\\n\" \"$commit\" &&\n+\t\tprintf \"%s\\n\" \"$parent\"\n+\t} >expect-requests &&\n+\ttest_cmp expect-requests external-notes-requests &&\n+\ttest_grep \"Notes (external):\" actual &&\n+\ttest_grep \"^    $commit$\" actual &&\n+\ttest_grep \"^    $parent$\" actual\n+'\n+\n+test_expect_success PERL,EXECKEEPSPID 'notes.externalCommand terminates helper during exit cleanup' '\n+\tcommit=$(git rev-parse HEAD) &&\n+\ttest_env TEST_EXTERNAL_NOTES_EXIT_DELAY=10 \\\n+\t\trun_with_limited_time 2 \\\n+\t\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t$external_notes_command_timeout_config \\\n+\t\tlog --external-notes -1 >actual &&\n+\ttest_grep \"^Notes (external):$\" actual &&\n+\ttest_grep \"^    $commit$\" actual\n+'\n+\n+test_expect_success 'notes.externalCommandName labels external notes' '\n+\tcommit=$(git rev-parse HEAD) &&\n+\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t$external_notes_command_timeout_config \\\n+\t\t-c notes.externalCommandName=commit-id log -1 >actual &&\n+\ttest_grep \"Notes (commit-id):\" actual &&\n+\ttest_grep \"^    $commit$\" actual\n+'\n+\n+test_expect_success 'notes.externalCommandName is rendered literally' '\n+\tcommit=$(git rev-parse HEAD) &&\n+\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t$external_notes_command_timeout_config \\\n+\t\t-c notes.externalCommandName=refs/notes/commits \\\n+\t\tlog --external-notes -1 >actual &&\n+\ttest_grep \"^Notes (refs/notes/commits):$\" actual &&\n+\t! grep \"^Notes:$\" actual &&\n+\ttest_grep \"^    $commit$\" actual\n+'\n+\n+test_expect_success 'notes.externalCommandTimeoutMs rejects negative values' '\n+\ttest_must_fail git -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t-c notes.externalCommandTimeoutMs=-1 log -1 2>err &&\n+\ttest_grep \"notes.externalCommandTimeoutMs must be non-negative\" err\n+'\n+\n+test_expect_success 'notes.externalCommandTimeoutMs times out delayed response' '\n+\tgit log -1 >expect &&\n+\ttest_env TEST_EXTERNAL_NOTES_DELAY=1 \\\n+\t\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t-c notes.externalCommandTimeoutMs=1 \\\n+\t\tlog -1 >actual 2>err &&\n+\ttest_cmp expect actual &&\n+\ttest_grep \"notes.externalCommand failed\" err &&\n+\ttest_line_count = 1 err\n+'\n+\n+test_expect_success 'notes.externalCommandTimeoutMs applies to whole response' '\n+\tgit log -1 >expect &&\n+\ttest_env TEST_EXTERNAL_NOTES_BODY=x \\\n+\t\t TEST_EXTERNAL_NOTES_CHAR_DELAY=0.02 \\\n+\t\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t-c notes.externalCommandTimeoutMs=50 \\\n+\t\tlog -1 >actual 2>err &&\n+\ttest_cmp expect actual &&\n+\ttest_grep \"notes.externalCommand failed\" err &&\n+\ttest_line_count = 1 err\n+'\n+\n+test_expect_success PERL,EXECKEEPSPID 'notes.externalCommandTimeoutMs terminates timed-out helper' '\n+\tgit log -1 >expect &&\n+\ttest_env TEST_EXTERNAL_NOTES_DELAY=10 \\\n+\t\trun_with_limited_time 2 \\\n+\t\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t-c notes.externalCommandTimeoutMs=1 \\\n+\t\tlog -1 >actual 2>err &&\n+\ttest_cmp expect actual &&\n+\ttest_grep \"notes.externalCommand failed\" err &&\n+\ttest_line_count = 1 err\n+'\n+\n+test_expect_success PERL,EXECKEEPSPID 'notes.externalCommandTimeoutMs force-kills timed-out helper' '\n+\tgit log -1 >expect &&\n+\ttest_env TEST_EXTERNAL_NOTES_DELAY=10 \\\n+\t\t TEST_EXTERNAL_NOTES_IGNORE_TERM=true \\\n+\t\trun_with_limited_time 2 \\\n+\t\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t-c notes.externalCommandTimeoutMs=1 \\\n+\t\tlog -1 >actual 2>err &&\n+\ttest_cmp expect actual &&\n+\ttest_grep \"notes.externalCommand failed\" err &&\n+\ttest_line_count = 1 err\n+'\n+\n+test_expect_success 'notes.externalCommandTimeoutMs=0 disables timeout' '\n+\tcommit=$(git rev-parse HEAD) &&\n+\ttest_env TEST_EXTERNAL_NOTES_DELAY=1 \\\n+\t\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t-c notes.externalCommandTimeoutMs=0 \\\n+\t\tlog --external-notes -1 >actual &&\n+\ttest_grep \"^Notes (external):$\" actual &&\n+\ttest_grep \"^    $commit$\" actual\n+'\n+\n+test_expect_success 'notes.externalCommand handles CRLF note bodies' '\n+\tbody=$(printf \"A\\r\\nB\") &&\n+\ttest_env TEST_EXTERNAL_NOTES_BODY=\"$body\" \\\n+\t\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t$external_notes_command_timeout_config \\\n+\t\tlog --external-notes -1 >actual &&\n+\ttest_grep \"^Notes (external):$\" actual &&\n+\ttest_grep \"^    B$\" actual\n+'\n+\n+test_expect_success 'notes.externalCommand accepts CRLF missing response' '\n+\tgit log -1 >expect &&\n+\ttest_env TEST_EXTERNAL_NOTES_RESPONSE=missing \\\n+\t\t TEST_EXTERNAL_NOTES_LINE_ENDING=crlf \\\n+\t\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t$external_notes_command_timeout_config \\\n+\t\tlog -1 >actual &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'notes.externalCommand rejects unterminated missing response' '\n+\tgit log -1 >expect &&\n+\ttest_env TEST_EXTERNAL_NOTES_RESPONSE=missing \\\n+\t\t TEST_EXTERNAL_NOTES_LINE_ENDING=none \\\n+\t\t TEST_EXTERNAL_NOTES_EXIT_AFTER_RESPONSE=true \\\n+\t\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\tlog -1 >actual 2>err &&\n+\ttest_cmp expect actual &&\n+\ttest_grep \"notes.externalCommand failed\" err &&\n+\ttest_line_count = 1 err\n+'\n+\n+test_expect_success PERL,EXECKEEPSPID 'notes.externalCommand rejects unterminated live response without deadlock' '\n+\tgit log -1 >expect &&\n+\ttest_env TEST_EXTERNAL_NOTES_RESPONSE=missing \\\n+\t\t TEST_EXTERNAL_NOTES_LINE_ENDING=none \\\n+\t\trun_with_limited_time 2 \\\n+\t\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\tlog -1 >actual 2>err &&\n+\ttest_cmp expect actual &&\n+\ttest_grep \"notes.externalCommand failed\" err &&\n+\ttest_line_count = 1 err\n+'\n+\n+test_expect_success 'notes.externalCommand accepts CRLF protocol lines' '\n+\tcommit=$(git rev-parse HEAD) &&\n+\ttest_env TEST_EXTERNAL_NOTES_LINE_ENDING=crlf \\\n+\t\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t$external_notes_command_timeout_config \\\n+\t\tlog --external-notes -1 >actual &&\n+\ttest_grep \"^Notes (external):$\" actual &&\n+\ttest_grep \"^    $commit$\" actual\n+'\n+\n+test_expect_success 'notes.externalCommand missing response shows no external notes' '\n+\twrite_script external-notes-missing <<-\\EOF &&\n+\twhile IFS= read -r commit\n+\tdo\n+\t\tprintf \"%s missing\\n\" \"$commit\"\n+\tdone\n+\tEOF\n+\tgit log -1 >expect &&\n+\tgit -c notes.externalCommand=./external-notes-missing log -1 >actual &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'notes.externalCommand empty note shows no external notes' '\n+\twrite_script external-notes-empty <<-\\EOF &&\n+\twhile IFS= read -r commit\n+\tdo\n+\t\tprintf \"%s ok 0\\n\\n\" \"$commit\"\n+\tdone\n+\tEOF\n+\tgit log -1 >expect &&\n+\tgit -c notes.externalCommand=./external-notes-empty log -1 >actual &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'notes.externalCommand rejects invalid note lengths' '\n+\twrite_script external-notes-invalid-length <<-\\EOF &&\n+\twhile IFS= read -r commit\n+\tdo\n+\t\tprintf \"%s ok %s\\n\" \"$commit\" \"$1\"\n+\tdone\n+\tEOF\n+\tgit log -2 >expect &&\n+\tfor bad_length in -1 +1 1x x\n+\tdo\n+\t\tgit -c notes.externalCommand=\"./external-notes-invalid-length $bad_length\" \\\n+\t\t\tlog -2 >actual 2>err &&\n+\t\ttest_cmp expect actual &&\n+\t\ttest_grep \"notes.externalCommand failed\" err &&\n+\t\ttest_line_count = 1 err || return 1\n+\tdone\n+'\n+\n+test_expect_success 'notes.externalCommand is suppressed by --no-notes' '\n+\trm -f external-notes-starts &&\n+\tgit -c notes.externalCommand=\"$external_notes_command\" log --no-notes -1 >actual &&\n+\ttest_path_is_missing external-notes-starts &&\n+\t! grep \"Notes (external):\" actual\n+'\n+\n+test_expect_success 'notes.externalCommand is suppressed by --no-external-notes' '\n+\trm -f external-notes-starts &&\n+\tgit log -1 >expect &&\n+\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\tlog --no-external-notes -1 >actual &&\n+\ttest_cmp expect actual &&\n+\ttest_path_is_missing external-notes-starts\n+'\n+\n+test_expect_success 'notes.externalCommand combines with explicit notes ref' '\n+\tcommit=$(git rev-parse HEAD) &&\n+\trm -f external-notes-starts &&\n+\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t$external_notes_command_timeout_config \\\n+\t\tlog --notes=other --external-notes -1 >actual &&\n+\ttest_line_count = 1 external-notes-starts &&\n+\ttest_grep \"Notes (other):\" actual &&\n+\ttest_grep \"^    other note$\" actual &&\n+\ttest_grep \"Notes (external):\" actual &&\n+\ttest_grep \"^    $commit$\" actual &&\n+\t! grep \"^    order test$\" actual\n+'\n+\n+test_expect_success '--show-notes=ref remains additive after --external-notes' '\n+\tcommit=$(git rev-parse HEAD) &&\n+\trm -f external-notes-starts &&\n+\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t$external_notes_command_timeout_config \\\n+\t\tlog --external-notes --show-notes=other -1 >actual &&\n+\ttest_line_count = 1 external-notes-starts &&\n+\ttest_grep \"^Notes:$\" actual &&\n+\ttest_grep \"^    order test$\" actual &&\n+\ttest_grep \"^Notes (other):$\" actual &&\n+\ttest_grep \"^    other note$\" actual &&\n+\ttest_grep \"^Notes (external):$\" actual &&\n+\ttest_grep \"^    $commit$\" actual\n+'\n+\n+test_expect_success 'notes.externalCommand can be enabled without default notes refs' '\n+\tcommit=$(git rev-parse HEAD) &&\n+\trm -f external-notes-starts &&\n+\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t$external_notes_command_timeout_config \\\n+\t\tlog --external-notes -1 >actual &&\n+\ttest_line_count = 1 external-notes-starts &&\n+\ttest_grep \"Notes (external):\" actual &&\n+\ttest_grep \"^    $commit$\" actual &&\n+\t! grep \"^    order test$\" actual &&\n+\t! grep \"^    other note$\" actual\n+'\n+\n+test_expect_success 'notes.externalCommand combines with default notes refs' '\n+\tcommit=$(git rev-parse HEAD) &&\n+\trm -f external-notes-starts &&\n+\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t$external_notes_command_timeout_config \\\n+\t\tlog --external-notes --notes -1 >actual &&\n+\ttest_line_count = 1 external-notes-starts &&\n+\ttest_grep \"Notes:\" actual &&\n+\ttest_grep \"^    order test$\" actual &&\n+\ttest_grep \"Notes (external):\" actual &&\n+\ttest_grep \"^    $commit$\" actual &&\n+\t! grep \"^    other note$\" actual\n+'\n+\n+test_expect_success 'notes.externalCommand obeys last --external-notes option' '\n+\tcommit=$(git rev-parse HEAD) &&\n+\trm -f external-notes-starts &&\n+\tgit log --no-notes -1 >expect &&\n+\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\tlog --external-notes --no-external-notes -1 >actual &&\n+\ttest_cmp expect actual &&\n+\ttest_path_is_missing external-notes-starts &&\n+\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t$external_notes_command_timeout_config \\\n+\t\tlog --notes=other --no-external-notes --external-notes -1 >actual &&\n+\ttest_line_count = 1 external-notes-starts &&\n+\ttest_grep \"Notes (other):\" actual &&\n+\ttest_grep \"^    other note$\" actual &&\n+\ttest_grep \"Notes (external):\" actual &&\n+\ttest_grep \"^    $commit$\" actual &&\n+\t! grep \"^    order test$\" actual\n+'\n+\n+test_expect_success 'notes.externalCommand honors raw notes formatting' '\n+\tcommit=$(git rev-parse HEAD) &&\n+\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t$external_notes_command_timeout_config \\\n+\t\tshow -s --format=%N >actual &&\n+\ttest_grep \"^$commit$\" actual &&\n+\t! grep \"Notes (external):\" actual\n+'\n+\n+test_expect_success 'format-patch --external-notes includes external notes only' '\n+\tcommit=$(git rev-parse HEAD) &&\n+\trm -f external-notes-starts &&\n+\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t$external_notes_command_timeout_config \\\n+\t\tformat-patch --external-notes -1 --stdout >actual &&\n+\ttest_line_count = 1 external-notes-starts &&\n+\ttest_grep \"^Notes (external):\" actual &&\n+\ttest_grep \"^    $commit$\" actual &&\n+\t! grep \"^    order test$\" actual\n+'\n+\n+test_expect_success 'notes.externalCommand is not used for grep matching' '\n+\tcommit=$(git rev-parse HEAD) &&\n+\trm -f external-notes-starts &&\n+\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\tlog --grep=\"$commit\" >actual &&\n+\ttest_must_be_empty actual &&\n+\ttest_path_is_missing external-notes-starts\n+'\n+\n+test_expect_success 'notes.externalCommandForGrep includes external notes in grep matching' '\n+\tcommit=$(git rev-parse HEAD) &&\n+\trm -f external-notes-starts &&\n+\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t$external_notes_command_timeout_config \\\n+\t\t-c notes.externalCommandForGrep=true \\\n+\t\tlog --grep=\"$commit\" -1 >actual &&\n+\ttest_line_count = 1 external-notes-starts &&\n+\ttest_grep \"Notes (external):\" actual\n+'\n+\n+test_expect_success 'notes.externalCommandForGrep does not search hidden notes' '\n+\tcommit=$(git rev-parse HEAD) &&\n+\trm -f external-notes-starts &&\n+\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t-c notes.externalCommandForGrep=true \\\n+\t\tlog --oneline --grep=\"$commit\" -1 >actual &&\n+\ttest_must_be_empty actual &&\n+\ttest_path_is_missing external-notes-starts\n+'\n+\n+test_expect_success 'notes.externalCommandForGrep honors --no-external-notes' '\n+\tcommit=$(git rev-parse HEAD) &&\n+\trm -f external-notes-starts &&\n+\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t-c notes.externalCommandForGrep=true \\\n+\t\tlog --no-external-notes --grep=\"$commit\" -1 >actual &&\n+\ttest_must_be_empty actual &&\n+\ttest_path_is_missing external-notes-starts\n+'\n+\n+test_expect_success 'notes.externalCommandForGrep combines with explicit notes ref' '\n+\tcommit=$(git rev-parse HEAD) &&\n+\trm -f external-notes-starts &&\n+\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t$external_notes_command_timeout_config \\\n+\t\t-c notes.externalCommandForGrep=true \\\n+\t\tlog --notes=other --external-notes --grep=\"$commit\" -1 >actual &&\n+\ttest_line_count = 1 external-notes-starts &&\n+\ttest_grep \"Notes (external):\" actual &&\n+\ttest_grep \"Notes (other):\" actual &&\n+\t! grep \"^    order test$\" actual\n+'\n+\n+test_expect_success 'notes.externalCommandForGrep is ignored from local config' '\n+\tcommit=$(git rev-parse HEAD) &&\n+\trm -f external-notes-starts &&\n+\ttest_config notes.externalCommandForGrep true &&\n+\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\tlog --grep=\"$commit\" >actual &&\n+\ttest_must_be_empty actual &&\n+\ttest_path_is_missing external-notes-starts\n+'\n+\n+test_expect_success 'notes.externalCommand is not used with explicit notes ref' '\n+\trm -f external-notes-starts &&\n+\tgit -c notes.externalCommand=\"$external_notes_command\" log --notes=other -1 >actual &&\n+\ttest_path_is_missing external-notes-starts &&\n+\t! grep \"Notes (external):\" actual\n+'\n+\n+test_expect_success 'notes.externalCommand is ignored from local config' '\n+\trm -f external-notes-starts &&\n+\ttest_config notes.externalCommand \"$external_notes_command\" &&\n+\tgit log -1 >actual &&\n+\ttest_path_is_missing external-notes-starts &&\n+\t! grep \"Notes (external):\" actual\n+'\n+\n+test_expect_success 'notes.externalCommandName is ignored from local config' '\n+\ttest_config notes.externalCommandName local &&\n+\tgit -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t$external_notes_command_timeout_config \\\n+\t\tlog -1 >actual &&\n+\ttest_grep \"Notes (external):\" actual &&\n+\t! grep \"Notes (local):\" actual\n+'\n+\n+test_expect_success 'reset_external_notes_command clears cached helper config' '\n+\ttest-tool notes-external-config-reset >actual &&\n+\tcat >expect <<-\\EOF &&\n+\tconfigured=0\n+\tname=external\n+\ttimeout_ms=100\n+\tgrep=0\n+\tEOF\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'notes.externalCommand warning is shown once' '\n+\twrite_script external-notes-fail <<-\\EOF &&\n+\twhile IFS= read -r commit\n+\tdo\n+\t\tprintf \"%s-mismatch missing\\n\" \"$commit\"\n+\tdone\n+\tEOF\n+\tgit -c notes.externalCommand=./external-notes-fail log -2 >actual 2>err &&\n+\ttest_grep \"notes.externalCommand failed\" err &&\n+\ttest_line_count = 1 err\n+'\n+\n test_expect_success 'Allow notes on non-commits (trees, blobs, tags)' '\n \ttest_config core.notesRef refs/notes/other &&\n \techo \"Note on a tree\" >expect &&\ndiff --git a/t/t6120-describe.sh b/t/t6120-describe.sh\nindex 8ee3d2c37d02..abbdb42dc9f7 100755\n--- a/t/t6120-describe.sh\n+++ b/t/t6120-describe.sh\n@@ -15,6 +15,7 @@ GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main\n export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n \n . ./test-lib.sh\n+. \"$TEST_DIRECTORY\"/lib-notes.sh\n \n check_describe () {\n \tindir= &&\n@@ -867,6 +868,22 @@ test_expect_success 'format-rev with %N (note)' '\n \ttest_cmp expect actual\n '\n \n+test_expect_success 'format-rev with %N uses external notes' '\n+\tcommit=$(git -C repo-format rev-parse HEAD) &&\n+\trm -f repo-format/format-rev-external-notes-starts \\\n+\t\trepo-format/format-rev-external-notes-requests &&\n+\tprintf \"%s\\n\" \"$commit\" >input &&\n+\tprintf \"%s\\n\\n\" \"$commit\" >expect &&\n+\tTEST_EXTERNAL_NOTES_PREFIX=format-rev-external-notes \\\n+\tgit -C repo-format -c notes.externalCommand=\"$external_notes_command\" \\\n+\t\t$external_notes_command_timeout_config \\\n+\t\tformat-rev --stdin-mode=text --format=\"tformat:%N\" \\\n+\t\t<input >actual &&\n+\ttest_line_count = 1 repo-format/format-rev-external-notes-starts &&\n+\ttest_cmp input repo-format/format-rev-external-notes-requests &&\n+\ttest_cmp expect actual\n+'\n+\n test_expect_success 'format-rev --notes<ref> (custom notes ref)' '\n \t# One custom notes ref\n \ttest_when_finished \"git -C repo-format notes remove\" &&\n-- \n2.53.0\n\n"},{"id":"543712","messageId":"87v7cjq7vc.fsf@gitster.g","threadId":"65662","inReplyTo":"290fe06d81e956253d3a06fc1e16848e0b86b603.1779207350.git.siddh.raman.pant@oracle.com","subject":"Re: [PATCH 1/9] Documentation/git-range-diff: add missing notes options in synopsis","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-19T23:47:51Z","receivedAt":"2026-05-19T23:47:58Z","isPatch":true,"body":"Siddh Raman Pant <siddh.raman.pant@oracle.com> writes:\n\n> Signed-off-by: Siddh Raman Pant <siddh.raman.pant@oracle.com>\n> ---\n>  Documentation/git-range-diff.adoc | 2 +-\n>  1 file changed, 1 insertion(+), 1 deletion(-)\n\nThis has nothing to do with \"external notes\" topic, no?\n\n>\n> diff --git a/Documentation/git-range-diff.adoc b/Documentation/git-range-diff.adoc\n> index 880557084533..5cc5e2ed5673 100644\n> --- a/Documentation/git-range-diff.adoc\n> +++ b/Documentation/git-range-diff.adoc\n> @@ -11,7 +11,7 @@ SYNOPSIS\n>  git range-diff [--color=[<when>]] [--no-color] [<diff-options>]\n>  \t[--no-dual-color] [--creation-factor=<factor>]\n>  \t[--left-only | --right-only] [--diff-merges=<format>]\n> -\t[--remerge-diff]\n> +\t[--remerge-diff] [--no-notes | --notes[=<ref>]]\n>  \t( <range1> <range2> | <rev1>...<rev2> | <base> <rev1> <rev2> )\n>  \t[[--] <path>...]\n"},{"id":"543713","messageId":"87qzn7q7qj.fsf@gitster.g","threadId":"65662","inReplyTo":"6a8c2093643a385641ef0b2cde33839dc98d8678.1779207350.git.siddh.raman.pant@oracle.com","subject":"Re: [PATCH 3/9] wrapper: add sleep_nanosec","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-19T23:50:44Z","receivedAt":"2026-05-19T23:50:48Z","isPatch":true,"body":"Siddh Raman Pant <siddh.raman.pant@oracle.com> writes:\n\n> Signed-off-by: Siddh Raman Pant <siddh.raman.pant@oracle.com>\n> ---\n\nThe space above the signed-off-by line should be utilized to explain\nwhy we want this change.  For the purpose of this series, why do we\nwant to sleep at nanosecond precision?\n\n>  wrapper.c | 49 +++++++++++++++++++++++++++++++++++++++++++++++++\n>  wrapper.h |  1 +\n>  2 files changed, 50 insertions(+)\n>\n> diff --git a/wrapper.c b/wrapper.c\n> index 16f5a63fbb61..1349255f1eb4 100644\n> --- a/wrapper.c\n> +++ b/wrapper.c\n> @@ -10,6 +10,7 @@\n>  #include \"gettext.h\"\n>  #include \"strbuf.h\"\n>  #include \"trace2.h\"\n> +#include <time.h>\n>  \n>  #ifdef HAVE_RTLGENRANDOM\n>  /* This is required to get access to RtlGenRandom. */\n> @@ -708,6 +709,54 @@ void sleep_millisec(int millisec)\n>  \tpoll(NULL, 0, millisec);\n>  }\n>  \n> +#ifdef GIT_WINDOWS_NATIVE\n> +/* No nanosleep() on Windows, so fall-back to using sleep_millisec(). */\n> +int sleep_nanosec(uint64_t nanosec)\n> +{\n> +\tuint64_t ns_in_1ms = 1000000ULL;\t/* 1 ms = 10^6 ns */\n> +\n> +\tuint64_t millisec = nanosec / ns_in_1ms;\n> +\tif (nanosec % ns_in_1ms)\n> +\t\tmillisec++;\n> +\n> +\t/* Chunked sleep if we can't represent in integer. */\n> +\twhile (millisec > INT_MAX) {\n> +\t\tsleep_millisec(INT_MAX);\n> +\t\tmillisec -= INT_MAX;\n> +\t}\n> +\n> +\tsleep_millisec((int)millisec);\n> +\n> +\treturn 0;\n> +}\n> +#else\n> +/* Not Windows, so use the more exact nanosleep(). */\n> +int sleep_nanosec(uint64_t nanosec)\n> +{\n> +\tint ret;\n> +\tstruct timespec duration, remaining;\n> +\n> +\t/* Construct the duration by dividing the given total (1s = 10^9ns). */\n> +\tduration.tv_sec = nanosec / 1000000000ULL;\n> +\tduration.tv_nsec = nanosec % 1000000000ULL;\n> +\n> +\twhile(1) {\n> +\t\tret = nanosleep(&duration, &remaining);\n> +\n> +\t\t/* Continue sleeping if interrupted. */\n> +\t\tif (ret == -1 && errno == EINTR) {\n> +\t\t\tduration = remaining;\n> +\t\t\tcontinue;\n> +\t\t}\n> +\n> +\t\t/* Either success or an error. */\n> +\t\tbreak;\n> +\t}\n> +\n> +\treturn ret;\n> +}\n> +#endif  /* GIT_WINDOWS_NATIVE */\n> +\n>  int xgethostname(char *buf, size_t len)\n>  {\n>  \t/*\n> diff --git a/wrapper.h b/wrapper.h\n> index 15ac3bab6e97..c39992893a81 100644\n> --- a/wrapper.h\n> +++ b/wrapper.h\n> @@ -130,6 +130,7 @@ int warn_on_fopen_errors(const char *path);\n>  int open_nofollow(const char *path, int flags);\n>  \n>  void sleep_millisec(int millisec);\n> +int sleep_nanosec(uint64_t nanosec);\n>  \n>  enum {\n>  \t/*\n"},{"id":"543716","messageId":"87fr3nq74l.fsf@gitster.g","threadId":"65662","inReplyTo":"9619077369f1a567bd505b1de1e4f672a5cd1950.1779207350.git.siddh.raman.pant@oracle.com","subject":"Re: [PATCH 7/9] notes: support an external command to display notes","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-20T00:03:54Z","receivedAt":"2026-05-20T00:03:59Z","isPatch":true,"body":"Siddh Raman Pant <siddh.raman.pant@oracle.com> writes:\n\n> This problem excaberates on scale.\n>\n> One solution to this is a realtime fetch or faster updation via\n> external means, but unfortunately we lose the coherence in the\n> display of information, and the user would end up reinventing\n> git log.\n>\n> So let's add support for an external command to display the notes.\n\nIt is unclear how we would arrive at \"So let's\" from the previous\nparagraph.  It is not limited to notes but multiple people updating\nthe same thing racing against each other happens all the time in the\nmain part of the history, no?  Isn't a better solution for such\nracing situation usually based on a better merge support, I have to\nwonder?\n\n> We split the addition of documentation and tests from this commit for\n> easier review. The new help text added in Documentation/ in the next\n> commit should make the usage clear.\n\nIt is unclear why a large body of code that is not documented or\nwhose uses are not illustrated by examples found in the test scripts\nis easier to review, though.\n"},{"id":"543736","messageId":"aaf25b8c84a51d0a3156af1944ec39b51f764019.camel@oracle.com","threadId":"65662","inReplyTo":"87fr3nq74l.fsf@gitster.g","subject":"Re: [PATCH 7/9] notes: support an external command to display notes","fromName":"Siddh Raman Pant","fromEmail":"siddh.raman.pant@oracle.com","sentAt":"2026-05-20T06:59:36Z","receivedAt":"2026-05-20T06:59:46Z","isPatch":true,"body":"On Wed, May 20 2026 at 05:33:54 +0530, Junio C Hamano wrote:\n> Siddh Raman Pant <siddh.raman.pant@oracle.com> writes:\n> \n> > This problem excaberates on scale.\n> > \n> > One solution to this is a realtime fetch or faster updation via\n> > external means, but unfortunately we lose the coherence in the\n> > display of information, and the user would end up reinventing\n> > git log.\n> > \n> > So let's add support for an external command to display the notes.\n> \n> It is unclear how we would arrive at \"So let's\" from the previous\n> paragraph.  It is not limited to notes but multiple people updating\n> the same thing racing against each other happens all the time in the\n> main part of the history, no?  Isn't a better solution for such\n> racing situation usually based on a better merge support, I have to\n> wonder?\n\nSorry, I should have been clear.\n\nThe issue I meant to describe is not primarily about two people\nupdating the same note object at the same time.\n\nThe workflow I have in mind is different. In kernel work, the same\nlogical upstream fix can appear as different commit objects across many\ndownstream branches, such as the stable branches and vendor-specific\nbranches (based on which the released kernel is actually built).\nDifferent developers may be working on those branches in parallel, and\na review decision recorded for one backport is useful context for the\nothers.\n\nToday, seeing that decision in ordinary history output requires first\nsynchronizing the local notes ref, and then interpreting those notes\nfor the branch being inspected. The latter step is workflow-specific\nand can be cheap, but keeping the local notes state fresh enough can be\nexpensive in a large kernel repository with a large shared notes\nhistory (and if we are to extrapolate, a slow git server conn/ops can\nbe a factor too).\n\nThat is the synchronization problem I was trying to describe: not that\nGit should solve all concurrent note updates, but that users can be\nlooking at stale note-derived information simply because their local\nnotes state has not caught up yet and catching up is expensive.\n\nThe intended role of the external command is to move that freshness\npolicy out of Git's notes ref synchronization path. A site-specific\nhelper can decide how to obtain current note text for the commit being\ndisplayed, such as consulting an external service, doing a targeted\nlookup, or using its own cache/update policy. Git still owns the\ncoherent git log/show presentation; the helper only supplies the note\ntext to display.\n\n> > We split the addition of documentation and tests from this commit for\n> > easier review. The new help text added in Documentation/ in the next\n> > commit should make the usage clear.\n> \n> It is unclear why a large body of code that is not documented or\n> whose uses are not illustrated by examples found in the test scripts\n> is easier to review, though.\n\nOkay my bad. I'll squash them in v2 after this discussion, along with\nrewording the commit.\n\nThanks,\nSiddh\n"},{"id":"543737","messageId":"b3958381907244ca06a39e2fc116eec113a6bc85.camel@oracle.com","threadId":"65662","inReplyTo":"87v7cjq7vc.fsf@gitster.g","subject":"Re: [PATCH 1/9] Documentation/git-range-diff: add missing notes options in synopsis","fromName":"Siddh Raman Pant","fromEmail":"siddh.raman.pant@oracle.com","sentAt":"2026-05-20T07:00:24Z","receivedAt":"2026-05-20T07:00:33Z","isPatch":true,"body":"On Wed, May 20 2026 at 05:17:51 +0530, Junio C Hamano wrote:\n> This has nothing to do with \"external notes\" topic, no?\n\nYeah, but since I added the command line flag I found it doesn't\nmention the existing flags.\n\nFixing it in the \"external notes\" commit would be bad, so I put it\nbefore that, since it also then provides a logical place to add new\nflags.\n\nThanks,\nSiddh\n"},{"id":"543738","messageId":"56552f593c7fed411af24413e4d3e77a34828451.camel@oracle.com","threadId":"65662","inReplyTo":"87qzn7q7qj.fsf@gitster.g","subject":"Re: [PATCH 3/9] wrapper: add sleep_nanosec","fromName":"Siddh Raman Pant","fromEmail":"siddh.raman.pant@oracle.com","sentAt":"2026-05-20T07:07:41Z","receivedAt":"2026-05-20T07:07:52Z","isPatch":true,"body":"On Wed, May 20 2026 at 05:20:44 +0530, Junio C Hamano wrote:\n> The space above the signed-off-by line should be utilized to explain\n> why we want this change.  For the purpose of this series, why do we\n> want to sleep at nanosecond precision?\n\nThe current time returned by getnanotime() is in nanoseconds which is\nused for deadline, so to avoid re-casting in helper code path we try to\nstay in nanosecond world. The caller can store in ns once and reuse it\neverytime.\n\nThanks,\nSiddh\n"},{"id":"543761","messageId":"xmqqpl2p38s4.fsf@gitster.g","threadId":"65662","inReplyTo":"b3958381907244ca06a39e2fc116eec113a6bc85.camel@oracle.com","subject":"Re: [PATCH 1/9] Documentation/git-range-diff: add missing notes options in synopsis","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-21T00:28:59Z","receivedAt":"2026-05-21T00:29:02Z","isPatch":true,"body":"Siddh Raman Pant <siddh.raman.pant@oracle.com> writes:\n\n> On Wed, May 20 2026 at 05:17:51 +0530, Junio C Hamano wrote:\n>> This has nothing to do with \"external notes\" topic, no?\n>\n> Yeah, but since I added the command line flag I found it doesn't\n> mention the existing flags.\n>\n> Fixing it in the \"external notes\" commit would be bad, so I put it\n> before that, since it also then provides a logical place to add new\n> flags.\n\nWhat I meant was that it would have been better as a standalone\npatch that is unrelated to the (now) 8-patch topic for the external\nnotes.  That way, it can move faster without waiting for the rest.\n\nUnless this patch has complex semantic or textual conflicts that\nmakes it easier to manage together with the external notes series,\nthat is.  I think adding [--notes=...] to one existing line (this\npatch) and adding a new line with [--[no-]external] on it (the main\npart of the topic) can be done in parallel and it is not too much to\nask for the integrator to merge them on the receiving end.\n\nThanks.\n\n\n\n"},{"id":"543762","messageId":"ag5b4O7-k-3QBR4W@fruit.crustytoothpaste.net","threadId":"65662","inReplyTo":"9619077369f1a567bd505b1de1e4f672a5cd1950.1779207350.git.siddh.raman.pant@oracle.com","subject":"Re: [PATCH 7/9] notes: support an external command to display notes","fromName":"brian m. carlson","fromEmail":"sandals@crustytoothpaste.net","sentAt":"2026-05-21T01:12:00Z","receivedAt":"2026-05-21T01:18:10Z","isPatch":true,"body":"On 2026-05-19 at 16:30:36, Siddh Raman Pant wrote:\n> git notes is a very very helpful feature to show user-supplied\n> information about a commit alongside its message transparently.\n> \n> For distributed teams working on large git repos (huge number of\n> branches/refs, files, etc.) and using the notes feature to mark\n> information on git commits, a TOCTOU race can happen due to very\n> large size of the repo and notes ref:\n> \t- Person A updates a note for commit X.\n> \t- Person A pushes the notes but it takes some time.\n> \t- Person B fetches notes and doesn't find the updated note.\n> \t- Person B can come to know of it only when he overwrites it\n> \t  and encounters a push failure.\n> \n> This problem excaberates on scale.\n> \n> One solution to this is a realtime fetch or faster updation via\n> external means, but unfortunately we lose the coherence in the\n> display of information, and the user would end up reinventing\n> git log.\n> \n> So let's add support for an external command to display the notes.\n> \n> We split the addition of documentation and tests from this commit for\n> easier review. The new help text added in Documentation/ in the next\n> commit should make the usage clear.\n> \n> Assisted-by: Codex:gpt-5.5-xhigh-fast\n\nJust a question here: was this written in whole or in part by Codex, or\nwas it just used as a reference to ask questions?  I ask because the\nstyle of notes-external.c differs quite a bit from the style we use (for\none, the horizontal rule comments) and we have this in\nSubmittingPatches:\n\n    The Developer's Certificate of Origin requires contributors to certify\n    that they know the origin of their contributions to the project and\n    that they have the right to submit it under the project's license.\n    It's not yet clear that this can be legally satisfied when submitting\n    significant amount of content that has been generated by AI tools.\n\n    [...]\n\n    To avoid these issues, we will reject anything that looks AI\n    generated, that sounds overly formal or bloated, that looks like AI\n    slop, that looks good on the surface but makes no sense, or that\n    senders don’t understand or cannot explain.\n\nI'll note that it also has a lot of global variables, which are common\nin the codebase but we're trying to move away from, and it's more\nverbose in commenting than we'd normally see elsewhere in the codebase.\n-- \nbrian m. carlson (they/them)\nToronto, Ontario, CA\n"},{"id":"543765","messageId":"4086055f59eec99f94847a1b37c684a084f08e0b.camel@oracle.com","threadId":"65662","inReplyTo":"ag5b4O7-k-3QBR4W@fruit.crustytoothpaste.net","subject":"Re: [PATCH 7/9] notes: support an external command to display notes","fromName":"Siddh Raman Pant","fromEmail":"siddh.raman.pant@oracle.com","sentAt":"2026-05-21T04:12:41Z","receivedAt":"2026-05-21T04:12:57Z","isPatch":true,"body":"On Thu, May 21 2026 at 06:42:00 +0530, brian m. carlson wrote:\n> > Assisted-by: Codex:gpt-5.5-xhigh-fast\n> \n> Just a question here: was this written in whole or in part by Codex, or\n> was it just used as a reference to ask questions?  I ask because the\n> style of notes-external.c differs quite a bit from the style we use (for\n> one, the horizontal rule comments) and we have this in\n\nAI tools typically don't generate comments in code like in this series,\nyou can see by trying out for yourself. Each comment is hand-written by\nme. Sorry, I'll remove those lines in v2 after this discussion.\n\nAI was used for review, and providing the initial skeletal, which I\nchanged significantly.\n\n> SubmittingPatches:\n> \n>     The Developer's Certificate of Origin requires contributors to certify\n>     that they know the origin of their contributions to the project and\n>     that they have the right to submit it under the project's license.\n>     It's not yet clear that this can be legally satisfied when submitting\n>     significant amount of content that has been generated by AI tools.\n> \n>     [...]\n> \n>     To avoid these issues, we will reject anything that looks AI\n>     generated, that sounds overly formal or bloated, that looks like AI\n>     slop, that looks good on the surface but makes no sense, or that\n>     senders don’t understand or cannot explain.\n\nPlease tell me why this change is a slop and doesn't make sense.\n\nIf I wanted to mislead here, I would not have used the \"Assisted-by\"\ntrailer, which is now being used in kernel land:\n\nhttps://www.kernel.org/doc/html/latest/process/submitting-patches.html#using-assisted-by\n\nThere have already been commits in the git.git history having the\nAssisted-by trailer.\n\n> I'll note that it also has a lot of global variables, which are common\n> in the codebase but we're trying to move away from, \n\nIs there a new facility to store the config without a global variable?\n\nIf the issue is the number, I can make a housing struct if you want.\n\n> and it's more\n> verbose in commenting than we'd normally see elsewhere in the codebase.\n\nI added comments to explain the code clearly as it's being followed,\nespecially since this is a new feature and I wanted the intent to be\nclear.\n\nIf you could tell me which comments to remove, that would be great.\n\nThanks,\nSiddh\n"},{"id":"543766","messageId":"c0e892b012c780c254069bd718b98453a2027ece.camel@oracle.com","threadId":"65662","inReplyTo":"xmqqpl2p38s4.fsf@gitster.g","subject":"Re: [PATCH 1/9] Documentation/git-range-diff: add missing notes options in synopsis","fromName":"Siddh Raman Pant","fromEmail":"siddh.raman.pant@oracle.com","sentAt":"2026-05-21T04:13:45Z","receivedAt":"2026-05-21T04:13:55Z","isPatch":true,"body":"On Thu, May 21 2026 at 05:58:59 +0530, Junio C Hamano wrote:\n> Siddh Raman Pant <siddh.raman.pant@oracle.com> writes:\n> \n> > On Wed, May 20 2026 at 05:17:51 +0530, Junio C Hamano wrote:\n> > > This has nothing to do with \"external notes\" topic, no?\n> > \n> > Yeah, but since I added the command line flag I found it doesn't\n> > mention the existing flags.\n> > \n> > Fixing it in the \"external notes\" commit would be bad, so I put it\n> > before that, since it also then provides a logical place to add new\n> > flags.\n> \n> What I meant was that it would have been better as a standalone\n> patch that is unrelated to the (now) 8-patch topic for the external\n> notes.  That way, it can move faster without waiting for the rest.\n> \n> Unless this patch has complex semantic or textual conflicts that\n> makes it easier to manage together with the external notes series,\n> that is.  I think adding [--notes=...] to one existing line (this\n> patch) and adding a new line with [--[no-]external] on it (the main\n> part of the topic) can be done in parallel and it is not too much to\n> ask for the integrator to merge them on the receiving end.\n\nOk sure, I'll send it as a standalone patch.\n\nThanks,\nSiddh\n"},{"id":"543774","messageId":"b69605a6-e841-47b9-a899-a57e184d3c8b@kdbg.org","threadId":"65662","inReplyTo":"f58c8c522814dce9257f64733e9fbc9bd9f446c0.1779207350.git.siddh.raman.pant@oracle.com","subject":"Re: [PATCH 4/9] run-command: add support for timeout in command finisher","fromName":"Johannes Sixt","fromEmail":"j6t@kdbg.org","sentAt":"2026-05-21T07:21:51Z","receivedAt":"2026-05-21T07:22:02Z","isPatch":true,"body":"Am 19.05.26 um 18:30 schrieb Siddh Raman Pant:\n> A called command may not respond to the initial signal and will get\n> stuck in finish_command() -> wait_or_whine().\n> \n> So let's add timeout support into the finisher so that if a deadline\n> occurs, we can send a force-kill signal.\n\nThis is extremely suspicious. A communication protocl with a child\nprogram that requires to kill the child looks like a design error. A\nband-aid like this timeout should not be necessary for a well-behaved\nchild process.\n\nIf the (your?) problem is that the child process is actually not\nwell-behaved, then I suggest to use a middle-man as child process that\nbehaves well from the point of view of the git process, but can punish\nthe ill-behaved downstream process when needed.\n\nPlease, do not add this infrastructure to core Git, and instead fix the\ncommunication protocol.\n\n> \n> The force-kill signal is in the argument because a program may trap a\n> signal, so it is the responsibility of caller to pass the correct kill\n> signal.\n-- Hannes\n\n"},{"id":"543806","messageId":"ag7EtmUk3UCpBU4Y@ugly.lan","threadId":"65662","inReplyTo":"b69605a6-e841-47b9-a899-a57e184d3c8b@kdbg.org","subject":"Re: [PATCH 4/9] run-command: add support for timeout in command finisher","fromName":"Oswald Buddenhagen","fromEmail":"oswald.buddenhagen@gmx.de","sentAt":"2026-05-21T08:39:18Z","receivedAt":"2026-05-21T08:39:28Z","isPatch":true,"body":"On Thu, May 21, 2026 at 09:21:51AM +0200, Johannes Sixt wrote:\n>Please, do not add this infrastructure to core Git, and instead fix the\n>communication protocol.\n>\nthere is nothing to fix here. proper error handling including timeout \nhandling should just be part of every protocol handler, and in the case \nof child processes, forcible termination is part of that.\n\none can ignore the issue, in which case termination is left to the user \nby ctrl-c'ing the whole process group. this isn't very user-friendly, \nbecause it doesn't report the problem, and it may leave hung processes \nbehind. it is also extremely bad if keeping the parent process alive is \na lot more important than the child process, but this doesn't appear to \napply to the particular use case.\n\nadding a proxy doesn't fix the problem, it just adds another point of \nfailure.\n"},{"id":"543813","messageId":"2f7eea03273ffaacc50a9ae186673da88fc3345f.camel@oracle.com","threadId":"65662","inReplyTo":"b69605a6-e841-47b9-a899-a57e184d3c8b@kdbg.org","subject":"Re: [PATCH 4/9] run-command: add support for timeout in command finisher","fromName":"Siddh Raman Pant","fromEmail":"siddh.raman.pant@oracle.com","sentAt":"2026-05-21T09:59:09Z","receivedAt":"2026-05-21T09:59:24Z","isPatch":true,"body":"On Thu, May 21 2026 at 12:51:51 +0530, Johannes Sixt wrote:\n> This is extremely suspicious. A communication protocl with a child\n> program that requires to kill the child looks like a design error. A\n> band-aid like this timeout should not be necessary for a well-behaved\n> child process.\n\nI do not think this is a protocol design error. The normal protocol does\nnot require killing the helper: git sends one object id, the helper\nsends one bounded response, and the helper exits when git closes its\npipes.\n\nThe timeout is for the failure path, where the external helper has\nalready stopped following that protocol or is blocked on something\noutside git's control. Since git starts the helper and puts it on the\nlog/grep path, git also needs a bounded way to recover when that helper\ndoes not make progress. Otherwise an optional note source can prevent\nthe main git command from completing.\n\n> If the (your?) problem is that the child process is actually not\n> well-behaved, then I suggest to use a middle-man as child process that\n> behaves well from the point of view of the git process, but can punish\n> the ill-behaved downstream process when needed.\n\nA middle-man would need the same timeout/termination/reaping logic, and\ngit would still need to handle the middle-man itself hanging / failing.\nSo I don't think it removes the problem, it just makes each user or\ndeployment carry that process-supervision logic outside git.\n\nExternal notes are additive. If the helper misbehaves, the intended\nbehavior is to warn once, disable that source for the rest of the\nprocess, and let git continue without those notes. That seems\npreferable to leaving git stuck in finish_command().\n\nThanks,\nSiddh\n"},{"id":"543833","messageId":"cf52154c-1275-4a4b-957e-5aa17f22705c@kdbg.org","threadId":"65662","inReplyTo":"2f7eea03273ffaacc50a9ae186673da88fc3345f.camel@oracle.com","subject":"Re: [PATCH 4/9] run-command: add support for timeout in command finisher","fromName":"Johannes Sixt","fromEmail":"j6t@kdbg.org","sentAt":"2026-05-21T14:36:05Z","receivedAt":"2026-05-21T14:36:15Z","isPatch":true,"body":"Am 21.05.26 um 11:59 schrieb Siddh Raman Pant:\n> The timeout is for the failure path, where the external helper has\n> already stopped following that protocol or is blocked on something\n> outside git's control. Since git starts the helper and puts it on the\n> log/grep path, git also needs a bounded way to recover when that helper\n> does not make progress. Otherwise an optional note source can prevent\n> the main git command from completing.\n\nThat Git communicates with a process that looks like it stopped is the\nnormal case, for example:\n\n- Output is sent to the pager. The user can take their time to study the\noutput. All the while, git waits patiently for the user to advance the\npager.\n\n- Git fetch transfers large amounts of data across the network. Most of\nthe time it waits for data to arrive and does nothing. The peer process\nlooks like it hangs. Git does not decide to kill the connection at any\ntime. It is the user's decision to do so.\n\nIf the notes provider hangs, then it is not on Git to decide when it has\nwaited long enough.\n\n-- Hannes\n\n"},{"id":"543853","messageId":"ag92poA7U6ZefRv3@fruit.crustytoothpaste.net","threadId":"65662","inReplyTo":"4086055f59eec99f94847a1b37c684a084f08e0b.camel@oracle.com","subject":"Re: [PATCH 7/9] notes: support an external command to display notes","fromName":"brian m. carlson","fromEmail":"sandals@crustytoothpaste.net","sentAt":"2026-05-21T21:18:30Z","receivedAt":"2026-05-21T21:18:33Z","isPatch":true,"body":"On 2026-05-21 at 04:12:41, Siddh Raman Pant wrote:\n> On Thu, May 21 2026 at 06:42:00 +0530, brian m. carlson wrote:\n> > > Assisted-by: Codex:gpt-5.5-xhigh-fast\n> > \n> > Just a question here: was this written in whole or in part by Codex, or\n> > was it just used as a reference to ask questions?  I ask because the\n> > style of notes-external.c differs quite a bit from the style we use (for\n> > one, the horizontal rule comments) and we have this in\n> \n> AI tools typically don't generate comments in code like in this series,\n> you can see by trying out for yourself. Each comment is hand-written by\n> me. Sorry, I'll remove those lines in v2 after this discussion.\n\nI've actually seen AI tools do things very similar to what you've\nwritten.\n\n> > SubmittingPatches:\n> > \n> >     The Developer's Certificate of Origin requires contributors to certify\n> >     that they know the origin of their contributions to the project and\n> >     that they have the right to submit it under the project's license.\n> >     It's not yet clear that this can be legally satisfied when submitting\n> >     significant amount of content that has been generated by AI tools.\n> > \n> >     [...]\n> > \n> >     To avoid these issues, we will reject anything that looks AI\n> >     generated, that sounds overly formal or bloated, that looks like AI\n> >     slop, that looks good on the surface but makes no sense, or that\n> >     senders don’t understand or cannot explain.\n> \n> Please tell me why this change is a slop and doesn't make sense.\n\nI didn't say this was slop and didn't make sense.  I quoted the portion\nthat says that we don't accept anything AI generated, including for\nlicense reasons.  There's still very little clarity about whether AI\ncode is a derivative work of the training set or whether it can be\ncopyrightable at all, very especially on a worldwide basis.  We don't\nwant to end up with a legal or license problem that the DCO was intended\nto solve.\n\n> If I wanted to mislead here, I would not have used the \"Assisted-by\"\n> trailer, which is now being used in kernel land:\n> \n> https://www.kernel.org/doc/html/latest/process/submitting-patches.html#using-assisted-by\n\nThe kernel and Git do different things.  Linux generally allows AI and\nwe generally restrict its use quite heavily.  Linux tries to never break\ndependent projects and we don't have that policy.\n\nI appreciate the header being included and agree that it should be, but\nit's important we ask questions about the provenance of the code when AI\nis used because many people do not read SubmittingPatches (or\ncontributing documentation in general).\n\n> > I'll note that it also has a lot of global variables, which are common\n> > in the codebase but we're trying to move away from, \n> \n> Is there a new facility to store the config without a global variable?\n> \n> If the issue is the number, I can make a housing struct if you want.\n\nWe'd typically use repo_config_get_string or such to fetch the\nconfiguration these days.  If you don't want to fetch it multiple times,\nwe'd generally read all the config and put it in a struct that we'd\ninitialize with a function at a suitable time.\n\nThere's effort to avoid the global variables because they don't work\nwell in libraries and we want to allow libgit.a to be used more\ngenerally.  In addition, Rust considers static mutable variables to be\nunsafe, so as we add more Rust, we'll need to minimize the use of any\nglobals.\n\n> I added comments to explain the code clearly as it's being followed,\n> especially since this is a new feature and I wanted the intent to be\n> clear.\n> \n> If you could tell me which comments to remove, that would be great.\n\nI don't think it's necessarily a problem to have the comments, but it is\nuncommon in our codebase, which is what drew my attention.\n-- \nbrian m. carlson (they/them)\nToronto, Ontario, CA\n"},{"id":"543864","messageId":"xmqqv7cgxq0o.fsf@gitster.g","threadId":"65662","inReplyTo":"cf52154c-1275-4a4b-957e-5aa17f22705c@kdbg.org","subject":"Re: [PATCH 4/9] run-command: add support for timeout in command finisher","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-22T00:10:47Z","receivedAt":"2026-05-22T00:10:50Z","isPatch":true,"body":"Johannes Sixt <j6t@kdbg.org> writes:\n\n> Am 21.05.26 um 11:59 schrieb Siddh Raman Pant:\n>> The timeout is for the failure path, where the external helper has\n>> already stopped following that protocol or is blocked on something\n>> outside git's control. Since git starts the helper and puts it on the\n>> log/grep path, git also needs a bounded way to recover when that helper\n>> does not make progress. Otherwise an optional note source can prevent\n>> the main git command from completing.\n>\n> That Git communicates with a process that looks like it stopped is the\n> normal case, for example:\n>\n> - Output is sent to the pager. The user can take their time to study the\n> output. All the while, git waits patiently for the user to advance the\n> pager.\n>\n> - Git fetch transfers large amounts of data across the network. Most of\n> the time it waits for data to arrive and does nothing. The peer process\n> looks like it hangs. Git does not decide to kill the connection at any\n> time. It is the user's decision to do so.\n>\n> If the notes provider hangs, then it is not on Git to decide when it has\n> waited long enough.\n\nIt is often the sticking sore point that there is no good timeout\nvalue that suites for everybody.\n\nIf a protocol builds its own way to declare \"this backend is slow,\nso please do not consider less than 3 seconds of nonaction something\nto worry about but kill it off if you waited more than that\" to make\nthe receiving/waiting end responsible for managing timeout, that\nmight be workable, but it certainly feels like a kludge.  The\nprotocol can instead allow an \"error - for your particular request,\nwe couldn't come up with an answer within a reasonable time limit\"\nresponse (in practice, \"within time limit\" does not have to be the\nonly reason for such an error) to be returned, I think.\n\n\n\n\n"},{"id":"543879","messageId":"20260522051048.GA862219@coredump.intra.peff.net","threadId":"65662","inReplyTo":"cf52154c-1275-4a4b-957e-5aa17f22705c@kdbg.org","subject":"Re: [PATCH 4/9] run-command: add support for timeout in command finisher","fromName":"Jeff King","fromEmail":"peff@peff.net","sentAt":"2026-05-22T05:10:48Z","receivedAt":"2026-05-22T05:10:53Z","isPatch":true,"body":"On Thu, May 21, 2026 at 04:36:05PM +0200, Johannes Sixt wrote:\n\n> Am 21.05.26 um 11:59 schrieb Siddh Raman Pant:\n> > The timeout is for the failure path, where the external helper has\n> > already stopped following that protocol or is blocked on something\n> > outside git's control. Since git starts the helper and puts it on the\n> > log/grep path, git also needs a bounded way to recover when that helper\n> > does not make progress. Otherwise an optional note source can prevent\n> > the main git command from completing.\n> \n> That Git communicates with a process that looks like it stopped is the\n> normal case, for example:\n> \n> - Output is sent to the pager. The user can take their time to study the\n> output. All the while, git waits patiently for the user to advance the\n> pager.\n> \n> - Git fetch transfers large amounts of data across the network. Most of\n> the time it waits for data to arrive and does nothing. The peer process\n> looks like it hangs. Git does not decide to kill the connection at any\n> time. It is the user's decision to do so.\n> \n> If the notes provider hangs, then it is not on Git to decide when it has\n> waited long enough.\n\nYeah, I agree with your point of view. If I understand this patch series\ncorrectly, it is about adding an external process to map commit ids to\nnote data. So I can think of some existing features that are quite close\nto that in nature, none of which use timeouts:\n\n  - textconv filters and external diffs which process data in the middle\n    of a git-log invocation\n\n  - long-lived clean/smudge filters map blobs to arbitrarily large text\n\n  - cat-file's batch mode maps object ids to user-specified data about\n    that object\n\nAs you note, it's up to the command to be well-behaved. Git should\nnotice and respond appropriately if the command closes the pipe, of\ncourse. Sometimes a timeout can help with a poorly behaved command, but\nIMHO it is not worth the cost of non-determinism that it brings.\n\nMoreover, the bits touching run-command here make me nervous, especially\nafter the challenges we saw in the child-cleanup topic that was reverted\njust after v2.54. There is often a shell interposed between Git and the\nsub-command, and we don't always know how the shell will react to\nsignals. Using SIGKILL will eventually get us _something_ to wait() on,\nbut it might not even be the process we care about!\n\nI don't really care much about this external-notes feature one way or\nthe other, but if we are going to do it, I don't see any reason why it\nwould not behave like all of the other similar parts of Git.\n\n-Peff\n"},{"id":"543884","messageId":"cea0975e77d8167c0bceab8a45acf6882910a74b.camel@oracle.com","threadId":"65662","inReplyTo":"xmqqv7cgxq0o.fsf@gitster.g","subject":"Re: [PATCH 4/9] run-command: add support for timeout in command finisher","fromName":"Siddh Raman Pant","fromEmail":"siddh.raman.pant@oracle.com","sentAt":"2026-05-22T05:46:55Z","receivedAt":"2026-05-22T05:47:08Z","isPatch":true,"body":"On Fri, May 22 2026 at 05:40:47 +0530, Junio C Hamano wrote:\n> If a protocol builds its own way to declare \"this backend is slow,\n> so please do not consider less than 3 seconds of nonaction something\n> to worry about but kill it off if you waited more than that\" to make\n> the receiving/waiting end responsible for managing timeout, that\n> might be workable, but it certainly feels like a kludge.  The\n> protocol can instead allow an \"error - for your particular request,\n> we couldn't come up with an answer within a reasonable time limit\"\n> response (in practice, \"within time limit\" does not have to be the\n> only reason for such an error) to be returned, I think.\n\nI think we are confusing two different commits here.\n\nThe response read deadline is the next commit. This commit is about\nforce-killing a process if it doesn't respond to the initial\ntermination signal.\n\nThanks,\nSiddh\n"},{"id":"543888","messageId":"a0916ed8965d2024ee0f6005c942f77a16f28dbb.camel@oracle.com","threadId":"65662","inReplyTo":"20260522051048.GA862219@coredump.intra.peff.net","subject":"Re: [PATCH 4/9] run-command: add support for timeout in command finisher","fromName":"Siddh Raman Pant","fromEmail":"siddh.raman.pant@oracle.com","sentAt":"2026-05-22T05:59:20Z","receivedAt":"2026-05-22T06:08:56Z","isPatch":true,"body":"On Fri, May 22 2026 at 10:40:48 +0530, Jeff King wrote:\n> On Thu, May 21, 2026 at 04:36:05PM +0200, Johannes Sixt wrote:\n> \n> > Am 21.05.26 um 11:59 schrieb Siddh Raman Pant:\n> > > The timeout is for the failure path, where the external helper has\n> > > already stopped following that protocol or is blocked on something\n> > > outside git's control. Since git starts the helper and puts it on the\n> > > log/grep path, git also needs a bounded way to recover when that helper\n> > > does not make progress. Otherwise an optional note source can prevent\n> > > the main git command from completing.\n> > \n> > That Git communicates with a process that looks like it stopped is the\n> > normal case, for example:\n> > \n> > - Output is sent to the pager. The user can take their time to study the\n> > output. All the while, git waits patiently for the user to advance the\n> > pager.\n> > \n> > - Git fetch transfers large amounts of data across the network. Most of\n> > the time it waits for data to arrive and does nothing. The peer process\n> > looks like it hangs. Git does not decide to kill the connection at any\n> > time. It is the user's decision to do so.\n> > \n> > If the notes provider hangs, then it is not on Git to decide when it has\n> > waited long enough.\n> \n> Yeah, I agree with your point of view. If I understand this patch series\n> correctly, it is about adding an external process to map commit ids to\n> note data. So I can think of some existing features that are quite close\n> to that in nature, none of which use timeouts:\n> \n>   - textconv filters and external diffs which process data in the middle\n>     of a git-log invocation\n> \n>   - long-lived clean/smudge filters map blobs to arbitrarily large text\n> \n>   - cat-file's batch mode maps object ids to user-specified data about\n>     that object\n> \n> As you note, it's up to the command to be well-behaved. Git should\n> notice and respond appropriately if the command closes the pipe, of\n> course. Sometimes a timeout can help with a poorly behaved command, but\n> IMHO it is not worth the cost of non-determinism that it brings.\n> \n> Moreover, the bits touching run-command here make me nervous, especially\n> after the challenges we saw in the child-cleanup topic that was reverted\n> just after v2.54. There is often a shell interposed between Git and the\n> sub-command, and we don't always know how the shell will react to\n> signals. Using SIGKILL will eventually get us _something_ to wait() on,\n> but it might not even be the process we care about!\n> \n> I don't really care much about this external-notes feature one way or\n> the other, but if we are going to do it, I don't see any reason why it\n> would not behave like all of the other similar parts of Git.\n> \n> -Peff\n\nOkay, since the consensus here is pretty clear, I will remove this\ncommit and send a v2.\n\nThanks,\nSiddh\n"}]}