"GOT", but the "O" is a cute, smiling pufferfish. Index | Thread | Search

From:
Stefan Sperling <stsp@stsp.name>
Subject:
gotd notification support
To:
gameoftrees@openbsd.org
Date:
Tue, 19 Mar 2024 16:03:30 +0100

Download raw body.

Thread
This patch adds initial notification support to gotd.

I have been working on this on the side for a couple of months now.
It turned into a bigger chunk of work than I initially expected.

By now, email notifications are working and have passing regression tests.
I left out some configurable features that I had envisioned in my initial
documentation draft for this feature because they added too much complexity.

The current behaviour produces 'got tag -l' style output for tags, and
'got log -d' style output for commits. If more than 50 commits are sent
in one batch, we switch to 'got log -s' style.

I am happy enough with this behaviour for the initial version. We can
of course keep tweaking this and add options if different behaviour turns
out to be needed. In particular, sending full diffs would require some
further changes in the diff code, because of int fd vs. FIlE *f.)

The HTTP bits are not finished, and I would appreciate help with them
once this diff makes it into the tree. Once HTTP notifications are
functional we can finally move the Got project's primary repository
to gotd without losing commit notifications in our IRC channel.

OK?

diffstat refs/heads/main refs/heads/notify
 M  gitwrapper/gitwrapper.c                           |    1+   1-
 M  gotd/Makefile                                     |    7+   1-
 M  gotd/gotd.c                                       |  240+  10-
 M  gotd/gotd.conf.5                                  |  133+   0-
 M  gotd/gotd.h                                       |   84+   1-
 A  gotd/libexec/Makefile                             |    3+   0-
 A  gotd/libexec/Makefile.inc                         |    7+   0-
 A  gotd/libexec/got-notify-email/Makefile            |   20+   0-
 A  gotd/libexec/got-notify-email/got-notify-email.c  |  344+   0-
 M  gotd/parse.y                                      |  626+   5-
 M  gotd/privsep_stub.c                               |    8+   0-
 A  gotd/notify.c                                     |  522+   0-
 A  gotd/notify.h                                     |   17+   0-
 M  gotd/repo_write.c                                 |  599+   0-
 M  gotd/repo_write.h                                 |    1+   0-
 M  gotd/session.c                                    |  453+   6-
 M  gotd/session.h                                    |    1+   1-
 M  regress/gotd/Makefile                             |   29+   3-
 M  regress/gotd/README                               |    6+   0-
 A  regress/gotd/email_notification.sh                |  533+   0-

20 files changed, 3634 insertions(+), 28 deletions(-)

diff refs/heads/main refs/heads/notify
commit - e0cc3e2b4793098f30708f83c18cf466a59aa9d3
commit + d1068956508f45942636ec0fead4377781f4e8b7
blob - 28645f07dc93480582fce4b5f7dea5813b028a23
blob + 326421d114b5877a893a1f63613d87e64dde3559
--- gitwrapper/gitwrapper.c
+++ gitwrapper/gitwrapper.c
@@ -172,7 +172,7 @@ main(int argc, char *argv[])
 			goto done;
 	}
 
-	repo = gotd_find_repo_by_name(repo_name, &gotd);
+	repo = gotd_find_repo_by_name(repo_name, &gotd.repos);
 
 	/*
 	 * Invoke our custom Git server if the repository was found
blob - 877e4d8fa52533fe457b51eefc3aed259e36e6a5
blob + 72ddf39763d42262c40de7298eb23144cf2d1703
--- gotd/Makefile
+++ gotd/Makefile
@@ -1,5 +1,7 @@
 .PATH:${.CURDIR}/../lib
 
+SUBDIR = libexec
+
 .include "../got-version.mk"
 
 .if ${GOT_RELEASE} == "Yes"
@@ -15,7 +17,11 @@ SRCS=		gotd.c auth.c repo_read.c repo_write.c log.c pr
 		object_open_io.c object_parse.c opentemp.c pack.c path.c \
 		read_gitconfig.c read_gotconfig.c reference.c repository.c  \
 		hash.c sigs.c pack_create_io.c pollfd.c reference_parse.c \
-		repo_imsg.c pack_index.c session.c object_qid.c
+		repo_imsg.c pack_index.c session.c object_qid.c notify.c \
+		commit_graph.c diffreg.c diff.c \
+		diff_main.c diff_atomize_text.c diff_myers.c diff_output.c \
+		diff_output_plain.c diff_output_unidiff.c \
+		diff_output_edscript.c diff_patience.c
 
 CLEANFILES = parse.h
 
blob - b46b5573bc4b7b42b5b311fec8206262a1111da3
blob + 82cbd62438015fab107bd4aa25d20bd02e800127
--- gotd/gotd.c
+++ gotd/gotd.c
@@ -48,6 +48,7 @@
 #include "got_repository.h"
 #include "got_object.h"
 #include "got_reference.h"
+#include "got_diff.h"
 
 #include "got_lib_delta.h"
 #include "got_lib_object.h"
@@ -64,6 +65,7 @@
 #include "session.h"
 #include "repo_read.h"
 #include "repo_write.h"
+#include "notify.h"
 
 #ifndef nitems
 #define nitems(_a)	(sizeof((_a)) / sizeof((_a)[0]))
@@ -300,6 +302,9 @@ proc_done(struct gotd_child_proc *proc)
 		disconnect(client);
 	}
 
+	if (proc == gotd.notify_proc)
+		gotd.notify_proc = NULL;
+
 	evtimer_del(&proc->tmo);
 
 	if (proc->iev.ibuf.fd != -1) {
@@ -531,7 +536,7 @@ start_client_authentication(struct gotd_client *client
 		err = ensure_client_is_not_writing(client);
 		if (err)
 			return err;
-		repo = gotd_find_repo_by_name(ireq.repo_name, &gotd);
+		repo = gotd_find_repo_by_name(ireq.repo_name, &gotd.repos);
 		if (repo == NULL)
 			return got_error(GOT_ERR_NOT_GIT_REPO);
 		err = start_auth_child(client, GOTD_AUTH_READ, repo,
@@ -543,7 +548,7 @@ start_client_authentication(struct gotd_client *client
 		err = ensure_client_is_not_reading(client);
 		if (err)
 			return err;
-		repo = gotd_find_repo_by_name(ireq.repo_name, &gotd);
+		repo = gotd_find_repo_by_name(ireq.repo_name, &gotd.repos);
 		if (repo == NULL)
 			return got_error(GOT_ERR_NOT_GIT_REPO);
 		err = start_auth_child(client,
@@ -735,7 +740,8 @@ static const char *gotd_proc_names[PROC_MAX] = {
 	"session_write",
 	"repo_read",
 	"repo_write",
-	"gitwrapper"
+	"gitwrapper",
+	"notify"
 };
 
 static void
@@ -1116,6 +1122,72 @@ done:
 }
 
 static void
+gotd_dispatch_notifier(int fd, short event, void *arg)
+{
+	struct gotd_imsgev *iev = arg;
+	struct imsgbuf *ibuf = &iev->ibuf;
+	struct gotd_child_proc *proc = gotd.notify_proc;
+	ssize_t n;
+	int shut = 0;
+	struct imsg imsg;
+
+	if (proc->iev.ibuf.fd != fd)
+		fatalx("%s: unexpected fd %d", __func__, fd);
+
+	if (event & EV_READ) {
+		if ((n = imsg_read(ibuf)) == -1 && errno != EAGAIN)
+			fatal("imsg_read error");
+		if (n == 0) {
+			/* Connection closed. */
+			shut = 1;
+			goto done;
+		}
+	}
+
+	if (event & EV_WRITE) {
+		n = msgbuf_write(&ibuf->w);
+		if (n == -1 && errno != EAGAIN)
+			fatal("msgbuf_write");
+		if (n == 0) {
+			/* Connection closed. */
+			shut = 1;
+			goto done;
+		}
+	}
+
+	for (;;) {
+		if ((n = imsg_get(ibuf, &imsg)) == -1)
+			fatal("%s: imsg_get error", __func__);
+		if (n == 0)	/* No more messages. */
+			break;
+
+		switch (imsg.hdr.type) {
+		default:
+			log_debug("unexpected imsg %d", imsg.hdr.type);
+			break;
+		}
+
+		imsg_free(&imsg);
+	}
+done:
+	if (!shut) {
+		gotd_imsg_event_add(iev);
+	} else {
+		/* This pipe is dead. Remove its event handler */
+		event_del(&iev->ev);
+
+		/*
+		 * Do not exit all of gotd if the notification handler dies.
+		 * We can continue operating without notifications until an
+		 * operator intervenes.
+		 */
+		log_warnx("notify child process (pid %d) closed its imsg pipe "
+		    "unexpectedly", proc->pid);
+		proc_done(proc);
+	}
+}
+
+static void
 gotd_dispatch_auth_child(int fd, short event, void *arg)
 {
 	const struct got_error *err = NULL;
@@ -1216,7 +1288,7 @@ gotd_dispatch_auth_child(int fd, short event, void *ar
 		goto done;
 	}
 
-	repo = gotd_find_repo_by_name(client->auth->repo_name, &gotd);
+	repo = gotd_find_repo_by_name(client->auth->repo_name, &gotd.repos);
 	if (repo == NULL) {
 		err = got_error(GOT_ERR_NOT_GIT_REPO);
 		goto done;
@@ -1251,6 +1323,7 @@ connect_session(struct gotd_client *client)
 	const struct got_error *err = NULL;
 	struct gotd_imsg_connect iconnect;
 	int s;
+	struct ibuf *wbuf;
 
 	memset(&iconnect, 0, sizeof(iconnect));
 
@@ -1261,14 +1334,28 @@ connect_session(struct gotd_client *client)
 	iconnect.client_id = client->id;
 	iconnect.euid = client->euid;
 	iconnect.egid = client->egid;
+	iconnect.username_len = strlen(client->username);
 
-	if (gotd_imsg_compose_event(&client->session->iev, GOTD_IMSG_CONNECT,
-	    PROC_GOTD, s, &iconnect, sizeof(iconnect)) == -1) {
+	wbuf = imsg_create(&client->session->iev.ibuf, GOTD_IMSG_CONNECT,
+	    PROC_GOTD, gotd.pid, sizeof(iconnect) + iconnect.username_len);
+	if (wbuf == NULL) {
 		err = got_error_from_errno("imsg compose CONNECT");
 		close(s);
 		return err;
 	}
+	if (imsg_add(wbuf, &iconnect, sizeof(iconnect)) == -1) {
+		close(s);
+		return got_error_from_errno("imsg_add CONNECT");
+	}
+	if (imsg_add(wbuf, client->username, iconnect.username_len) == -1) {
+		close(s);
+		return got_error_from_errno("imsg_add CONNECT");
+	}
 
+	ibuf_fd_set(wbuf, s);
+	imsg_close(&client->session->iev.ibuf, wbuf);
+	gotd_imsg_event_add(&client->session->iev);
+
 	/*
 	 * We are no longer interested in messages from this client.
 	 * Further client requests will be handled by the session process.
@@ -1368,7 +1455,7 @@ gotd_dispatch_client_session(int fd, short event, void
 			struct gotd_repo *repo;
 			const char *name = client->session->repo_name;
 
-			repo = gotd_find_repo_by_name(name, &gotd);
+			repo = gotd_find_repo_by_name(name, &gotd.repos);
 			if (repo != NULL) {
 				enum gotd_procid proc_type;
 
@@ -1408,6 +1495,40 @@ done:
 	}
 }
 
+static const struct got_error *
+connect_notifier_and_session(struct gotd_client *client)
+{
+	const struct got_error *err = NULL;
+	struct gotd_imsgev *session_iev = &client->session->iev;
+	int pipe[2];
+
+	if (gotd.notify_proc == NULL)
+		return NULL;
+
+	if (socketpair(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK,
+	    PF_UNSPEC, pipe) == -1)
+		return got_error_from_errno("socketpair");
+
+	/* Pass notifier pipe to session . */
+	if (gotd_imsg_compose_event(session_iev, GOTD_IMSG_CONNECT_NOTIFIER,
+	    PROC_GOTD, pipe[0], NULL, 0) == -1) {
+		err = got_error_from_errno("imsg compose CONNECT_NOTIFIER");
+		close(pipe[0]);
+		close(pipe[1]);
+		return err;
+	}
+
+	/* Pass session pipe to notifier. */
+	if (gotd_imsg_compose_event(&gotd.notify_proc->iev,
+	    GOTD_IMSG_CONNECT_SESSION, PROC_GOTD, pipe[1], NULL, 0) == -1) {
+		err = got_error_from_errno("imsg compose CONNECT_SESSION");
+		close(pipe[1]);
+		return err;
+	}
+
+	return NULL;
+}
+
 static void
 gotd_dispatch_repo_child(int fd, short event, void *arg)
 {
@@ -1471,6 +1592,9 @@ gotd_dispatch_repo_child(int fd, short event, void *ar
 			err = connect_session(client);
 			if (err)
 				break;
+			err = connect_notifier_and_session(client);
+			if (err)
+				break;
 			err = connect_repo_child(client, proc);
 			break;
 		default:
@@ -1550,6 +1674,9 @@ start_child(enum gotd_procid proc_id, const char *repo
 	case PROC_REPO_WRITE:
 		argv[argc++] = (char *)"-W";
 		break;
+	case PROC_NOTIFY:
+		argv[argc++] = (char *)"-N";
+		break;
 	default:
 		fatalx("invalid process id %d", proc_id);
 	}
@@ -1603,6 +1730,37 @@ start_listener(char *argv0, const char *confpath, int 
 	gotd.listen_proc = proc;
 }
 
+static void
+start_notifier(char *argv0, const char *confpath, int daemonize, int verbosity)
+{
+	struct gotd_child_proc *proc;
+
+	proc = calloc(1, sizeof(*proc));
+	if (proc == NULL)
+		fatal("calloc");
+
+	TAILQ_INSERT_HEAD(&procs, proc, entry);
+
+	/* proc->tmo is initialized in main() after event_init() */
+
+	proc->type = PROC_NOTIFY;
+
+	if (socketpair(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK,
+	    PF_UNSPEC, proc->pipe) == -1)
+		fatal("socketpair");
+
+	proc->pid = start_child(proc->type, NULL, argv0, confpath,
+	    proc->pipe[1], daemonize, verbosity);
+	imsg_init(&proc->iev.ibuf, proc->pipe[0]);
+	proc->iev.handler = gotd_dispatch_notifier;
+	proc->iev.events = EV_READ;
+	proc->iev.handler_arg = NULL;
+	event_set(&proc->iev.ev, proc->iev.ibuf.fd, EV_READ,
+	    gotd_dispatch_notifier, &proc->iev);
+
+	gotd.notify_proc = proc;
+}
+
 static const struct got_error *
 start_session_child(struct gotd_client *client, struct gotd_repo *repo,
     char *argv0, const char *confpath, int daemonize, int verbosity)
@@ -1821,6 +1979,25 @@ set_max_datasize(void)
 	setrlimit(RLIMIT_DATA, &rl);
 }
 
+static void
+unveil_notification_helpers(void)
+{
+	const char *helpers[] = {
+	    GOTD_PATH_PROG_NOTIFY_EMAIL,
+	    GOTD_PATH_PROG_NOTIFY_HTTP,
+	};
+	size_t i;
+
+	for (i = 0; i < nitems(helpers); i++) {
+		if (unveil(helpers[i], "x") == 0)
+			continue;
+		fatal("unveil %s", helpers[i]);
+	}
+
+	if (unveil(NULL, NULL) == -1)
+		fatal("unveil");
+}
+
 int
 main(int argc, char **argv)
 {
@@ -1835,12 +2012,16 @@ main(int argc, char **argv)
 	struct event evsigint, evsigterm, evsighup, evsigusr1, evsigchld;
 	int *pack_fds = NULL, *temp_fds = NULL;
 	struct gotd_repo *repo = NULL;
+	char *default_sender = NULL;
+	char hostname[HOST_NAME_MAX + 1];
+	FILE *diff_f1 = NULL, *diff_f2 = NULL;
+	int diff_fd1 = -1, diff_fd2 = -1;
 
 	TAILQ_INIT(&procs);
 
 	log_init(1, LOG_DAEMON); /* Log to stderr until daemonized. */
 
-	while ((ch = getopt(argc, argv, "Adf:LnP:RsSvW")) != -1) {
+	while ((ch = getopt(argc, argv, "Adf:LnNP:RsSvW")) != -1) {
 		switch (ch) {
 		case 'A':
 			proc_id = PROC_AUTH;
@@ -1857,6 +2038,9 @@ main(int argc, char **argv)
 		case 'n':
 			noaction = 1;
 			break;
+		case 'N':
+			proc_id = PROC_NOTIFY;
+			break;
 		case 'P':
 			repo_path = realpath(optarg, NULL);
 			if (repo_path == NULL)
@@ -1926,6 +2110,7 @@ main(int argc, char **argv)
 			fatal("daemon");
 		gotd.pid = getpid();
 		start_listener(argv0, confpath, daemonize, verbosity);
+		start_notifier(argv0, confpath, daemonize, verbosity);
 	} else if (proc_id == PROC_LISTEN) {
 		snprintf(title, sizeof(title), "%s", gotd_proc_names[proc_id]);
 		if (verbosity) {
@@ -1954,6 +2139,13 @@ main(int argc, char **argv)
 			fatalx("repository path not specified");
 		snprintf(title, sizeof(title), "%s %s",
 		    gotd_proc_names[proc_id], repo_path);
+	} else if (proc_id == PROC_NOTIFY) {
+		snprintf(title, sizeof(title), "%s", gotd_proc_names[proc_id]);
+		if (gethostname(hostname, sizeof(hostname)) == -1)
+			fatal("gethostname");
+		if (asprintf(&default_sender, "%s@%s",
+		    pw->pw_name, hostname) == -1)
+			fatal("asprintf");
 	} else
 		fatal("invalid process id %d", proc_id);
 
@@ -2020,10 +2212,14 @@ main(int argc, char **argv)
 #endif
 		if (proc_id == PROC_SESSION_READ)
 			apply_unveil_repo_readonly(repo_path, 1);
-		else
+		else {
 			apply_unveil_repo_readwrite(repo_path);
+			repo = gotd_find_repo_by_path(repo_path, &gotd);
+			if (repo == NULL)
+				fatalx("no repository for path %s", repo_path);
+		}
 		session_main(title, repo_path, pack_fds, temp_fds,
-		    &gotd.request_timeout, proc_id);
+		    &gotd.request_timeout, repo, proc_id);
 		/* NOTREACHED */
 		break;
 	case PROC_REPO_READ:
@@ -2038,6 +2234,19 @@ main(int argc, char **argv)
 		exit(0);
 	case PROC_REPO_WRITE:
 		set_max_datasize();
+
+		diff_f1 = got_opentemp();
+		if (diff_f1 == NULL)
+			fatal("got_opentemp");
+		diff_f2 = got_opentemp();
+		if (diff_f2 == NULL)
+			fatal("got_opentemp");
+		diff_fd1 = got_opentempfd();
+		if (diff_fd1 == -1)
+			fatal("got_opentempfd");
+		diff_fd2 = got_opentempfd();
+		if (diff_fd2 == -1)
+			fatal("got_opentempfd");
 #ifndef PROFILE
 		if (pledge("stdio rpath recvfd unveil", NULL) == -1)
 			err(1, "pledge");
@@ -2047,11 +2256,25 @@ main(int argc, char **argv)
 		if (repo == NULL)
 			fatalx("no repository for path %s", repo_path);
 		repo_write_main(title, repo_path, pack_fds, temp_fds,
+		    diff_f1, diff_f2, diff_fd1, diff_fd2,
 		    &repo->protected_tag_namespaces,
 		    &repo->protected_branch_namespaces,
 		    &repo->protected_branches);
 		/* NOTREACHED */
 		exit(0);
+	case PROC_NOTIFY:
+#ifndef PROFILE
+		if (pledge("stdio proc exec recvfd unveil", NULL) == -1)
+			err(1, "pledge");
+#endif
+		/*
+		 * Limit "exec" promise to notification helpers via unveil(2).
+		 */
+		unveil_notification_helpers();
+
+		notify_main(title, &gotd.repos, default_sender);
+		/* NOTREACHED */
+		exit(0);
 	default:
 		fatal("invalid process id %d", proc_id);
 	}
@@ -2061,6 +2284,10 @@ main(int argc, char **argv)
 
 	evtimer_set(&gotd.listen_proc->tmo, kill_proc_timeout,
 	    gotd.listen_proc);
+	if (gotd.notify_proc) {
+		evtimer_set(&gotd.notify_proc->tmo, kill_proc_timeout,
+		    gotd.notify_proc);
+	}
 
 	apply_unveil_selfexec();
 
@@ -2078,10 +2305,13 @@ main(int argc, char **argv)
 	signal_add(&evsigchld, NULL);
 
 	gotd_imsg_event_add(&gotd.listen_proc->iev);
+	if (gotd.notify_proc)
+		gotd_imsg_event_add(&gotd.notify_proc->iev);
 
 	event_dispatch();
 
 	free(repo_path);
+	free(default_sender);
 	gotd_shutdown();
 
 	return 0;
blob - 09928aa29395cb1acfaff6303c26cda1adfaf34a
blob + 45a21b3bd385d22ec6ae514d311b7bd4a6753183
--- gotd/gotd.conf.5
+++ gotd/gotd.conf.5
@@ -237,6 +237,132 @@ do not need to be listed in
 .Nm .
 These namespaces are always protected and even attempts to create new
 references in these namespaces will always be denied.
+.It Ic notify Brq Ar ...
+The
+.Ic notify
+directive enables notifications about new commits or tags
+added to the repository.
+.Pp
+Notifications via email require an SMTP daemon which accepts mail
+for forwarding without requiring client authentication or encryption.
+On
+.Ox
+the
+.Xr smtpd 8
+daemon can be used for this purpose.
+The default content of email notifications looks similar to the output of the
+.Cm got log -d
+command.
+.Pp
+.\" Notifications via HTTP require a HTTP or HTTPS server which is accepting
+.\" POST requests with or without HTTP Basic authentication.
+.\" Depending on the use case a custom server-side CGI script may be required
+.\" for the processing of notifications.
+.\" HTTP notifications can achieve functionality
+.\" similar to Git's server-side post-receive hook script with
+.\" .Xr gotd 8
+.\" by triggering arbitrary post-commit actions via the HTTP server.
+.\" .Pp
+The
+.Ic notify
+directive expects parameters which must be enclosed in curly braces.
+The available parameters are as follows:
+.Pp
+.Bl -tag -width Ds
+.It Ic branch Ar name
+Send notifications about commits to the named branch.
+The
+.Ar name
+will be looked up in the
+.Dq refs/heads/
+reference namespace.
+This directive may be specified multiple times to build a list of
+branches to send notifications for.
+If neither a
+.Ic branch
+nor a
+.Ic reference namespace
+are specified then changes to any reference will trigger notifications.
+.It Ic reference Ic namespace Ar namespace
+Send notifications about commits or tags within a reference namespace.
+This directive may be specified multiple times to build a list of
+namespaces to send notifications for.
+If neither a
+.Ic branch
+nor a
+.Ic reference namespace
+are specified then changes to any reference will trigger notifications.
+.It Ic email Oo Ic from Ar sender Oc Ic to Ar recipient Oo Ic reply to Ar responder Oc Oo Ic relay Ar hostname Oo Ic port Ar port Oc Oc
+Send notifications via email to the specified
+.Ar recipient .
+This directive may be specified multiple times to build a list of
+recipients to send notifications to.
+.Pp
+The
+.Ar recipient
+must be an email addresses that accepts mail.
+The
+.Ar sender
+will be used as the From address.
+If not specified, the sender defaults to an email address composed of the user
+account running
+.Xr gotd 8
+and the local hostname.
+.Pp
+If a
+.Ar responder
+is specified via the
+.Ic reply to
+directive, the
+.Ar responder
+will be used as the Reply-to address.
+Setting the Reply-to header can be useful if replies should go to a
+mailing list instead of the
+.Ar sender ,
+for example.
+.Pp
+By default, mail will be sent to the SMTP server listening on the loopback
+address 127.0.0.1 on port 25.
+The
+.Ic relay
+and
+.Ic port
+directives can be used to specify a different SMTP server address and port.
+.Pp
+.\" .It Ic url Ar URL Ic user Ar user Ic password Ar password Oc
+.\" Send notifications via HTTP.
+.\" This directive may be specified multiple times to build a list of
+.\" HTTP servers to send notifications to.
+.\" .Pp
+.\" The notification will be sent as a POST request to the given
+.\" .Ar URL ,
+.\" which must be a valid HTTP URL and begin with either
+.\" .Dq http://
+.\" or
+.\" .Dq https:// .
+.\" If HTTPS is used, sending of notifications will only succeed if
+.\" no TLS errors occur.
+.\" .Pp
+.\" The optional
+.\" .Ic user
+.\" and
+.\" .Ic password
+.\" directives enable HTTP Basic authentication.
+.\" If used, both a
+.\" .Ar user
+.\" and a
+.\" .Ar password
+.\" must be specified.
+.\" The
+.\" .Ar password
+.\" must not be an empty string.
+.\" .Pp
+.\" The request body contains a JSON document with the following objects:
+.\" .Bl -tag -width { "notifications" : array }
+.\" .It { "notifications" : array }
+.\" The top-level object contains an array of all notifications in this request.
+.\" .It TODO ...
+.\" .El
 .El
 .Sh FILES
 .Bl -tag -width Ds -compact
@@ -276,6 +402,13 @@ repository "openbsd/ports" {
 		branch "main"
 		tag namespace "refs/tags/"
 	}
+
+	notify {
+		branch "main"
+		reference namespace "refs/tags/"
+		email to openbsd-ports-changes@example.com
+.\"		url https://example.com/notify/ user "flan_announcer" password "secret"
+	}
 }
 
 # Use a larger request timeout value:
blob - acb40dee8cd351b48669c3c3247c42ed5f44501b
blob + d6d5b413a46c4062f8b7a4a635cd9ffea6d6c49f
--- gotd/gotd.h
+++ gotd/gotd.h
@@ -21,6 +21,23 @@
 #define GOTD_CONF_PATH	"/etc/gotd.conf"
 #define GOTD_EMPTY_PATH	"/var/empty"
 
+#ifndef GOT_LIBEXECDIR
+#define GOT_LIBEXECDIR /usr/libexec
+#endif
+
+#define GOTD_STRINGIFY(x) #x
+#define GOTD_STRINGVAL(x) GOTD_STRINGIFY(x)
+
+#define GOTD_PROG_NOTIFY_EMAIL	got-notify-email
+#define GOTD_PROG_NOTIFY_HTTP	got-notify-http
+
+#define GOTD_PATH_PROG_NOTIFY_EMAIL \
+	GOTD_STRINGVAL(GOT_LIBEXECDIR) "/" \
+	GOTD_STRINGVAL(GOTD_PROG_NOTIFY_EMAIL)
+#define GOTD_PATH_PROG_NOTIFY_HTTP \
+	GOTD_STRINGVAL(GOT_LIBEXECDIR) "/" \
+	GOTD_STRINGVAL(GOTD_PROG_NOTIFY_HTTP)
+
 #define GOTD_MAXCLIENTS		1024
 #define GOTD_MAX_CONN_PER_UID	4
 #define GOTD_FD_RESERVE		5
@@ -41,6 +58,7 @@ enum gotd_procid {
 	PROC_REPO_READ,
 	PROC_REPO_WRITE,
 	PROC_GITWRAPPER,
+	PROC_NOTIFY,
 	PROC_MAX,
 };
 
@@ -70,6 +88,32 @@ struct gotd_access_rule {
 };
 STAILQ_HEAD(gotd_access_rule_list, gotd_access_rule);
 
+enum gotd_notification_target_type {
+	GOTD_NOTIFICATION_VIA_EMAIL,
+	GOTD_NOTIFICATION_VIA_HTTP
+};
+
+struct gotd_notification_target {
+	STAILQ_ENTRY(gotd_notification_target) entry;
+
+	enum gotd_notification_target_type type;
+	union {
+		struct {
+			char *sender;
+			char *recipient;
+			char *responder;
+			char *hostname;
+			char *port;
+		} email;
+		struct {
+			char *url;
+			char *user;
+			char *password;
+		} http;
+	} conf;
+};
+STAILQ_HEAD(gotd_notification_targets, gotd_notification_target);
+
 struct gotd_repo {
 	TAILQ_ENTRY(gotd_repo)	 entry;
 
@@ -80,6 +124,10 @@ struct gotd_repo {
 	struct got_pathlist_head protected_tag_namespaces;
 	struct got_pathlist_head protected_branch_namespaces;
 	struct got_pathlist_head protected_branches;
+
+	struct got_pathlist_head notification_refs;
+	struct got_pathlist_head notification_ref_namespaces;
+	struct gotd_notification_targets notification_targets;
 };
 TAILQ_HEAD(gotd_repolist, gotd_repo);
 
@@ -93,6 +141,7 @@ enum gotd_session_state {
 	GOTD_STATE_EXPECT_PACKFILE,
 	GOTD_STATE_EXPECT_DONE,
 	GOTD_STATE_DONE,
+	GOTD_STATE_NOTIFY,
 };
 
 struct gotd_client_capability {
@@ -120,6 +169,8 @@ struct gotd {
 	struct gotd_repolist repos;
 	int nrepos;
 	struct gotd_child_proc *listen_proc;
+	struct gotd_child_proc *notify_proc;
+	int notifications_enabled;
 	struct timeval request_timeout;
 	struct timeval auth_timeout;
 	struct gotd_uid_connection_limit *connection_limits;
@@ -193,6 +244,12 @@ enum gotd_imsg_type {
 	/* Auth child process. */
 	GOTD_IMSG_AUTHENTICATE,
 	GOTD_IMSG_ACCESS_GRANTED,
+
+	/* Notify child process. */
+	GOTD_IMSG_CONNECT_NOTIFIER,
+	GOTD_IMSG_CONNECT_SESSION,
+	GOTD_IMSG_NOTIFY,
+	GOTD_IMSG_NOTIFICATION_SENT
 };
 
 /* Structure for GOTD_IMSG_ERROR. */
@@ -425,6 +482,9 @@ struct gotd_imsg_connect {
 	uint32_t client_id;
 	uid_t euid;
 	gid_t egid;
+	size_t username_len;
+
+	/* Followed by username_len data bytes. */
 };
 
 /* Structure for GOTD_IMSG_CONNECT_REPO_CHILD. */
@@ -443,12 +503,35 @@ struct gotd_imsg_auth {
 	uint32_t client_id;
 };
 
+/* Structures for GOTD_IMSG_NOTIFY. */
+enum gotd_notification_action {
+	GOTD_NOTIF_ACTION_CREATED,
+	GOTD_NOTIF_ACTION_REMOVED,
+	GOTD_NOTIF_ACTION_CHANGED
+};
+/* IMSG_NOTIFY session <-> repo_write */
+struct gotd_imsg_notification_content {
+	uint32_t client_id;
+	enum gotd_notification_action action;
+	uint8_t old_id[SHA1_DIGEST_LENGTH];
+	uint8_t new_id[SHA1_DIGEST_LENGTH];
+	size_t refname_len;
+	/* Followed by refname_len data bytes. */
+};
+/* IMSG_NOTIFY session -> notify*/
+struct gotd_imsg_notify {
+	char repo_name[NAME_MAX];
+	char subject_line[64];
+};
+
 int parse_config(const char *, enum gotd_procid, struct gotd *);
-struct gotd_repo *gotd_find_repo_by_name(const char *, struct gotd *);
+struct gotd_repo *gotd_find_repo_by_name(const char *, struct gotd_repolist *);
 struct gotd_repo *gotd_find_repo_by_path(const char *, struct gotd *);
 struct gotd_uid_connection_limit *gotd_find_uid_connection_limit(
     struct gotd_uid_connection_limit *limits, size_t nlimits, uid_t uid);
 int gotd_parseuid(const char *s, uid_t *uid);
+const struct got_error *gotd_parse_url(char **, char **, char **,
+    char **, const char *);
 
 /* imsg.c */
 const struct got_error *gotd_imsg_flush(struct imsgbuf *);
blob - /dev/null
blob + 59c09c35e0b26fb30a138fc1dd52d8f994826c32 (mode 644)
--- /dev/null
+++ gotd/libexec/Makefile
@@ -0,0 +1,3 @@
+SUBDIR = got-notify-email
+
+.include <bsd.subdir.mk>
blob - /dev/null
blob + 1bc739e7aa3fede7385d957470cee88dd2460099 (mode 644)
--- /dev/null
+++ gotd/libexec/Makefile.inc
@@ -0,0 +1,7 @@
+.include "../../Makefile.inc"
+
+realinstall:
+	${INSTALL} ${INSTALL_COPY} -o ${BINOWN} -g ${BINGRP} \
+	-m ${BINMODE} ${PROG} ${LIBEXECDIR}/${PROG}
+
+NOMAN = Yes
blob - /dev/null
blob + bc6f702e1ea25327f3545179d493446d81da8948 (mode 644)
--- /dev/null
+++ gotd/libexec/got-notify-email/Makefile
@@ -0,0 +1,20 @@
+.PATH:${.CURDIR}/../..
+.PATH:${.CURDIR}/../../../lib
+
+.include "../../../got-version.mk"
+
+PROG=		got-notify-email
+SRCS=		got-notify-email.c pollfd.c error.c hash.c
+
+
+CPPFLAGS = -I${.CURDIR}/../../../include -I${.CURDIR}/../../../lib
+
+.if defined(PROFILE)
+LDADD = -lutil_p -lz_p -lm_p
+.else
+LDADD = -lutil -lz -lm
+.endif
+
+DPADD = ${LIBZ} ${LIBUTIL}
+
+.include <bsd.prog.mk>
blob - /dev/null
blob + c30df5c843d0b1a5e9409c256587897f8c603997 (mode 644)
--- /dev/null
+++ gotd/libexec/got-notify-email/got-notify-email.c
@@ -0,0 +1,344 @@
+/*
+ * Copyright (c) 2024 Stefan Sperling <stsp@openbsd.org>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+#include <sys/socket.h>
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <stdarg.h>
+#include <getopt.h>
+#include <err.h>
+#include <pwd.h>
+#include <netdb.h>
+#include <time.h>
+#include <unistd.h>
+
+#include "got_error.h"
+
+#include "got_lib_poll.h"
+
+int smtp_timeout = 60; /* in seconds */
+
+__dead static void
+usage(void)
+{
+	fprintf(stderr, "usage: %s [-f sender ] [-r responder] "
+	    "[-s subject] [-h hostname] [-p port] recipient\n", getprogname());
+	exit(1);
+}
+
+static char *
+set_default_fromaddr(void)
+{
+	struct passwd *pw = NULL;
+	char *s;
+	char hostname[255];
+
+	pw = getpwuid(getuid());
+	if (pw == NULL) {
+		errx(1, "my UID %d was not found in password database",
+		    getuid());
+	}
+	
+	if (gethostname(hostname, sizeof(hostname)) == -1)
+		err(1, "gethostname");
+
+	if (asprintf(&s, "%s@%s", pw->pw_name, hostname) == -1)
+		err(1, "asprintf");
+
+	return s;
+}
+
+static int
+read_smtp_code(int s, const char *code)
+{
+	const struct got_error *error;
+	char buf[4];
+	size_t n;
+
+	error = got_poll_read_full_timeout(s, &n, buf, 3, 3, smtp_timeout);
+	if (error)
+		errx(1, "read: %s", error->msg);
+	if (strncmp(buf, code, 3) != 0) {
+		buf[3] = '\0';
+		warnx("unexpected SMTP message code: %s", buf);
+		return -1;
+	}
+
+	return 0;
+}
+
+static int
+skip_to_crlf(int s)
+{
+	const struct got_error *error;
+	char buf[1];
+	size_t len;
+
+	for (;;) {
+		error = got_poll_read_full_timeout(s, &len, buf, 1, 1,
+		    smtp_timeout);
+		if (error)
+			errx(1, "read: %s", error->msg);
+		if (buf[0] == '\r') {
+			error = got_poll_read_full(s, &len, buf, 1, 1);
+			if (error)
+				errx(1, "read: %s", error->msg);
+			if (buf[0] == '\n')
+				return 0;
+		}
+	}
+
+	return -1;
+}
+
+static int
+send_smtp_msg(int s, const char *fmt, ...)
+{
+	const struct got_error *error;
+	char buf[512];
+	int len;
+	va_list ap;
+
+	va_start(ap, fmt);
+	len = vsnprintf(buf, sizeof(buf), fmt, ap);
+	va_end(ap);
+	if (len < 0) {
+		warn("vsnprintf");
+		return -1;
+	}
+	if (len >= sizeof(buf)) {
+		warnx("%s: buffer too small for message '%s...'",
+		    __func__, buf);
+		return -1;
+	}
+
+	error = got_poll_write_full(s, buf, len);
+	if (error) {
+		warnx("write: %s", error->msg);
+		return -1;
+	}
+
+	return 0;
+}
+
+static char *
+get_datestr(time_t *time, char *datebuf)
+{
+	struct tm mytm, *tm;
+	char *p, *s;
+
+	tm = gmtime_r(time, &mytm);
+	if (tm == NULL)
+		return NULL;
+	s = asctime_r(tm, datebuf);
+	if (s == NULL)
+		return NULL;
+	p = strchr(s, '\n');
+	if (p)
+		*p = '\0';
+	return s;
+}
+
+static void
+send_email(const char *myfromaddr, const char *fromaddr,
+    const char *recipient, const char *replytoaddr,
+    const char *subject, const char *hostname, const char *port)
+{
+	const struct got_error *error;
+	char *line = NULL;
+	size_t linesize = 0;
+	ssize_t linelen;
+	struct addrinfo hints, *res = NULL;
+	int s = -1, ret;
+	time_t now;
+	char datebuf[26];
+	char *datestr;
+
+	now = time(NULL);
+	datestr = get_datestr(&now, datebuf);
+
+	memset(&hints, 0, sizeof(hints));
+	hints.ai_family = AF_INET;
+	hints.ai_socktype = SOCK_STREAM;
+
+	ret = getaddrinfo(hostname, port, &hints, &res);
+	if (ret)
+		errx(1, "getaddrinfo: %s", gai_strerror(ret));
+
+	s = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
+	if (s == -1)
+		err(1, "socket");
+
+	if (connect(s, res->ai_addr, res->ai_addrlen) == -1)
+		err(1, "connect 127.0.0.1:25");
+
+	if (read_smtp_code(s, "220"))
+		errx(1, "unexpected SMTP greeting received");
+	if (skip_to_crlf(s))
+		errx(1, "invalid SMTP message received");
+
+	if (send_smtp_msg(s, "HELO localhost\r\n"))
+		errx(1, "could not send HELO");
+	if (read_smtp_code(s, "250"))
+		errx(1, "unexpected SMTP response received");
+	if (skip_to_crlf(s))
+		errx(1, "invalid SMTP message received");
+
+	if (send_smtp_msg(s, "MAIL FROM:<%s>\r\n", myfromaddr))
+		errx(1, "could not send MAIL FROM");
+	if (read_smtp_code(s, "250"))
+		errx(1, "unexpected SMTP response received");
+	if (skip_to_crlf(s))
+		errx(1, "invalid SMTP message received");
+
+	if (send_smtp_msg(s, "RCPT TO:<%s>\r\n", recipient))
+		errx(1, "could not send MAIL FROM");
+	if (read_smtp_code(s, "250"))
+		errx(1, "unexpected SMTP response received");
+	if (skip_to_crlf(s))
+		errx(1, "invalid SMTP message received");
+
+	if (send_smtp_msg(s, "DATA\r\n"))
+		errx(1, "could not send MAIL FROM");
+	if (read_smtp_code(s, "354"))
+		errx(1, "unexpected SMTP response received");
+	if (skip_to_crlf(s))
+		errx(1, "invalid SMTP message received");
+
+	if (send_smtp_msg(s, "From: %s\r\n", fromaddr))
+		errx(1, "could not send From header");
+	if (send_smtp_msg(s, "To: %s\r\n", recipient))
+		errx(1, "could not send To header");
+	if (replytoaddr) {
+		if (send_smtp_msg(s, "Reply-To: %s\r\n", replytoaddr))
+			errx(1, "could not send Reply-To header");
+	}
+	if (send_smtp_msg(s, "Date: %s +0000 (UTC)\r\n", datestr))
+		errx(1, "could not send Date header");
+
+	if (send_smtp_msg(s, "Subject: %s\r\n", subject))
+		errx(1, "could not send Subject header");
+
+	if (send_smtp_msg(s, "\r\n"))
+		errx(1, "could not send body delimiter");
+
+	while ((linelen = getline(&line, &linesize, stdin)) != -1) {
+		if (line[0] == '.') { /* dot stuffing */
+			error = got_poll_write_full(s, ".", 1);
+			if (error)
+				errx(1, "write: %s", error->msg);
+		}
+		error = got_poll_write_full(s, line, linelen);
+		if (error)
+			errx(1, "write: %s", error->msg);
+	}
+
+	if (send_smtp_msg(s, "\r\n.\r\n"))
+		errx(1, "could not send data terminator");
+	if (read_smtp_code(s, "250"))
+		errx(1, "unexpected SMTP response received");
+	if (skip_to_crlf(s))
+		errx(1, "invalid SMTP message received");
+
+	if (send_smtp_msg(s, "QUIT\r\n"))
+		errx(1, "could not send QUIT");
+
+	if (read_smtp_code(s, "221"))
+		errx(1, "unexpected SMTP response received");
+	if (skip_to_crlf(s))
+		errx(1, "invalid SMTP message received");
+
+	close(s);
+	free(line);
+	if (res)
+		freeaddrinfo(res);
+}
+
+int
+main(int argc, char *argv[])
+{
+	char *default_fromaddr = NULL;
+	const char *fromaddr = NULL, *recipient = NULL, *replytoaddr = NULL;
+	const char *subject = "gotd notification";
+	const char *hostname = "127.0.0.1";
+	const char *port = "25";
+	const char *errstr;
+	char *timeoutstr;
+	int ch;
+
+	while ((ch = getopt(argc, argv, "f:r:s:h:p:")) != -1) {
+		switch (ch) {
+		case 'h':
+			hostname = optarg;
+			break;
+		case 'f':
+			fromaddr = optarg;
+			break;
+		case 'p':
+			port = optarg;
+			break;
+		case 'r':
+			replytoaddr = optarg;
+			break;
+		case 's':
+			subject = optarg;
+			break;
+		default:
+			usage();
+			/* NOTREACHED */
+			break;
+		}
+	}
+
+	argc -= optind;
+	argv += optind;
+
+	if (argc != 1)
+		usage();
+
+	/* used by the regression test suite */
+	timeoutstr = getenv("GOT_NOTIFY_EMAIL_TIMEOUT");
+	if (timeoutstr) {
+		smtp_timeout = strtonum(timeoutstr, 0, 600, &errstr); 
+		if (errstr != NULL)
+			errx(1, "timeout in seconds is %s: %s",
+			    errstr, timeoutstr);
+	}
+
+#ifndef PROFILE
+	if (pledge("stdio dns inet getpw", NULL) == -1)
+		err(1, "pledge");
+#endif
+	default_fromaddr = set_default_fromaddr();
+
+#ifndef PROFILE
+	if (pledge("stdio dns inet", NULL) == -1)
+		err(1, "pledge");
+#endif
+
+	recipient = argv[0];
+	if (fromaddr == NULL)
+		fromaddr = default_fromaddr;
+
+	send_email(default_fromaddr, fromaddr, recipient, replytoaddr,
+	    subject, hostname, port);
+
+	free(default_fromaddr);
+	return 0;
+}
blob - 58db764d105aff68e65237083482558bcf17fb47
blob + 4792873a2f7cc5b8b83fde7afc7b5a1a7f269d58
--- gotd/parse.y
+++ gotd/parse.y
@@ -73,6 +73,7 @@ int		 lookup(char *);
 int		 lgetc(int);
 int		 lungetc(int);
 int		 findeol(void);
+static char	*port_sprintf(int);
 
 TAILQ_HEAD(symhead, sym)	 symhead = TAILQ_HEAD_INITIALIZER(symhead);
 struct sym {
@@ -102,6 +103,14 @@ static int			 conf_protect_branch_namespace(
 				    struct gotd_repo *, char *);
 static int			 conf_protect_branch(struct gotd_repo *,
 				    char *);
+static int			 conf_notify_branch(struct gotd_repo *,
+				    char *);
+static int			 conf_notify_ref_namespace(struct gotd_repo *,
+				    char *);
+static int			 conf_notify_email(struct gotd_repo *,
+				    char *, char *, char *, char *, char *);
+static int			 conf_notify_http(struct gotd_repo *,
+				    char *, char *, char *);
 static enum gotd_procid		 gotd_proc_id;
 
 typedef struct {
@@ -117,7 +126,8 @@ typedef struct {
 
 %token	PATH ERROR LISTEN ON USER REPOSITORY PERMIT DENY
 %token	RO RW CONNECTION LIMIT REQUEST TIMEOUT
-%token	PROTECT NAMESPACE BRANCH TAG
+%token	PROTECT NAMESPACE BRANCH TAG REFERENCE RELAY PORT
+%token	NOTIFY EMAIL FROM REPLY TO URL PASSWORD
 
 %token	<v.string>	STRING
 %token	<v.number>	NUMBER
@@ -299,6 +309,320 @@ protectflags	: TAG NAMESPACE STRING {
 		}
 		;
 
+notify		: NOTIFY '{' optnl notifyflags_l '}'
+		| NOTIFY notifyflags
+
+notifyflags_l	: notifyflags optnl notifyflags_l
+		| notifyflags optnl
+		;
+
+notifyflags	: BRANCH STRING {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_NOTIFY) {
+				if (conf_notify_branch(new_repo, $2)) {
+					free($2);
+					YYERROR;
+				}
+				free($2);
+			}
+		}
+		| REFERENCE NAMESPACE STRING {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_NOTIFY) {
+				if (conf_notify_ref_namespace(new_repo, $3)) {
+					free($3);
+					YYERROR;
+				}
+				free($3);
+			}
+		}
+		| EMAIL TO STRING {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_NOTIFY) {
+				if (conf_notify_email(new_repo, NULL, $3,
+				    NULL, NULL, NULL)) {
+					free($3);
+					YYERROR;
+				}
+				free($3);
+			}
+		}
+		| EMAIL FROM STRING TO STRING {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_NOTIFY) {
+				if (conf_notify_email(new_repo, $3, $5,
+				    NULL, NULL, NULL)) {
+					free($3);
+					free($5);
+					YYERROR;
+				}
+				free($3);
+				free($5);
+			}
+		}
+		| EMAIL TO STRING REPLY TO STRING {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_NOTIFY) {
+				if (conf_notify_email(new_repo, NULL, $3,
+				    $6, NULL, NULL)) {
+					free($3);
+					free($6);
+					YYERROR;
+				}
+				free($3);
+				free($6);
+			}
+		}
+		| EMAIL FROM STRING TO STRING REPLY TO STRING {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_NOTIFY) {
+				if (conf_notify_email(new_repo, $3, $5,
+				    $8, NULL, NULL)) {
+					free($3);
+					free($5);
+					free($8);
+					YYERROR;
+				}
+				free($3);
+				free($5);
+				free($8);
+			}
+		}
+		| EMAIL TO STRING RELAY STRING {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_NOTIFY) {
+				if (conf_notify_email(new_repo, NULL, $3,
+				    NULL, $5, NULL)) {
+					free($3);
+					free($5);
+					YYERROR;
+				}
+				free($3);
+				free($5);
+			}
+		}
+		| EMAIL FROM STRING TO STRING RELAY STRING {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_NOTIFY) {
+				if (conf_notify_email(new_repo, $3, $5,
+				    NULL, $7, NULL)) {
+					free($3);
+					free($5);
+					free($7);
+					YYERROR;
+				}
+				free($3);
+				free($5);
+				free($7);
+			}
+		}
+		| EMAIL TO STRING REPLY TO STRING RELAY STRING {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_NOTIFY) {
+				if (conf_notify_email(new_repo, NULL, $3,
+				    $6, $8, NULL)) {
+					free($3);
+					free($6);
+					free($8);
+					YYERROR;
+				}
+				free($3);
+				free($6);
+				free($8);
+			}
+		}
+		| EMAIL FROM STRING TO STRING REPLY TO STRING RELAY STRING {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_NOTIFY) {
+				if (conf_notify_email(new_repo, $3, $5,
+				    $8, $10, NULL)) {
+					free($3);
+					free($5);
+					free($8);
+					free($10);
+					YYERROR;
+				}
+				free($3);
+				free($5);
+				free($8);
+				free($10);
+			}
+		}
+		| EMAIL TO STRING RELAY STRING PORT STRING {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_NOTIFY) {
+				if (conf_notify_email(new_repo, NULL, $3,
+				    NULL, $5, $7)) {
+					free($3);
+					free($5);
+					free($7);
+					YYERROR;
+				}
+				free($3);
+				free($5);
+				free($7);
+			}
+		}
+		| EMAIL FROM STRING TO STRING RELAY STRING PORT STRING {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_NOTIFY) {
+				if (conf_notify_email(new_repo, $3, $5,
+				    NULL, $7, $9)) {
+					free($3);
+					free($5);
+					free($7);
+					free($9);
+					YYERROR;
+				}
+				free($3);
+				free($5);
+				free($7);
+				free($9);
+			}
+		}
+		| EMAIL TO STRING REPLY TO STRING RELAY STRING PORT STRING {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_NOTIFY) {
+				if (conf_notify_email(new_repo, NULL, $3,
+				    $6, $8, $10)) {
+					free($3);
+					free($6);
+					free($8);
+					free($10);
+					YYERROR;
+				}
+				free($3);
+				free($6);
+				free($8);
+				free($10);
+			}
+		}
+		| EMAIL FROM STRING TO STRING REPLY TO STRING RELAY STRING PORT STRING {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_NOTIFY) {
+				if (conf_notify_email(new_repo, $3, $5,
+				    $8, $10, $12)) {
+					free($3);
+					free($5);
+					free($8);
+					free($10);
+					free($12);
+					YYERROR;
+				}
+				free($3);
+				free($5);
+				free($8);
+				free($10);
+				free($12);
+			}
+		}
+		| EMAIL TO STRING RELAY STRING PORT NUMBER {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_NOTIFY) {
+				if (conf_notify_email(new_repo, NULL, $3,
+				    NULL, $5, port_sprintf($7))) {
+					free($3);
+					free($5);
+					YYERROR;
+				}
+				free($3);
+				free($5);
+			}
+		}
+		| EMAIL FROM STRING TO STRING RELAY STRING PORT NUMBER {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_NOTIFY) {
+				if (conf_notify_email(new_repo, $3, $5,
+				    NULL, $7, port_sprintf($9))) {
+					free($3);
+					free($5);
+					free($7);
+					YYERROR;
+				}
+				free($3);
+				free($5);
+				free($7);
+			}
+		}
+		| EMAIL TO STRING REPLY TO STRING RELAY STRING PORT NUMBER {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_NOTIFY) {
+				if (conf_notify_email(new_repo, NULL, $3,
+				    $6, $8, port_sprintf($10))) {
+					free($3);
+					free($6);
+					free($8);
+					YYERROR;
+				}
+				free($3);
+				free($6);
+				free($8);
+			}
+		}
+		| EMAIL FROM STRING TO STRING REPLY TO STRING RELAY STRING PORT NUMBER {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_NOTIFY) {
+				if (conf_notify_email(new_repo, $3, $5,
+				    $8, $10, port_sprintf($12))) {
+					free($3);
+					free($5);
+					free($8);
+					free($10);
+					YYERROR;
+				}
+				free($3);
+				free($5);
+				free($8);
+				free($10);
+			}
+		}
+		| URL STRING {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_NOTIFY) {
+				if (conf_notify_http(new_repo, $2, NULL,
+				    NULL)) {
+					free($2);
+					YYERROR;
+				}
+				free($2);
+			}
+		}
+		| URL STRING USER STRING PASSWORD STRING {
+			if (gotd_proc_id == PROC_GOTD ||
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_NOTIFY) {
+				if (conf_notify_http(new_repo, $2, $4, $6)) {
+					free($2);
+					free($4);
+					free($6);
+					YYERROR;
+				}
+				free($2);
+				free($4);
+				free($6);
+			}
+		}
+		;
+	
 repository	: REPOSITORY STRING {
 			struct gotd_repo *repo;
 
@@ -313,7 +637,9 @@ repository	: REPOSITORY STRING {
 			if (gotd_proc_id == PROC_GOTD ||
 			    gotd_proc_id == PROC_AUTH ||
 			    gotd_proc_id == PROC_REPO_WRITE ||
-			    gotd_proc_id == PROC_GITWRAPPER) {
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_GITWRAPPER |
+			    gotd_proc_id == PROC_NOTIFY) {
 				new_repo = conf_new_repo($2);
 			}
 			free($2);
@@ -325,7 +651,9 @@ repoopts1	: PATH STRING {
 			if (gotd_proc_id == PROC_GOTD ||
 			    gotd_proc_id == PROC_AUTH ||
 			    gotd_proc_id == PROC_REPO_WRITE ||
-			    gotd_proc_id == PROC_GITWRAPPER) {
+			    gotd_proc_id == PROC_SESSION_WRITE ||
+			    gotd_proc_id == PROC_GITWRAPPER ||
+			    gotd_proc_id == PROC_NOTIFY) {
 				if (!got_path_is_absolute($2)) {
 					yyerror("%s: path %s is not absolute",
 					    __func__, $2);
@@ -385,6 +713,7 @@ repoopts1	: PATH STRING {
 				free($2);
 		}
 		| protect
+		| notify
 		;
 
 repoopts2	: repoopts2 repoopts1 nl
@@ -435,19 +764,29 @@ lookup(char *s)
 		{ "branch",			BRANCH },
 		{ "connection",			CONNECTION },
 		{ "deny",			DENY },
+		{ "email",			EMAIL },
+		{ "from",			FROM },
 		{ "limit",			LIMIT },
 		{ "listen",			LISTEN },
 		{ "namespace",			NAMESPACE },
+		{ "notify",			NOTIFY },
 		{ "on",				ON },
+		{ "password",			PASSWORD },
 		{ "path",			PATH },
 		{ "permit",			PERMIT },
+		{ "port",			PORT },
 		{ "protect",			PROTECT },
+		{ "reference",			REFERENCE },
+		{ "relay",			RELAY },
+		{ "reply",			REPLY },
 		{ "repository",			REPOSITORY },
 		{ "request",			REQUEST },
 		{ "ro",				RO },
 		{ "rw",				RW },
 		{ "tag",			TAG },
 		{ "timeout",			TIMEOUT },
+		{ "to",				TO },
+		{ "url",			URL },
 		{ "user",			USER },
 	};
 	const struct keywords *p;
@@ -919,6 +1258,10 @@ conf_new_repo(const char *name)
 	TAILQ_INIT(&repo->protected_tag_namespaces);
 	TAILQ_INIT(&repo->protected_branch_namespaces);
 	TAILQ_INIT(&repo->protected_branches);
+	TAILQ_INIT(&repo->protected_branches);
+	TAILQ_INIT(&repo->notification_refs);
+	TAILQ_INIT(&repo->notification_ref_namespaces);
+	STAILQ_INIT(&repo->notification_targets);
 
 	if (strlcpy(repo->name, name, sizeof(repo->name)) >=
 	    sizeof(repo->name))
@@ -1075,6 +1418,187 @@ conf_protect_branch(struct gotd_repo *repo, char *bran
 	return 0;
 }
 
+static int
+conf_notify_branch(struct gotd_repo *repo, char *branchname)
+{
+	const struct got_error *error;
+	struct got_pathlist_entry *pe;
+	char *refname;
+
+	if (strncmp(branchname, "refs/heads/", 11) != 0) {
+		if (asprintf(&refname, "refs/heads/%s", branchname) == -1) {
+			yyerror("asprintf: %s", strerror(errno));
+			return -1;
+		}
+	} else {
+		refname = strdup(branchname);
+		if (refname == NULL) {
+			yyerror("strdup: %s", strerror(errno));
+			return -1;
+		}
+	}
+
+	if (!refname_is_valid(refname)) {
+		free(refname);
+		return -1;
+	}
+
+	error = got_pathlist_insert(&pe, &repo->notification_refs,
+	    refname, NULL);
+	if (error) {
+		free(refname);
+		yyerror("got_pathlist_insert: %s", error->msg);
+		return -1;
+	}
+	if (pe == NULL)
+		free(refname);
+
+	return 0;
+}
+
+static int
+conf_notify_ref_namespace(struct gotd_repo *repo, char *namespace)
+{
+	const struct got_error *error;
+	struct got_pathlist_entry *pe;
+	char *s;
+
+	got_path_strip_trailing_slashes(namespace);
+	if (!refname_is_valid(namespace))
+		return -1;
+
+	if (asprintf(&s, "%s/", namespace) == -1) {
+		yyerror("asprintf: %s", strerror(errno));
+		return -1;
+	}
+
+	error = got_pathlist_insert(&pe, &repo->notification_ref_namespaces,
+	    s, NULL);
+	if (error) {
+		free(s);
+		yyerror("got_pathlist_insert: %s", error->msg);
+		return -1;
+	}
+	if (pe == NULL)
+		free(s);
+
+	return 0;
+}
+
+static int
+conf_notify_email(struct gotd_repo *repo, char *sender, char *recipient,
+    char *responder, char *hostname, char *port)
+{
+	struct gotd_notification_target *target;
+
+	STAILQ_FOREACH(target, &repo->notification_targets, entry) {
+		if (target->type != GOTD_NOTIFICATION_VIA_EMAIL)
+			continue;
+		if (strcmp(target->conf.email.recipient, recipient) == 0) {
+			yyerror("duplicate email notification for '%s' in "
+			    "repository '%s'", recipient, repo->name);
+			return -1;
+		}
+	}
+
+	target = calloc(1, sizeof(*target));
+	if (target == NULL)
+		fatal("calloc");
+	target->type = GOTD_NOTIFICATION_VIA_EMAIL;
+	if (sender) {
+		target->conf.email.sender = strdup(sender);
+		if (target->conf.email.sender == NULL)
+			fatal("strdup");
+	}
+	target->conf.email.recipient = strdup(recipient);
+	if (target->conf.email.recipient == NULL)
+		fatal("strdup");
+	if (responder) {
+		target->conf.email.responder = strdup(responder);
+		if (target->conf.email.responder == NULL)
+			fatal("strdup");
+	}
+	if (hostname) {
+		target->conf.email.hostname = strdup(hostname);
+		if (target->conf.email.hostname == NULL)
+			fatal("strdup");
+	}
+	if (port) {
+		target->conf.email.port = strdup(port);
+		if (target->conf.email.port == NULL)
+			fatal("strdup");
+	}
+
+	STAILQ_INSERT_TAIL(&repo->notification_targets, target, entry);
+	return 0;
+}
+
+static int
+conf_notify_http(struct gotd_repo *repo, char *url, char *user, char *password)
+{
+	const struct got_error *error;
+	struct gotd_notification_target *target;
+	char *proto, *host, *port, *request_path;
+	int ret = 0;
+
+	error = gotd_parse_url(&proto, &host, &port, &request_path, url);
+	if (error) {
+		yyerror("invalid HTTP notification URL '%s' in "
+		    "repository '%s': %s", url, repo->name, error->msg);
+		return -1;
+	}
+
+	if (strcmp(proto, "http") != 0 && strcmp(proto, "https") != 0) {
+		yyerror("invalid protocol '%s' in notification URL '%s' in "
+		    "repository '%s", proto, url, repo->name);
+		ret = -1;
+		goto done;
+	}
+
+	if (strcmp(proto, "http") == 0 && (user != NULL || password != NULL)) {
+		log_warnx("%s: WARNING: Using basic authentication over "
+		    "plaintext http:// will leak credentials; https:// is "
+		    "recommended for URL '%s'", getprogname(), url);
+	}
+
+	STAILQ_FOREACH(target, &repo->notification_targets, entry) {
+		if (target->type != GOTD_NOTIFICATION_VIA_HTTP)
+			continue;
+		if (strcmp(target->conf.http.url, url) == 0) {
+			yyerror("duplicate notification for URL '%s' in "
+			    "repository '%s'", url, repo->name);
+			ret = -1;
+			goto done;
+		}
+	}
+
+	target = calloc(1, sizeof(*target));
+	if (target == NULL)
+		fatal("calloc");
+	target->type = GOTD_NOTIFICATION_VIA_HTTP;
+	target->conf.http.url = strdup(url);
+	if (target->conf.http.url == NULL)
+		fatal("calloc");
+	if (user) {
+		target->conf.http.user = strdup(user);
+		if (target->conf.http.user == NULL)
+			fatal("calloc");
+	}	
+	if (password) {
+		target->conf.http.password = strdup(password);
+		if (target->conf.http.password == NULL)
+			fatal("calloc");
+	}	
+
+	STAILQ_INSERT_TAIL(&repo->notification_targets, target, entry);
+done:
+	free(proto);
+	free(host);
+	free(port);
+	free(request_path);
+	return ret;
+}
+
 int
 symset(const char *nam, const char *val, int persist)
 {
@@ -1131,12 +1655,12 @@ symget(const char *nam)
 }
 
 struct gotd_repo *
-gotd_find_repo_by_name(const char *repo_name, struct gotd *gotd)
+gotd_find_repo_by_name(const char *repo_name, struct gotd_repolist *repos)
 {
 	struct gotd_repo *repo;
 	size_t namelen;
 
-	TAILQ_FOREACH(repo, &gotd->repos, entry) {
+	TAILQ_FOREACH(repo, repos, entry) {
 		namelen = strlen(repo->name);
 		if (strncmp(repo->name, repo_name, namelen) != 0)
 			continue;
@@ -1198,3 +1722,100 @@ gotd_parseuid(const char *s, uid_t *uid)
 		return -1;
 	return 0;
 }
+
+const struct got_error *
+gotd_parse_url(char **proto, char **host, char **port,
+    char **request_path, const char *url)
+{
+	const struct got_error *err = NULL;
+	char *s, *p, *q;
+
+	*proto = *host = *port = *request_path = NULL;
+
+	p = strstr(url, "://");
+	if (!p)
+		return got_error(GOT_ERR_PARSE_URI);
+
+	*proto = strndup(url, p - url);
+	if (*proto == NULL) {
+		err = got_error_from_errno("strndup");
+		goto done;
+	}
+	s = p + 3;
+
+	p = strstr(s, "/");
+	if (p == NULL || strlen(p) == 1) {
+		err = got_error(GOT_ERR_PARSE_URI);
+		goto done;
+	}
+
+	q = memchr(s, ':', p - s);
+	if (q) {
+		*host = strndup(s, q - s);
+		if (*host == NULL) {
+			err = got_error_from_errno("strndup");
+			goto done;
+		}
+		if ((*host)[0] == '\0') {
+			err = got_error(GOT_ERR_PARSE_URI);
+			goto done;
+		}
+		*port = strndup(q + 1, p - (q + 1));
+		if (*port == NULL) {
+			err = got_error_from_errno("strndup");
+			goto done;
+		}
+		if ((*port)[0] == '\0') {
+			err = got_error(GOT_ERR_PARSE_URI);
+			goto done;
+		}
+	} else {
+		*host = strndup(s, p - s);
+		if (*host == NULL) {
+			err = got_error_from_errno("strndup");
+			goto done;
+		}
+		if ((*host)[0] == '\0') {
+			err = got_error(GOT_ERR_PARSE_URI);
+			goto done;
+		}
+	}
+
+	while (p[0] == '/' && p[1] == '/')
+		p++;
+	*request_path = strdup(p);
+	if (*request_path == NULL) {
+		err = got_error_from_errno("strdup");
+		goto done;
+	}
+	got_path_strip_trailing_slashes(*request_path);
+	if ((*request_path)[0] == '\0') {
+		err = got_error(GOT_ERR_PARSE_URI);
+		goto done;
+	}
+done:
+	if (err) {
+		free(*proto);
+		*proto = NULL;
+		free(*host);
+		*host = NULL;
+		free(*port);
+		*port = NULL;
+		free(*request_path);
+		*request_path = NULL;
+	}
+	return err;
+}
+
+static char *
+port_sprintf(int p)
+{
+	static char portno[32];
+	int n;
+
+	n = snprintf(portno, sizeof(portno), "%lld", (long long)p);
+	if (n < 0 || (size_t)n >= sizeof(portno))
+		fatalx("port number too long: %lld", (long long)p);
+
+	return portno;
+}
blob - e5c21b47893334094185748ac154995dee4065d6
blob + a061cd0784026046d9eed1bc09c78760626ce3e5
--- gotd/privsep_stub.c
+++ gotd/privsep_stub.c
@@ -64,3 +64,11 @@ got_privsep_init_pack_child(struct imsgbuf *ibuf, stru
 {
 	return got_error(GOT_ERR_NOT_IMPL);
 }
+
+const struct got_error *
+got_traverse_packed_commits(struct got_object_id_queue *traversed_commits,
+    struct got_object_id *commit_id, const char *path,
+    struct got_repository *repo)
+{
+	return NULL;
+}
blob - /dev/null
blob + 4a2d3fb31da4f66c4119939a8f0418199b202367 (mode 644)
--- /dev/null
+++ gotd/notify.c
@@ -0,0 +1,522 @@
+/*
+ * Copyright (c) Stefan Sperling <stsp@openbsd.org>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <sys/types.h>
+#include <sys/queue.h>
+#include <sys/tree.h>
+#include <sys/socket.h>
+#include <sys/wait.h>
+
+#include <errno.h>
+#include <event.h>
+#include <siphash.h>
+#include <limits.h>
+#include <sha1.h>
+#include <sha2.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <imsg.h>
+#include <unistd.h>
+
+#include "got_error.h"
+#include "got_path.h"
+
+#include "gotd.h"
+#include "log.h"
+#include "notify.h"
+
+#ifndef nitems
+#define nitems(_a)	(sizeof((_a)) / sizeof((_a)[0]))
+#endif
+
+static struct gotd_notify {
+	pid_t pid;
+	const char *title;
+	struct gotd_imsgev parent_iev;
+	struct gotd_repolist *repos;
+	const char *default_sender;
+} gotd_notify;
+
+struct gotd_notify_session {
+	STAILQ_ENTRY(gotd_notify_session) entry;
+	uint32_t id;
+	struct gotd_imsgev iev;
+};
+STAILQ_HEAD(gotd_notify_sessions, gotd_notify_session);
+
+static struct gotd_notify_sessions gotd_notify_sessions[GOTD_CLIENT_TABLE_SIZE];
+static SIPHASH_KEY sessions_hash_key;
+
+static void gotd_notify_shutdown(void);
+
+static uint64_t
+session_hash(uint32_t session_id)
+{
+	return SipHash24(&sessions_hash_key, &session_id, sizeof(session_id));
+}
+
+static void
+add_session(struct gotd_notify_session *session)
+{
+	uint64_t slot;
+
+	slot = session_hash(session->id) % nitems(gotd_notify_sessions);
+	STAILQ_INSERT_HEAD(&gotd_notify_sessions[slot], session, entry);
+}
+
+static struct gotd_notify_session *
+find_session(uint32_t session_id)
+{
+	uint64_t slot;
+	struct gotd_notify_session *s;
+
+	slot = session_hash(session_id) % nitems(gotd_notify_sessions);
+	STAILQ_FOREACH(s, &gotd_notify_sessions[slot], entry) {
+		if (s->id == session_id)
+			return s;
+	}
+
+	return NULL;
+}
+
+static struct gotd_notify_session *
+find_session_by_fd(int fd)
+{
+	uint64_t slot;
+	struct gotd_notify_session *s;
+
+	for (slot = 0; slot < nitems(gotd_notify_sessions); slot++) {
+		STAILQ_FOREACH(s, &gotd_notify_sessions[slot], entry) {
+			if (s->iev.ibuf.fd == fd)
+				return s;
+		}
+
+	}
+
+	return NULL;
+}
+
+
+static void
+remove_session(struct gotd_notify_session *session)
+{
+	uint64_t slot;
+
+	slot = session_hash(session->id) % nitems(gotd_notify_sessions);
+	STAILQ_REMOVE(&gotd_notify_sessions[slot], session,
+	    gotd_notify_session, entry);
+	free(session);
+}
+
+static uint32_t
+get_session_id(void)
+{
+	int duplicate = 0;
+	uint32_t id;
+
+	do {
+		id = arc4random();
+		duplicate = (find_session(id) != NULL);
+	} while (duplicate || id == 0);
+
+	return id;
+}
+
+static void
+gotd_notify_sighdlr(int sig, short event, void *arg)
+{
+	/*
+	 * Normal signal handler rules don't apply because libevent
+	 * decouples for us.
+	 */
+
+	switch (sig) {
+	case SIGHUP:
+		log_info("%s: ignoring SIGHUP", __func__);
+		break;
+	case SIGUSR1:
+		log_info("%s: ignoring SIGUSR1", __func__);
+		break;
+	case SIGTERM:
+	case SIGINT:
+		gotd_notify_shutdown();
+		/* NOTREACHED */
+		break;
+	default:
+		fatalx("unexpected signal");
+	}
+}
+
+static void
+run_notification_helper(const char *prog, const char **argv, int fd)
+{
+	const struct got_error *err = NULL;
+	pid_t pid;
+	int child_status;
+
+	pid = fork();
+	if (pid == -1) {
+		err = got_error_from_errno("fork");
+		log_warn("%s", err->msg);
+		return;
+	} else if (pid == 0) {
+		signal(SIGQUIT, SIG_DFL);
+		signal(SIGINT, SIG_DFL);
+		signal(SIGCHLD, SIG_DFL);
+
+		if (dup2(fd, STDIN_FILENO) == -1) {
+			fprintf(stderr, "%s: dup2: %s\n", getprogname(),
+			    strerror(errno));
+			_exit(1);
+		}
+
+		closefrom(STDERR_FILENO + 1);
+
+		if (execv(prog, (char *const *)argv) == -1) {
+			fprintf(stderr, "%s: exec %s: %s\n", getprogname(),
+			    prog, strerror(errno));
+			_exit(1);
+		}
+
+		/* not reached */
+	}
+
+	if (waitpid(pid, &child_status, 0) == -1) {
+		err = got_error_from_errno("waitpid");
+		goto done;
+	}
+
+	if (!WIFEXITED(child_status)) {
+		err = got_error(GOT_ERR_PRIVSEP_DIED);
+		goto done;
+	}
+
+	if (WEXITSTATUS(child_status) != 0)
+		err = got_error(GOT_ERR_PRIVSEP_EXIT);
+done:
+	if (err)
+		log_warnx("%s: child %s pid %d: %s", gotd_notify.title,
+		    prog, pid, err->msg);
+}
+
+static void
+notify_email(struct gotd_notification_target *target, const char *subject_line,
+    int fd)
+{
+	const char *argv[13];
+	int i = 0;
+
+	argv[i++] = GOTD_PATH_PROG_NOTIFY_EMAIL;
+
+	argv[i++] = "-f";
+	if (target->conf.email.sender)
+		argv[i++] = target->conf.email.sender;
+	else
+		argv[i++] = gotd_notify.default_sender;
+
+	if (target->conf.email.responder) {
+		argv[i++] = "-r";
+		argv[i++] = target->conf.email.responder;
+	}
+
+	if (target->conf.email.hostname) {
+		argv[i++] = "-h";
+		argv[i++] = target->conf.email.hostname;
+	}
+	
+	if (target->conf.email.port) {
+		argv[i++] = "-p";
+		argv[i++] = target->conf.email.port;
+	}
+
+	argv[i++] = "-s";
+	argv[i++] = subject_line;
+
+	argv[i++] = target->conf.email.recipient;
+
+	argv[i] = NULL;
+
+	run_notification_helper(GOTD_PATH_PROG_NOTIFY_EMAIL, argv, fd);
+}
+
+static void
+notify_http(struct gotd_notification_target *target, const char *subject_line,
+    int fd)
+{
+	const char *argv[10] = { 0 }; /* TODO */
+
+	run_notification_helper(GOTD_PATH_PROG_NOTIFY_HTTP, argv, fd);
+}
+
+static const struct got_error *
+send_notification(struct imsg *imsg, struct gotd_imsgev *iev)
+{
+	const struct got_error *err = NULL;
+	struct gotd_imsg_notify inotify;
+	size_t datalen;
+	struct gotd_repo *repo;
+	struct gotd_notification_target *target;
+	int fd;
+
+	datalen = imsg->hdr.len - IMSG_HEADER_SIZE;
+	if (datalen != sizeof(inotify))
+		return got_error(GOT_ERR_PRIVSEP_LEN);
+
+	memcpy(&inotify, imsg->data, datalen);
+
+	repo = gotd_find_repo_by_name(inotify.repo_name, gotd_notify.repos);
+	if (repo == NULL)
+		return got_error(GOT_ERR_PRIVSEP_MSG);
+
+	fd = imsg_get_fd(imsg);
+	if (fd == -1)
+		return got_error(GOT_ERR_PRIVSEP_NO_FD);
+
+	if (lseek(fd, 0, SEEK_SET) == -1) {
+		err = got_error_from_errno("lseek");
+		goto done;
+	}
+
+	STAILQ_FOREACH(target, &repo->notification_targets, entry) {
+		switch (target->type) {
+		case GOTD_NOTIFICATION_VIA_EMAIL:
+			notify_email(target, inotify.subject_line, fd);
+			break;
+		case GOTD_NOTIFICATION_VIA_HTTP:
+			notify_http(target, inotify.subject_line, fd);
+			break;
+		}
+	}
+
+	if (gotd_imsg_compose_event(iev, GOTD_IMSG_NOTIFICATION_SENT,
+	    PROC_NOTIFY, -1, NULL, 0) == -1) {
+		err = got_error_from_errno("imsg compose NOTIFY");
+		goto done;
+	}
+done:
+	close(fd);
+	return err;
+}
+
+static void
+notify_dispatch_session(int fd, short event, void *arg)
+{
+	struct gotd_imsgev *iev = arg;
+	struct imsgbuf *ibuf = &iev->ibuf;
+	ssize_t n;
+	int shut = 0;
+	struct imsg imsg;
+
+	if (event & EV_READ) {
+		if ((n = imsg_read(ibuf)) == -1 && errno != EAGAIN)
+			fatal("imsg_read error");
+		if (n == 0) {
+			/* Connection closed. */
+			shut = 1;
+			goto done;
+		}
+	}
+
+	if (event & EV_WRITE) {
+		n = msgbuf_write(&ibuf->w);
+		if (n == -1 && errno != EAGAIN)
+			fatal("msgbuf_write");
+		if (n == 0) {
+			/* Connection closed. */
+			shut = 1;
+			goto done;
+		}
+	}
+
+	for (;;) {
+		const struct got_error *err = NULL;
+
+		if ((n = imsg_get(ibuf, &imsg)) == -1)
+			fatal("%s: imsg_get error", __func__);
+		if (n == 0)	/* No more messages. */
+			break;
+
+		switch (imsg.hdr.type) {
+		case GOTD_IMSG_NOTIFY:
+			err = send_notification(&imsg, iev);
+			break;
+		default:
+			log_debug("unexpected imsg %d", imsg.hdr.type);
+			break;
+		}
+		imsg_free(&imsg);
+
+		if (err)
+			log_warnx("%s: %s", __func__, err->msg);
+	}
+done:
+	if (!shut) {
+		gotd_imsg_event_add(iev);
+	} else {
+		struct gotd_notify_session *session;
+
+		/* This pipe is dead. Remove its event handler */
+		event_del(&iev->ev);
+		imsg_clear(&iev->ibuf);
+
+		session = find_session_by_fd(fd);
+		if (session)
+			remove_session(session);
+	}
+}
+
+static const struct got_error *
+recv_session(struct imsg *imsg)
+{
+	struct gotd_notify_session *session;
+	size_t datalen;
+	int fd;
+
+	datalen = imsg->hdr.len - IMSG_HEADER_SIZE;
+	if (datalen != 0)
+		return got_error(GOT_ERR_PRIVSEP_LEN);
+
+	fd = imsg_get_fd(imsg);
+	if (fd == -1)
+		return got_error(GOT_ERR_PRIVSEP_NO_FD);
+
+	session = calloc(1, sizeof(*session));
+	if (session == NULL)
+		return got_error_from_errno("calloc");
+
+	session->id = get_session_id();
+	imsg_init(&session->iev.ibuf, fd);
+	session->iev.handler = notify_dispatch_session;
+	session->iev.events = EV_READ;
+	session->iev.handler_arg = NULL;
+	event_set(&session->iev.ev, session->iev.ibuf.fd, EV_READ,
+	    notify_dispatch_session, &session->iev);
+	gotd_imsg_event_add(&session->iev);
+	add_session(session);
+
+	return NULL;
+}
+
+static void
+notify_dispatch(int fd, short event, void *arg)
+{
+	struct gotd_imsgev *iev = arg;
+	struct imsgbuf *ibuf = &iev->ibuf;
+	ssize_t n;
+	int shut = 0;
+	struct imsg imsg;
+
+	if (event & EV_READ) {
+		if ((n = imsg_read(ibuf)) == -1 && errno != EAGAIN)
+			fatal("imsg_read error");
+		if (n == 0) {
+			/* Connection closed. */
+			shut = 1;
+			goto done;
+		}
+	}
+
+	if (event & EV_WRITE) {
+		n = msgbuf_write(&ibuf->w);
+		if (n == -1 && errno != EAGAIN)
+			fatal("msgbuf_write");
+		if (n == 0) {
+			/* Connection closed. */
+			shut = 1;
+			goto done;
+		}
+	}
+
+	for (;;) {
+		const struct got_error *err = NULL;
+
+		if ((n = imsg_get(ibuf, &imsg)) == -1)
+			fatal("%s: imsg_get error", __func__);
+		if (n == 0)	/* No more messages. */
+			break;
+
+		switch (imsg.hdr.type) {
+		case GOTD_IMSG_CONNECT_SESSION:
+			err = recv_session(&imsg);
+			break;
+		default:
+			log_debug("unexpected imsg %d", imsg.hdr.type);
+			break;
+		}
+		imsg_free(&imsg);
+
+		if (err)
+			log_warnx("%s: %s", __func__, err->msg);
+	}
+done:
+	if (!shut) {
+		gotd_imsg_event_add(iev);
+	} else {
+		/* This pipe is dead. Remove its event handler */
+		event_del(&iev->ev);
+		event_loopexit(NULL);
+	}
+
+}
+
+void
+notify_main(const char *title, struct gotd_repolist *repos,
+    const char *default_sender)
+{
+	const struct got_error *err = NULL;
+	struct event evsigint, evsigterm, evsighup, evsigusr1;
+
+	arc4random_buf(&sessions_hash_key, sizeof(sessions_hash_key));
+
+	gotd_notify.title = title;
+	gotd_notify.repos = repos;
+	gotd_notify.default_sender = default_sender;
+	gotd_notify.pid = getpid();
+
+	signal_set(&evsigint, SIGINT, gotd_notify_sighdlr, NULL);
+	signal_set(&evsigterm, SIGTERM, gotd_notify_sighdlr, NULL);
+	signal_set(&evsighup, SIGHUP, gotd_notify_sighdlr, NULL);
+	signal_set(&evsigusr1, SIGUSR1, gotd_notify_sighdlr, NULL);
+	signal(SIGPIPE, SIG_IGN);
+
+	signal_add(&evsigint, NULL);
+	signal_add(&evsigterm, NULL);
+	signal_add(&evsighup, NULL);
+	signal_add(&evsigusr1, NULL);
+
+	imsg_init(&gotd_notify.parent_iev.ibuf, GOTD_FILENO_MSG_PIPE);
+	gotd_notify.parent_iev.handler = notify_dispatch;
+	gotd_notify.parent_iev.events = EV_READ;
+	gotd_notify.parent_iev.handler_arg = NULL;
+	event_set(&gotd_notify.parent_iev.ev, gotd_notify.parent_iev.ibuf.fd,
+	    EV_READ, notify_dispatch, &gotd_notify.parent_iev);
+	gotd_imsg_event_add(&gotd_notify.parent_iev);
+
+	event_dispatch();
+
+	if (err)
+		log_warnx("%s: %s", title, err->msg);
+	gotd_notify_shutdown();
+}
+
+void
+gotd_notify_shutdown(void)
+{
+	log_debug("shutting down");
+	exit(0);
+}
blob - /dev/null
blob + 8173549e2a96f7a2443ff8bd53806411e4312d57 (mode 644)
--- /dev/null
+++ gotd/notify.h
@@ -0,0 +1,17 @@
+/*
+ * Copyright (c) 2024 Stefan Sperling <stsp@openbsd.org>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+void notify_main(const char *, struct gotd_repolist *, const char *);
blob - 6c4441cc4454a3e740e917e1c8cf70fb2764235c
blob + 2ff8767a91c4ae5e858bc0906a5889a4179c3f35
--- gotd/repo_write.c
+++ gotd/repo_write.c
@@ -19,6 +19,7 @@
 #include <sys/tree.h>
 #include <sys/types.h>
 
+#include <ctype.h>
 #include <event.h>
 #include <errno.h>
 #include <imsg.h>
@@ -41,6 +42,10 @@
 #include "got_object.h"
 #include "got_reference.h"
 #include "got_path.h"
+#include "got_diff.h"
+#include "got_cancel.h"
+#include "got_commit_graph.h"
+#include "got_opentemp.h"
 
 #include "got_lib_delta.h"
 #include "got_lib_delta_cache.h"
@@ -74,6 +79,12 @@ static struct repo_write {
 	struct got_pathlist_head *protected_tag_namespaces;
 	struct got_pathlist_head *protected_branch_namespaces;
 	struct got_pathlist_head *protected_branches;
+	struct {
+		FILE *f1;
+		FILE *f2;
+		int fd1;
+		int fd2;
+	} diff;
 } repo_write;
 
 struct gotd_ref_update {
@@ -1627,8 +1638,576 @@ receive_pack_idx(struct imsg *imsg, struct gotd_imsgev
 		return got_error(GOT_ERR_PRIVSEP_NO_FD);
 
 	return NULL;
+}
+
+static char *
+get_datestr(time_t *time, char *datebuf)
+{
+	struct tm mytm, *tm;
+	char *p, *s;
+
+	tm = gmtime_r(time, &mytm);
+	if (tm == NULL)
+		return NULL;
+	s = asctime_r(tm, datebuf);
+	if (s == NULL)
+		return NULL;
+	p = strchr(s, '\n');
+	if (p)
+		*p = '\0';
+	return s;
+}
+
+static const struct got_error *
+notify_removed_ref(const char *refname, uint8_t *sha1,
+    struct gotd_imsgev *iev, int fd)
+{
+	const struct got_error *err;
+	struct got_object_id id;
+	char *id_str;
+
+	memset(&id, 0, sizeof(id));
+	memcpy(id.sha1, sha1, sizeof(id.sha1));
+
+	err = got_object_id_str(&id_str, &id);
+	if (err)
+		return err;
+
+	dprintf(fd, "Removed %s: %s\n", refname, id_str);
+	free(id_str);
+	return err;
+}
+
+static const char *
+format_author(char *author)
+{
+	char *smallerthan;
+
+	smallerthan = strchr(author, '<');
+	if (smallerthan && smallerthan[1] != '\0')
+		author = smallerthan + 1;
+	author[strcspn(author, "@>")] = '\0';
+
+	return author;
+}
+
+static const struct got_error *
+print_commit_oneline(struct got_commit_object *commit, struct got_object_id *id,
+    struct got_repository *repo, int fd)
+{
+	const struct got_error *err = NULL;
+	char *id_str = NULL, *logmsg0 = NULL;
+	char *s, *nl;
+	char *committer = NULL, *author = NULL;
+	char datebuf[12]; /* YYYY-MM-DD + SPACE + NUL */
+	struct tm tm;
+	time_t committer_time;
+
+	err = got_object_id_str(&id_str, id);
+	if (err)
+		return err;
+
+	committer_time = got_object_commit_get_committer_time(commit);
+	if (gmtime_r(&committer_time, &tm) == NULL) {
+		err = got_error_from_errno("gmtime_r");
+		goto done;
+	}
+	if (strftime(datebuf, sizeof(datebuf), "%G-%m-%d ", &tm) == 0) {
+		err = got_error(GOT_ERR_NO_SPACE);
+		goto done;
+	}
+
+	err = got_object_commit_get_logmsg(&logmsg0, commit);
+	if (err)
+		goto done;
+
+	s = logmsg0;
+	while (isspace((unsigned char)s[0]))
+		s++;
+
+	nl = strchr(s, '\n');
+	if (nl) {
+		*nl = '\0';
+	}
+
+	if (strcmp(got_object_commit_get_author(commit),
+	    got_object_commit_get_committer(commit)) != 0) {
+		author = strdup(got_object_commit_get_author(commit));
+		if (author == NULL) {
+			err = got_error_from_errno("strdup");
+			goto done;
+		}
+		dprintf(fd, "%s%.7s %.8s %s\n", datebuf, id_str,
+		    format_author(author), s);
+	} else {
+		committer = strdup(got_object_commit_get_committer(commit));
+		if (committer == NULL) {
+			err = got_error_from_errno("strdup");
+			goto done;
+		}
+		dprintf(fd, "%s%.7s %.8s %s\n", datebuf, id_str,
+		    format_author(committer), s);
+	}
+
+	if (fsync(fd) == -1 && err == NULL)
+		err = got_error_from_errno("fsync");
+done:
+	free(id_str);
+	free(logmsg0);
+	free(committer);
+	free(author);
+	return err;
+}
+
+static const struct got_error *
+print_diffstat(struct got_diffstat_cb_arg *dsa, int fd)
+{
+	struct got_pathlist_entry *pe;
+
+	TAILQ_FOREACH(pe, dsa->paths, entry) {
+		struct got_diff_changed_path *cp = pe->data;
+		int pad = dsa->max_path_len - pe->path_len + 1;
+
+		dprintf(fd, " %c  %s%*c | %*d+ %*d-\n", cp->status,
+		     pe->path, pad, ' ', dsa->add_cols + 1, cp->add,
+		     dsa->rm_cols + 1, cp->rm);
+	}
+	dprintf(fd,
+	    "\n%d file%s changed, %d insertion%s(+), %d deletion%s(-)\n\n",
+	    dsa->nfiles, dsa->nfiles > 1 ? "s" : "", dsa->ins,
+	    dsa->ins != 1 ? "s" : "", dsa->del, dsa->del != 1 ? "s" : "");
+
+	return NULL;
+}
+
+static const struct got_error *
+print_commit(struct got_commit_object *commit, struct got_object_id *id,
+    struct got_repository *repo, struct got_pathlist_head *changed_paths,
+    struct got_diffstat_cb_arg *diffstat, int fd)
+{
+	const struct got_error *err = NULL;
+	char *id_str, *datestr, *logmsg0, *logmsg, *line;
+	char datebuf[26];
+	time_t committer_time;
+	const char *author, *committer;
+
+	err = got_object_id_str(&id_str, id);
+	if (err)
+		return err;
+
+	dprintf(fd, "commit %s\n", id_str);
+	free(id_str);
+	id_str = NULL;
+	dprintf(fd, "from: %s\n", got_object_commit_get_author(commit));
+	author = got_object_commit_get_author(commit);
+	committer = got_object_commit_get_committer(commit);
+	if (strcmp(author, committer) != 0)
+		dprintf(fd, "via: %s\n", committer);
+	committer_time = got_object_commit_get_committer_time(commit);
+	datestr = get_datestr(&committer_time, datebuf);
+	if (datestr)
+		dprintf(fd, "date: %s UTC\n", datestr);
+	if (got_object_commit_get_nparents(commit) > 1) {
+		const struct got_object_id_queue *parent_ids;
+		struct got_object_qid *qid;
+		int n = 1;
+		parent_ids = got_object_commit_get_parent_ids(commit);
+		STAILQ_FOREACH(qid, parent_ids, entry) {
+			err = got_object_id_str(&id_str, &qid->id);
+			if (err)
+				goto done;
+			dprintf(fd, "parent %d: %s\n", n++, id_str);
+			free(id_str);
+			id_str = NULL;
+		}
+	}
+
+	err = got_object_commit_get_logmsg(&logmsg0, commit);
+	if (err)
+		goto done;
+
+	logmsg = logmsg0;
+	do {
+		line = strsep(&logmsg, "\n");
+		if (line)
+			dprintf(fd, " %s\n", line);
+	} while (line);
+	free(logmsg0);
+
+	err = print_diffstat(diffstat, fd);
+	if (err)
+		goto done;
+
+	if (fsync(fd) == -1 && err == NULL)
+		err = got_error_from_errno("fsync");
+done:
+	free(id_str);
+	return err;
+}
+
+static const struct got_error *
+get_changed_paths(struct got_pathlist_head *paths,
+    struct got_commit_object *commit, struct got_repository *repo,
+    struct got_diffstat_cb_arg *dsa)
+{
+	const struct got_error *err = NULL;
+	struct got_object_id *tree_id1 = NULL, *tree_id2 = NULL;
+	struct got_tree_object *tree1 = NULL, *tree2 = NULL;
+	struct got_object_qid *qid;
+	got_diff_blob_cb cb = got_diff_tree_collect_changed_paths;
+	FILE *f1 = repo_write.diff.f1, *f2 = repo_write.diff.f2;
+	int fd1 = repo_write.diff.fd1, fd2 = repo_write.diff.fd2;
+
+	if (dsa)
+		cb = got_diff_tree_compute_diffstat;
+
+	err = got_opentemp_truncate(f1);
+	if (err)
+		return err;
+	err = got_opentemp_truncate(f2);
+	if (err)
+		return err;
+	err = got_opentemp_truncatefd(fd1);
+	if (err)
+		return err;
+	err = got_opentemp_truncatefd(fd2);
+	if (err)
+		return err;
+
+	qid = STAILQ_FIRST(got_object_commit_get_parent_ids(commit));
+	if (qid != NULL) {
+		struct got_commit_object *pcommit;
+		err = got_object_open_as_commit(&pcommit, repo,
+		    &qid->id);
+		if (err)
+			return err;
+
+		tree_id1 = got_object_id_dup(
+		    got_object_commit_get_tree_id(pcommit));
+		if (tree_id1 == NULL) {
+			got_object_commit_close(pcommit);
+			return got_error_from_errno("got_object_id_dup");
+		}
+		got_object_commit_close(pcommit);
+
+	}
+
+	if (tree_id1) {
+		err = got_object_open_as_tree(&tree1, repo, tree_id1);
+		if (err)
+			goto done;
+	}
+
+	tree_id2 = got_object_commit_get_tree_id(commit);
+	err = got_object_open_as_tree(&tree2, repo, tree_id2);
+	if (err)
+		goto done;
+
+	err = got_diff_tree(tree1, tree2, f1, f2, fd1, fd2, "", "", repo,
+	    cb, dsa ? (void *)dsa : paths, dsa ? 1 : 0);
+done:
+	if (tree1)
+		got_object_tree_close(tree1);
+	if (tree2)
+		got_object_tree_close(tree2);
+	free(tree_id1);
+	return err;
+}
+
+static const struct got_error *
+print_commits(struct got_object_id *root_id, struct got_object_id *end_id,
+    struct got_repository *repo, int fd)
+{
+	const struct got_error *err;
+	struct got_commit_graph *graph;
+	struct got_object_id_queue reversed_commits;
+	struct got_object_qid *qid;
+	struct got_commit_object *commit = NULL;
+	struct got_pathlist_head changed_paths;
+	int ncommits = 0;
+	const int shortlog_threshold = 50;
+
+	STAILQ_INIT(&reversed_commits);
+	TAILQ_INIT(&changed_paths);
+
+	/* XXX first-parent only for now */
+	err = got_commit_graph_open(&graph, "/", 1);
+	if (err)
+		return err;
+	err = got_commit_graph_iter_start(graph, root_id, repo,
+	    check_cancelled, NULL);
+	if (err)
+		goto done;
+	for (;;) {
+		struct got_object_id id;
+
+		err = got_commit_graph_iter_next(&id, graph, repo,
+		    check_cancelled, NULL);
+		if (err) {
+			if (err->code == GOT_ERR_ITER_COMPLETED)
+				err = NULL;
+			break;
+		}
+
+		err = got_object_open_as_commit(&commit, repo, &id);
+		if (err)
+			break;
+
+		if (end_id && got_object_id_cmp(&id, end_id) == 0)
+			break;
+
+		err = got_object_qid_alloc(&qid, &id);
+		if (err)
+			break;
+
+		STAILQ_INSERT_HEAD(&reversed_commits, qid, entry);
+		ncommits++;
+		got_object_commit_close(commit);
+
+		if (end_id == NULL)
+			break;
+	}
+
+	STAILQ_FOREACH(qid, &reversed_commits, entry) {
+		struct got_diffstat_cb_arg dsa = { 0, 0, 0, 0, 0, 0,
+		    &changed_paths, 0, 0, GOT_DIFF_ALGORITHM_PATIENCE };
+
+		err = got_object_open_as_commit(&commit, repo, &qid->id);
+		if (err)
+			break;
+	
+		if (ncommits > shortlog_threshold) {
+			err = print_commit_oneline(commit, &qid->id,
+			    repo, fd);
+			if (err)
+				break;
+		} else {
+			err = get_changed_paths(&changed_paths, commit,
+			    repo, &dsa);
+			if (err)
+				break;
+			err = print_commit(commit, &qid->id, repo,
+			    &changed_paths, &dsa, fd);
+		}
+		got_object_commit_close(commit);
+		commit = NULL;
+		got_pathlist_free(&changed_paths, GOT_PATHLIST_FREE_ALL);
+	}
+done:
+	if (commit)
+		got_object_commit_close(commit);
+	while (!STAILQ_EMPTY(&reversed_commits)) {
+		qid = STAILQ_FIRST(&reversed_commits);
+		STAILQ_REMOVE_HEAD(&reversed_commits, entry);
+		got_object_qid_free(qid);
+	}
+	got_pathlist_free(&changed_paths, GOT_PATHLIST_FREE_ALL);
+	got_commit_graph_close(graph);
+	return err;
 }
 
+static const struct got_error *
+print_tag(struct got_object_id *id, 
+    const char *refname, struct got_repository *repo, int fd)
+{
+	const struct got_error *err = NULL;
+	struct got_tag_object *tag = NULL;
+	const char *tagger = NULL;
+	char *id_str = NULL, *tagmsg0 = NULL, *tagmsg, *line, *datestr;
+	char datebuf[26];
+	time_t tagger_time;
+
+	err = got_object_open_as_tag(&tag, repo, id);
+	if (err)
+		return err;
+
+	tagger = got_object_tag_get_tagger(tag);
+	tagger_time = got_object_tag_get_tagger_time(tag);
+	err = got_object_id_str(&id_str,
+	    got_object_tag_get_object_id(tag));
+	if (err)
+		goto done;
+
+	dprintf(fd, "tag %s\n", refname);
+	dprintf(fd, "from: %s\n", tagger);
+	datestr = get_datestr(&tagger_time, datebuf);
+	if (datestr)
+		dprintf(fd, "date: %s UTC\n", datestr);
+
+	switch (got_object_tag_get_object_type(tag)) {
+	case GOT_OBJ_TYPE_BLOB:
+		dprintf(fd, "object: %s %s\n", GOT_OBJ_LABEL_BLOB, id_str);
+		break;
+	case GOT_OBJ_TYPE_TREE:
+		dprintf(fd, "object: %s %s\n", GOT_OBJ_LABEL_TREE, id_str);
+		break;
+	case GOT_OBJ_TYPE_COMMIT:
+		dprintf(fd, "object: %s %s\n", GOT_OBJ_LABEL_COMMIT, id_str);
+		break;
+	case GOT_OBJ_TYPE_TAG:
+		dprintf(fd, "object: %s %s\n", GOT_OBJ_LABEL_TAG, id_str);
+		break;
+	default:
+		break;
+	}
+
+	tagmsg0 = strdup(got_object_tag_get_message(tag));
+	if (tagmsg0 == NULL) {
+		err = got_error_from_errno("strdup");
+		goto done;
+	}
+	tagmsg = tagmsg0;
+	do {
+		line = strsep(&tagmsg, "\n");
+		if (line)
+			dprintf(fd, " %s\n", line);
+	} while (line);
+	free(tagmsg0);
+done:
+	if (tag)
+		got_object_tag_close(tag);
+	free(id_str);
+	return err;
+}
+
+static const struct got_error *
+notify_changed_ref(const char *refname, uint8_t *old_sha1,
+    uint8_t *new_sha1, struct gotd_imsgev *iev, int fd)
+{
+	const struct got_error *err;
+	struct got_object_id old_id, new_id;
+	int old_obj_type, new_obj_type;
+	const char *label;
+	char *new_id_str = NULL;
+
+	memset(&old_id, 0, sizeof(old_id));
+	memcpy(old_id.sha1, old_sha1, sizeof(old_id.sha1));
+	memset(&new_id, 0, sizeof(new_id));
+	memcpy(new_id.sha1, new_sha1, sizeof(new_id.sha1));
+
+	err = got_object_get_type(&old_obj_type, repo_write.repo, &old_id);
+	if (err)
+		return err;
+
+	err = got_object_get_type(&new_obj_type, repo_write.repo, &new_id);
+	if (err)
+		return err;
+
+	switch (new_obj_type) {
+	case GOT_OBJ_TYPE_COMMIT:
+		err = print_commits(&new_id,
+		    old_obj_type == GOT_OBJ_TYPE_COMMIT ? &old_id : NULL,
+		    repo_write.repo, fd);
+		break;
+	case GOT_OBJ_TYPE_TAG:
+		err = print_tag(&new_id, refname, repo_write.repo, fd);
+		break;
+	default:
+		err = got_object_type_label(&label, new_obj_type);
+		if (err)
+			goto done;
+		err = got_object_id_str(&new_id_str, &new_id);
+		if (err)
+			goto done;
+		dprintf(fd, "%s: %s object %s\n", refname, label, new_id_str);
+		break;
+	}
+done:
+	free(new_id_str);
+	return err;
+}
+
+static const struct got_error *
+notify_created_ref(const char *refname, uint8_t *sha1,
+    struct gotd_imsgev *iev, int fd)
+{
+	const struct got_error *err;
+	struct got_object_id id;
+	int obj_type;
+
+	memset(&id, 0, sizeof(id));
+	memcpy(id.sha1, sha1, sizeof(id.sha1));
+
+	err = got_object_get_type(&obj_type, repo_write.repo, &id);
+	if (err)
+		return err;
+
+	if (obj_type == GOT_OBJ_TYPE_TAG)
+		return print_tag(&id, refname, repo_write.repo, fd);
+
+	return print_commits(&id, NULL, repo_write.repo, fd);
+}
+
+static const struct got_error *
+render_notification(struct imsg *imsg, struct gotd_imsgev *iev)
+{
+	const struct got_error *err = NULL;
+	struct gotd_imsg_notification_content ireq;
+	size_t datalen, len;
+	char *refname;
+	struct ibuf *wbuf;
+	int fd;
+
+	fd = imsg_get_fd(imsg);
+	if (fd == -1)
+		return got_error(GOT_ERR_PRIVSEP_NO_FD);
+
+	datalen = imsg->hdr.len - IMSG_HEADER_SIZE;
+	if (datalen < sizeof(ireq))
+		return got_error(GOT_ERR_PRIVSEP_LEN);
+
+	memcpy(&ireq, imsg->data, sizeof(ireq));
+
+	if (datalen != sizeof(ireq) +  ireq.refname_len)
+		return got_error(GOT_ERR_PRIVSEP_LEN);
+
+	refname = strndup(imsg->data + sizeof(ireq), ireq.refname_len);
+	if (refname == NULL)
+		return got_error_from_errno("strndup");
+
+	switch (ireq.action) {
+	case GOTD_NOTIF_ACTION_CREATED:
+		err = notify_created_ref(refname, ireq.new_id, iev, fd);
+		break;
+	case GOTD_NOTIF_ACTION_REMOVED:
+		err = notify_removed_ref(refname, ireq.old_id, iev, fd);
+		break;
+	case GOTD_NOTIF_ACTION_CHANGED:
+		err = notify_changed_ref(refname, ireq.old_id, ireq.new_id,
+		    iev, fd);
+		break;
+	}
+
+	if (fsync(fd) == -1) {
+		err = got_error_from_errno("fsync");
+		goto done;
+	}
+
+	len = sizeof(ireq) + ireq.refname_len;
+	wbuf = imsg_create(&iev->ibuf, GOTD_IMSG_NOTIFY, PROC_REPO_WRITE,
+	    repo_write.pid, len);
+	if (wbuf == NULL) {
+		err = got_error_from_errno("imsg_create REF");
+		goto done;
+	}
+	if (imsg_add(wbuf, &ireq, sizeof(ireq)) == -1) {
+		err = got_error_from_errno("imsg_add NOTIFY");
+		goto done;
+	}
+	if (imsg_add(wbuf, refname, ireq.refname_len) == -1) {
+		err = got_error_from_errno("imsg_add NOTIFY");
+		goto done;
+	}
+
+	imsg_close(&iev->ibuf, wbuf);
+	gotd_imsg_event_add(iev);
+done:
+	free(refname);
+	if (close(fd) == -1 && err == NULL)
+		err = got_error_from_errno("close");
+	return err;
+}
+
 static void
 repo_write_dispatch_session(int fd, short event, void *arg)
 {
@@ -1727,6 +2306,13 @@ repo_write_dispatch_session(int fd, short event, void 
 				log_warnx("update refs: %s", err->msg);
 			}
 			break;
+		case GOTD_IMSG_NOTIFY:
+			err = render_notification(&imsg, iev);
+			if (err) {
+				log_warnx("render notification: %s", err->msg);
+				shut = 1;
+			}
+			break;
 		default:
 			log_debug("unexpected imsg %d", imsg.hdr.type);
 			break;
@@ -1840,6 +2426,7 @@ repo_write_dispatch(int fd, short event, void *arg)
 void
 repo_write_main(const char *title, const char *repo_path,
     int *pack_fds, int *temp_fds,
+    FILE *diff_f1, FILE *diff_f2, int diff_fd1, int diff_fd2,
     struct got_pathlist_head *protected_tag_namespaces,
     struct got_pathlist_head *protected_branch_namespaces,
     struct got_pathlist_head *protected_branches)
@@ -1862,6 +2449,10 @@ repo_write_main(const char *title, const char *repo_pa
 	repo_write.protected_tag_namespaces = protected_tag_namespaces;
 	repo_write.protected_branch_namespaces = protected_branch_namespaces;
 	repo_write.protected_branches = protected_branches;
+	repo_write.diff.f1 = diff_f1;
+	repo_write.diff.f2 = diff_f2;
+	repo_write.diff.fd1 = diff_fd1;
+	repo_write.diff.fd2 = diff_fd2;
 
 	STAILQ_INIT(&repo_write_client.ref_updates);
 
@@ -1894,6 +2485,14 @@ repo_write_main(const char *title, const char *repo_pa
 
 	event_dispatch();
 done:
+	if (fclose(diff_f1) == EOF && err == NULL)
+		err = got_error_from_errno("fclose");
+	if (fclose(diff_f2) == EOF && err == NULL)
+		err = got_error_from_errno("fclose");
+	if (close(diff_fd1) == -1 && err == NULL)
+		err = got_error_from_errno("close");
+	if (close(diff_fd2) == -1 && err == NULL)
+		err = got_error_from_errno("close");
 	if (err)
 		log_warnx("%s: %s", title, err->msg);
 	repo_write_shutdown();
blob - e8192eec3947ce83dcedba9e20048cb0ff7dfc76
blob + 6d09a0d009bafa745ad7d7fa491e5c6bcfe4149e
--- gotd/repo_write.h
+++ gotd/repo_write.h
@@ -15,6 +15,7 @@
  */
 
 void repo_write_main(const char *, const char *, int *, int *,
+    FILE *, FILE *, int, int,
     struct got_pathlist_head *, struct got_pathlist_head *,
     struct got_pathlist_head *);
 void repo_write_shutdown(void);
blob - d67328a67243a5beff513f9e3feeb89708060657
blob + 163f482824dd1bbb39949c76de672eb8682b553a
--- gotd/session.c
+++ gotd/session.c
@@ -53,14 +53,25 @@
 #include "log.h"
 #include "session.h"
 
+struct gotd_session_notif {
+	STAILQ_ENTRY(gotd_session_notif) entry;
+	int fd;
+	enum gotd_notification_action action;
+	char *refname;
+	struct got_object_id old_id;
+	struct got_object_id new_id;
+};
+STAILQ_HEAD(gotd_session_notifications, gotd_session_notif) notifications;
 
 static struct gotd_session {
 	pid_t pid;
 	const char *title;
 	struct got_repository *repo;
+	struct gotd_repo *repo_cfg;
 	int *pack_fds;
 	int *temp_fds;
 	struct gotd_imsgev parent_iev;
+	struct gotd_imsgev notifier_iev;
 	struct timeval request_timeout;
 	enum gotd_procid proc_id;
 } gotd_session;
@@ -79,6 +90,7 @@ static struct gotd_session_client {
 	struct event			 tmo;
 	uid_t				 euid;
 	gid_t				 egid;
+	char				*username;
 	char				*packfile_path;
 	char				*packidx_path;
 	int				 nref_updates;
@@ -401,6 +413,249 @@ begin_ref_updates(struct gotd_session_client *client, 
 }
 
 static const struct got_error *
+validate_namespace(const char *namespace)
+{
+	size_t len = strlen(namespace);
+
+	if (len < 5 || strncmp("refs/", namespace, 5) != 0 ||
+	    namespace[len - 1] != '/') {
+		return got_error_fmt(GOT_ERR_BAD_REF_NAME,
+		    "reference namespace '%s'", namespace);
+	}
+
+	return NULL;
+}
+
+static const struct got_error *
+queue_notification(struct got_object_id *old_id, struct got_object_id *new_id,
+    struct got_repository *repo, struct got_reference *ref)
+{
+	const struct got_error *err = NULL;
+	struct gotd_session_client *client = &gotd_session_client;
+	struct gotd_repo *repo_cfg = gotd_session.repo_cfg;
+	struct gotd_imsgev *iev = &client->repo_child_iev;
+	struct got_pathlist_entry *pe;
+	struct gotd_session_notif *notif;
+
+	if (iev->ibuf.fd == -1 ||
+	    STAILQ_EMPTY(&repo_cfg->notification_targets))
+		return NULL; /* notifications unused */
+
+	TAILQ_FOREACH(pe, &repo_cfg->notification_refs, entry) {
+		const char *refname = pe->path;
+		if (strcmp(got_ref_get_name(ref), refname) == 0)
+			break;
+	}
+	if (pe == NULL) {
+		TAILQ_FOREACH(pe, &repo_cfg->notification_ref_namespaces,
+		    entry) {
+			const char *namespace = pe->path;
+
+			err = validate_namespace(namespace);
+			if (err)
+				return err;
+			if (strncmp(namespace, got_ref_get_name(ref),
+			    strlen(namespace)) == 0)
+				break;
+		}
+	}
+
+	/*
+	 * If a branch or a reference namespace was specified in the
+	 * configuration file then only send notifications if a match
+	 * was found.
+	 */
+	if (pe == NULL && (!TAILQ_EMPTY(&repo_cfg->notification_refs) ||
+	    !TAILQ_EMPTY(&repo_cfg->notification_ref_namespaces)))
+		return NULL;
+
+	notif = calloc(1, sizeof(*notif));
+	if (notif == NULL)
+		return got_error_from_errno("calloc");
+
+	notif->fd = -1;
+
+	if (old_id == NULL)
+		notif->action = GOTD_NOTIF_ACTION_CREATED;
+	else if (new_id == NULL)
+		notif->action = GOTD_NOTIF_ACTION_REMOVED;
+	else
+		notif->action = GOTD_NOTIF_ACTION_CHANGED;
+
+	if (old_id != NULL)
+		memcpy(&notif->old_id, old_id, sizeof(notif->old_id));
+	if (new_id != NULL)
+		memcpy(&notif->new_id, new_id, sizeof(notif->new_id));
+
+	notif->refname = strdup(got_ref_get_name(ref));
+	if (notif->refname == NULL) {
+		err = got_error_from_errno("strdup");
+		goto done;
+	}
+
+	STAILQ_INSERT_TAIL(&notifications, notif, entry);
+done:
+	if (err && notif) {
+		free(notif->refname);
+		free(notif);
+	}
+	return err;
+}
+
+/* Forward notification content to the NOTIFY process. */
+static const struct got_error *
+forward_notification(struct gotd_session_client *client, struct imsg *imsg)
+{
+	const struct got_error *err = NULL;
+	struct gotd_imsgev *iev = &gotd_session.notifier_iev;
+	struct gotd_session_notif *notif;
+	struct gotd_imsg_notification_content icontent;
+	char *refname = NULL;
+	size_t datalen;
+	struct gotd_imsg_notify inotify;
+	const char *action;
+
+	memset(&inotify, 0, sizeof(inotify));
+
+	datalen = imsg->hdr.len - IMSG_HEADER_SIZE;
+	if (datalen < sizeof(icontent))
+		return got_error(GOT_ERR_PRIVSEP_LEN);
+	memcpy(&icontent, imsg->data, sizeof(icontent));
+	if (datalen != sizeof(icontent) + icontent.refname_len)
+		return got_error(GOT_ERR_PRIVSEP_LEN);
+	refname = strndup(imsg->data + sizeof(icontent), icontent.refname_len);
+	if (refname == NULL)
+		return got_error_from_errno("strndup");
+
+	notif = STAILQ_FIRST(&notifications);
+	if (notif == NULL)
+		return got_error(GOT_ERR_PRIVSEP_MSG);
+
+	STAILQ_REMOVE(&notifications, notif, gotd_session_notif, entry);
+
+	if (notif->action != icontent.action || notif->fd == -1 ||
+	    strcmp(notif->refname, refname) != 0) {
+		err = got_error(GOT_ERR_PRIVSEP_MSG);
+		goto done;
+	}
+	if (notif->action == GOTD_NOTIF_ACTION_CREATED) {
+		if (memcmp(notif->new_id.sha1, icontent.new_id,
+		    SHA1_DIGEST_LENGTH) != 0) {
+			err = got_error_msg(GOT_ERR_PRIVSEP_MSG,
+			    "received notification content for unknown event");
+			goto done;
+		}
+	} else if (notif->action == GOTD_NOTIF_ACTION_REMOVED) {
+		if (memcmp(notif->old_id.sha1, icontent.old_id,
+		    SHA1_DIGEST_LENGTH) != 0) {
+			err = got_error_msg(GOT_ERR_PRIVSEP_MSG,
+			    "received notification content for unknown event");
+			goto done;
+		}
+	} else if (memcmp(notif->old_id.sha1, icontent.old_id,
+	    SHA1_DIGEST_LENGTH) != 0 ||
+	    memcmp(notif->new_id.sha1, icontent.new_id,
+	    SHA1_DIGEST_LENGTH) != 0) {
+		err = got_error_msg(GOT_ERR_PRIVSEP_MSG,
+		    "received notification content for unknown event");
+		goto done;
+	}
+
+	switch (notif->action) {
+	case GOTD_NOTIF_ACTION_CREATED:
+		action = "created";
+		break;
+	case GOTD_NOTIF_ACTION_REMOVED:
+		action = "removed";
+		break;
+	case GOTD_NOTIF_ACTION_CHANGED:
+		action = "changed";
+		break;
+	default:
+		err = got_error(GOT_ERR_PRIVSEP_MSG);
+		goto done;
+	}
+
+	strlcpy(inotify.repo_name, gotd_session.repo_cfg->name,
+	    sizeof(inotify.repo_name));
+
+	snprintf(inotify.subject_line, sizeof(inotify.subject_line),
+	    "%s: %s %s %s", gotd_session.repo_cfg->name,
+	    client->username, action, notif->refname);
+
+	if (gotd_imsg_compose_event(iev, GOTD_IMSG_NOTIFY,
+	    PROC_SESSION_WRITE, notif->fd, &inotify, sizeof(inotify))
+	    == -1) {
+		err = got_error_from_errno("imsg compose NOTIFY");
+		goto done;
+	}
+	notif->fd = -1;
+done:
+	if (notif->fd != -1)
+		close(notif->fd);
+	free(notif);
+	free(refname);
+	return err;
+}
+
+/* Request notification content from REPO_WRITE process. */
+static const struct got_error *
+request_notification(struct gotd_session_notif *notif)
+{
+	const struct got_error *err = NULL;
+	struct gotd_session_client *client = &gotd_session_client;
+	struct gotd_imsgev *iev = &client->repo_child_iev;
+	struct gotd_imsg_notification_content icontent;
+	struct ibuf *wbuf;
+	size_t len;
+	int fd;
+
+	fd = got_opentempfd();
+	if (fd == -1)
+		return got_error_from_errno("got_opentemp");
+
+	memset(&icontent, 0, sizeof(icontent));
+	icontent.client_id = client->id;
+
+	icontent.action = notif->action;
+	memcpy(&icontent.old_id, &notif->old_id, sizeof(notif->old_id));
+	memcpy(&icontent.new_id, &notif->new_id, sizeof(notif->new_id));
+	icontent.refname_len = strlen(notif->refname);
+
+	len = sizeof(icontent) + icontent.refname_len;
+	wbuf = imsg_create(&iev->ibuf, GOTD_IMSG_NOTIFY,
+	    gotd_session.proc_id, gotd_session.pid, len);
+	if (wbuf == NULL) {
+		err = got_error_from_errno("imsg_create NOTIFY");
+		goto done;
+	}
+	if (imsg_add(wbuf, &icontent, sizeof(icontent)) == -1) {
+		err = got_error_from_errno("imsg_add NOTIFY");
+		goto done;
+	}
+	if (imsg_add(wbuf, notif->refname, icontent.refname_len) == -1) {
+		err = got_error_from_errno("imsg_add NOTIFY");
+		goto done;
+	}
+
+	notif->fd = dup(fd);
+	if (notif->fd == -1) {
+		err = got_error_from_errno("dup");
+		goto done;
+	}
+
+	ibuf_fd_set(wbuf, fd);
+	fd = -1;
+
+	imsg_close(&iev->ibuf, wbuf);
+	gotd_imsg_event_add(iev);
+done:
+	if (err && fd != -1)
+		close(fd);
+	return err;
+}
+
+static const struct got_error *
 update_ref(int *shut, struct gotd_session_client *client,
     const char *repo_path, struct imsg *imsg)
 {
@@ -409,6 +664,7 @@ update_ref(int *shut, struct gotd_session_client *clie
 	struct got_reference *ref = NULL;
 	struct gotd_imsg_ref_update iref;
 	struct got_object_id old_id, new_id;
+	struct gotd_session_notif *notif;
 	struct got_object_id *id = NULL;
 	char *refname = NULL;
 	size_t datalen;
@@ -451,6 +707,9 @@ update_ref(int *shut, struct gotd_session_client *clie
 			err = got_ref_write(ref, repo); /* will lock/unlock */
 			if (err)
 				goto done;
+			err = queue_notification(NULL, &new_id, repo, ref);
+			if (err)
+				goto done;
 		} else {
 			err = got_ref_resolve(&id, repo, ref);
 			if (err)
@@ -490,7 +749,9 @@ update_ref(int *shut, struct gotd_session_client *clie
 		err = got_ref_delete(ref, repo);
 		if (err)
 			goto done;
-
+		err = queue_notification(&old_id, NULL, repo, ref);
+		if (err)
+			goto done;
 		free(id);
 		id = NULL;
 	} else {
@@ -519,10 +780,12 @@ update_ref(int *shut, struct gotd_session_client *clie
 			err = got_ref_change_ref(ref, &new_id);
 			if (err)
 				goto done;
-
 			err = got_ref_write(ref, repo);
 			if (err)
 				goto done;
+			err = queue_notification(&old_id, &new_id, repo, ref);
+			if (err)
+				goto done;
 		}
 
 		free(id);
@@ -543,7 +806,17 @@ done:
 		client->nref_updates--;
 		if (client->nref_updates == 0) {
 			send_refs_updated(client);
-			client->flush_disconnect = 1;
+			notif = STAILQ_FIRST(&notifications);
+			if (notif) {
+				client->state = GOTD_STATE_NOTIFY;
+				err = request_notification(notif);
+				if (err) {
+					log_warn("could not send notification: "
+					    "%s", err->msg);
+					client->flush_disconnect = 1;
+				}
+			} else
+				client->flush_disconnect = 1;
 		}
 
 	}
@@ -560,6 +833,21 @@ done:
 	return err;
 }
 
+static const struct got_error *
+recv_notification_content(uint32_t *client_id, struct imsg *imsg)
+{
+	struct gotd_imsg_notification_content inotif;
+	size_t datalen;
+
+	datalen = imsg->hdr.len - IMSG_HEADER_SIZE;
+	if (datalen < sizeof(inotif))
+		return got_error(GOT_ERR_PRIVSEP_LEN);
+	memcpy(&inotif, imsg->data, sizeof(inotif));
+
+	*client_id = inotif.client_id;
+	return NULL;
+}
+
 static void
 session_dispatch_repo_child(int fd, short event, void *arg)
 {
@@ -596,7 +884,7 @@ session_dispatch_repo_child(int fd, short event, void 
 		uint32_t client_id = 0;
 		int do_disconnect = 0;
 		int do_ref_updates = 0, do_ref_update = 0;
-		int do_packfile_install = 0;
+		int do_packfile_install = 0, do_notify = 0;
 
 		if ((n = imsg_get(ibuf, &imsg)) == -1)
 			fatal("%s: imsg_get error", __func__);
@@ -627,6 +915,11 @@ session_dispatch_repo_child(int fd, short event, void 
 			if (err == NULL)
 				do_ref_update = 1;
 			break;
+		case GOTD_IMSG_NOTIFY:
+			err = recv_notification_content(&client_id, &imsg);
+			if (err == NULL)
+				do_notify = 1;
+			break;
 		default:
 			log_debug("unexpected imsg %d", imsg.hdr.type);
 			break;
@@ -638,6 +931,8 @@ session_dispatch_repo_child(int fd, short event, void 
 			else
 				disconnect(client);
 		} else {
+			struct gotd_session_notif *notif;
+
 			if (do_packfile_install)
 				err = install_pack(client,
 				    gotd_session.repo->path, &imsg);
@@ -646,8 +941,21 @@ session_dispatch_repo_child(int fd, short event, void 
 			else if (do_ref_update)
 				err = update_ref(&shut, client,
 				    gotd_session.repo->path, &imsg);
+			else if (do_notify)
+				err = forward_notification(client, &imsg);
 			if (err)
 				log_warnx("uid %d: %s", client->euid, err->msg);
+
+			notif = STAILQ_FIRST(&notifications);
+			if (notif && do_notify) {
+				/* Request content for next notification. */
+				err = request_notification(notif);
+				if (err) {
+					log_warn("could not send notification: "
+					    "%s", err->msg);
+					shut = 1;
+				}
+			}
 		}
 		imsg_free(&imsg);
 	}
@@ -1295,9 +1603,12 @@ recv_connect(struct imsg *imsg)
 		return got_error(GOT_ERR_PRIVSEP_MSG);
 
 	datalen = imsg->hdr.len - IMSG_HEADER_SIZE;
-	if (datalen != sizeof(iconnect))
+	if (datalen < sizeof(iconnect))
 		return got_error(GOT_ERR_PRIVSEP_LEN);
 	memcpy(&iconnect, imsg->data, sizeof(iconnect));
+	if (iconnect.username_len == 0 ||
+	    datalen != sizeof(iconnect) + iconnect.username_len)
+		return got_error(GOT_ERR_PRIVSEP_LEN);
 
 	client->euid = iconnect.euid;
 	client->egid = iconnect.egid;
@@ -1305,6 +1616,11 @@ recv_connect(struct imsg *imsg)
 	if (client->fd == -1)
 		return got_error(GOT_ERR_PRIVSEP_NO_FD);
 
+	client->username = strndup(imsg->data + sizeof(iconnect),
+	    iconnect.username_len);
+	if (client->username == NULL)
+		return got_error_from_errno("strndup");
+
 	imsg_init(&client->iev.ibuf, client->fd);
 	client->iev.handler = session_dispatch_client;
 	client->iev.events = EV_READ;
@@ -1317,7 +1633,117 @@ recv_connect(struct imsg *imsg)
 	return NULL;
 }
 
+static void
+session_dispatch_notifier(int fd, short event, void *arg)
+{
+	const struct got_error *err;
+	struct gotd_session_client *client = &gotd_session_client;
+	struct gotd_imsgev *iev = arg;
+	struct imsgbuf *ibuf = &iev->ibuf;
+	ssize_t n;
+	int shut = 0;
+	struct imsg imsg;
+	struct gotd_session_notif *notif;
+
+	if (event & EV_READ) {
+		if ((n = imsg_read(ibuf)) == -1 && errno != EAGAIN)
+			fatal("imsg_read error");
+		if (n == 0) {
+			/* Connection closed. */
+			shut = 1;
+			goto done;
+		}
+	}
+
+	if (event & EV_WRITE) {
+		n = msgbuf_write(&ibuf->w);
+		if (n == -1 && errno != EAGAIN)
+			fatal("msgbuf_write");
+		if (n == 0) {
+			/* Connection closed. */
+			shut = 1;
+			goto done;
+		}
+	}
+
+	for (;;) {
+		if ((n = imsg_get(ibuf, &imsg)) == -1)
+			fatal("%s: imsg_get error", __func__);
+		if (n == 0)	/* No more messages. */
+			break;
+
+		switch (imsg.hdr.type) {
+		case GOTD_IMSG_NOTIFICATION_SENT:
+			if (client->state != GOTD_STATE_NOTIFY) {
+				log_warn("unexpected imsg %d", imsg.hdr.type);
+				break;
+			}
+			notif = STAILQ_FIRST(&notifications);
+			if (notif == NULL) {
+				disconnect(client);
+				break; /* NOTREACHED */
+			}
+			/* Request content for the next notification. */
+			err = request_notification(notif);
+			if (err) {
+				log_warn("could not send notification: %s",
+				    err->msg);
+				disconnect(client);
+			}
+			break;
+		default:
+			log_debug("unexpected imsg %d", imsg.hdr.type);
+			break;
+		}
+
+		imsg_free(&imsg);
+	}
+done:
+	if (!shut) {
+		gotd_imsg_event_add(iev);
+	} else {
+		/* This pipe is dead. Remove its event handler */
+		event_del(&iev->ev);
+		imsg_clear(&iev->ibuf);
+		imsg_init(&iev->ibuf, -1);
+	}
+}
+
 static const struct got_error *
+recv_notifier(struct imsg *imsg)
+{
+	struct gotd_imsgev *iev = &gotd_session.notifier_iev;
+	struct gotd_session_client *client = &gotd_session_client;
+	size_t datalen;
+	int fd;
+
+	if (client->state != GOTD_STATE_EXPECT_LIST_REFS)
+		return got_error(GOT_ERR_PRIVSEP_MSG);
+
+	/* We should already have received a pipe to the listener. */
+	if (client->fd == -1)
+		return got_error(GOT_ERR_PRIVSEP_MSG);
+
+	datalen = imsg->hdr.len - IMSG_HEADER_SIZE;
+	if (datalen != 0)
+		return got_error(GOT_ERR_PRIVSEP_LEN);
+
+	fd = imsg_get_fd(imsg);
+	if (fd == -1)
+		return NULL; /* notifications unused */
+
+	imsg_init(&iev->ibuf, fd);
+	iev->handler = session_dispatch_notifier;
+	iev->events = EV_READ;
+	iev->handler_arg = NULL;
+	event_set(&iev->ev, iev->ibuf.fd, EV_READ,
+	    session_dispatch_notifier, iev);
+	gotd_imsg_event_add(iev);
+
+	return NULL;
+}
+
+static const struct got_error *
 recv_repo_child(struct imsg *imsg)
 {
 	struct gotd_imsg_connect_repo_child ichild;
@@ -1418,6 +1844,9 @@ session_dispatch(int fd, short event, void *arg)
 		case GOTD_IMSG_DISCONNECT:
 			do_disconnect = 1;
 			break;
+		case GOTD_IMSG_CONNECT_NOTIFIER:
+			err = recv_notifier(&imsg);
+			break;
 		case GOTD_IMSG_CONNECT_REPO_CHILD:
 			err = recv_repo_child(&imsg);
 			if (err)
@@ -1454,19 +1883,24 @@ done:
 void
 session_main(const char *title, const char *repo_path,
     int *pack_fds, int *temp_fds, struct timeval *request_timeout,
-    enum gotd_procid proc_id)
+    struct gotd_repo *repo_cfg, enum gotd_procid proc_id)
 {
 	const struct got_error *err = NULL;
 	struct event evsigint, evsigterm, evsighup, evsigusr1;
 
+	STAILQ_INIT(&notifications);
+
 	gotd_session.title = title;
 	gotd_session.pid = getpid();
 	gotd_session.pack_fds = pack_fds;
 	gotd_session.temp_fds = temp_fds;
 	memcpy(&gotd_session.request_timeout, request_timeout,
 	    sizeof(gotd_session.request_timeout));
+	gotd_session.repo_cfg = repo_cfg;
 	gotd_session.proc_id = proc_id;
 
+	imsg_init(&gotd_session.notifier_iev.ibuf, -1);
+
 	err = got_repo_open(&gotd_session.repo, repo_path, NULL, pack_fds);
 	if (err)
 		goto done;
@@ -1518,10 +1952,23 @@ done:
 void
 gotd_session_shutdown(void)
 {
+	struct gotd_session_notif *notif;
+
 	log_debug("shutting down");
+
+	while (!STAILQ_EMPTY(&notifications)) {
+		notif = STAILQ_FIRST(&notifications);
+		STAILQ_REMOVE_HEAD(&notifications, entry);
+		if (notif->fd != -1)
+			close(notif->fd);
+		free(notif->refname);
+		free(notif);
+	}
+
 	if (gotd_session.repo)
 		got_repo_close(gotd_session.repo);
 	got_repo_pack_fds_close(gotd_session.pack_fds);
 	got_repo_temp_fds_close(gotd_session.temp_fds);
+	free(gotd_session_client.username);
 	exit(0);
 }
blob - de20117ce268c646687a1977e8e0c7086a7fb2d6
blob + 624f7bb81035da8f1d5293bcbcdf72cd6f064102
--- gotd/session.h
+++ gotd/session.h
@@ -15,4 +15,4 @@
  */
 
 void session_main(const char *, const char *, int *, int *, struct timeval *,
-    enum gotd_procid);
+    struct gotd_repo *, enum gotd_procid);
blob - ddfc0f1e9d1f0c373066bc33b387387e5beeaf0d
blob + eaca641d2887c0f5e66a410257eaf3fae004a13d
--- regress/gotd/Makefile
+++ regress/gotd/Makefile
@@ -4,7 +4,7 @@ REGRESS_TARGETS=test_repo_read test_repo_read_group \
 	test_repo_read_denied_user test_repo_read_denied_group \
 	test_repo_read_bad_user test_repo_read_bad_group \
 	test_repo_write test_repo_write_empty test_request_bad \
-	test_repo_write_protected
+	test_repo_write_protected test_email_notification
 NOOBJ=Yes
 CLEANFILES=gotd.conf
 
@@ -14,7 +14,9 @@ GOTD_TEST_ROOT=/tmp
 GOTD_DEVUSER?=gotdev
 GOTD_DEVUSER_HOME!=userinfo $(GOTD_DEVUSER) | awk '/^dir/ {print $$2}'
 GOTD_TEST_REPO!?=mktemp -d "$(GOTD_TEST_ROOT)/gotd-test-repo-XXXXXXXXXX"
-GOTD_TEST_REPO_URL=ssh://${GOTD_DEVUSER}@127.0.0.1/test-repo
+GOTD_TEST_REPO_NAME=test-repo
+GOTD_TEST_REPO_URL=ssh://${GOTD_DEVUSER}@127.0.0.1/$(GOTD_TEST_REPO_NAME)
+GOTD_TEST_SMTP_PORT=2525
 
 GOTD_TEST_USER?=${DOAS_USER}
 .if empty(GOTD_TEST_USER)
@@ -37,15 +39,20 @@ PREFIX ?= ${GOTD_TEST_USER_HOME}
 BINDIR ?= ${PREFIX}/bin
 .endif
 
-GOTD_START_CMD?=$(BINDIR)/gotd -vv -f $(PWD)/gotd.conf
+GOTD_START_CMD?=env ${GOTD_ENV} $(BINDIR)/gotd -vv -f $(PWD)/gotd.conf
 GOTD_STOP_CMD?=$(BINDIR)/gotctl -f $(GOTD_SOCK) stop
 GOTD_TRAP=trap "$(GOTD_STOP_CMD)" HUP INT QUIT PIPE TERM
 
+GOTD_ENV=GOT_NOTIFY_EMAIL_TIMEOUT=1
+
 GOTD_TEST_ENV=GOTD_TEST_ROOT=$(GOTD_TEST_ROOT) \
 	GOTD_TEST_REPO_URL=$(GOTD_TEST_REPO_URL) \
+	GOTD_TEST_REPO_NAME=$(GOTD_TEST_REPO_NAME) \
 	GOTD_TEST_REPO=$(GOTD_TEST_REPO) \
 	GOTD_SOCK=$(GOTD_SOCK) \
 	GOTD_DEVUSER=$(GOTD_DEVUSER) \
+	GOTD_USER=$(GOTD_USER) \
+	GOTD_TEST_SMTP_PORT=$(GOTD_TEST_SMTP_PORT) \
 	HOME=$(GOTD_TEST_USER_HOME) \
 	PATH=$(GOTD_TEST_USER_HOME)/bin:$(PATH)
 
@@ -148,6 +155,20 @@ start_gotd_rw_protected: ensure_root
 	@$(GOTD_TRAP); $(GOTD_START_CMD)
 	@$(GOTD_TRAP); sleep .5
 
+start_gotd_email_notification: ensure_root
+	@echo 'listen on "$(GOTD_SOCK)"' > $(PWD)/gotd.conf
+	@echo "user $(GOTD_USER)" >> $(PWD)/gotd.conf
+	@echo 'repository "test-repo" {' >> $(PWD)/gotd.conf
+	@echo '    path "$(GOTD_TEST_REPO)"' >> $(PWD)/gotd.conf
+	@echo '    permit rw $(GOTD_DEVUSER)' >> $(PWD)/gotd.conf
+	@echo '    notify {' >> $(PWD)/gotd.conf
+	@echo -n '      email to ${GOTD_DEVUSER}' >> $(PWD)/gotd.conf
+	@echo ' relay 127.0.0.1 port ${GOTD_TEST_SMTP_PORT}' >> $(PWD)/gotd.conf
+	@echo "    }" >> $(PWD)/gotd.conf
+	@echo "}" >> $(PWD)/gotd.conf
+	@$(GOTD_TRAP); $(GOTD_START_CMD)
+	@$(GOTD_TRAP); sleep .5
+
 prepare_test_repo: ensure_root
 	@chown ${GOTD_USER} "${GOTD_TEST_REPO}"
 	@su -m ${GOTD_USER} -c 'env $(GOTD_TEST_ENV) sh ./prepare_test_repo.sh'
@@ -215,4 +236,9 @@ test_request_bad: prepare_test_repo_empty start_gotd_r
 		'env $(GOTD_TEST_ENV) sh ./request_bad.sh'
 	@$(GOTD_STOP_CMD) 2>/dev/null
 
+test_email_notification: prepare_test_repo start_gotd_email_notification
+	@-$(GOTD_TRAP); su -m ${GOTD_TEST_USER} -c \
+		'env $(GOTD_TEST_ENV) sh ./email_notification.sh'
+	@$(GOTD_STOP_CMD) 2>/dev/null
+
 .include <bsd.regress.mk>
blob - d75faae64b46a7c347ecb77c6631207a3ded44b0
blob + e91075f7971646d565d0d0c8b6509d711fc2128f
--- regress/gotd/README
+++ regress/gotd/README
@@ -56,3 +56,9 @@ The server test suite can now be run from the top-leve
 
 The suite must be started as root in order to be able to start and stop gotd.
 The test suite switches to non-root users as appropriate.
+
+The test suite uses netcat on port 2525 to test SMTP notifications.
+If this port is already in use then affected tests might fail.
+If needed the port can be overriden on the make command line:
+
+ $ doas make server-regress GOTD_TEST_SMTP_PORT=12345
blob - /dev/null
blob + 44052114a99b0f98370a006d3e3c691db559ceec (mode 644)
--- /dev/null
+++ regress/gotd/email_notification.sh
@@ -0,0 +1,533 @@
+#!/bin/sh
+#
+# Copyright (c) 2024 Stefan Sperling <stsp@openbsd.org>
+#
+# Permission to use, copy, modify, and distribute this software for any
+# purpose with or without fee is hereby granted, provided that the above
+# copyright notice and this permission notice appear in all copies.
+#
+# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+. ../cmdline/common.sh
+. ./common.sh
+
+test_file_changed() {
+	local testroot=`test_init file_changed 1`
+
+	got clone -a -q ${GOTD_TEST_REPO_URL} $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got clone failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	got checkout -q $testroot/repo-clone $testroot/wt >/dev/null
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got checkout failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	echo "change alpha" > $testroot/wt/alpha
+	(cd $testroot/wt && got commit -m 'make changes' > /dev/null)
+	local commit_id=`git_show_head $testroot/repo-clone`
+	local author_time=`git_show_author_time $testroot/repo-clone`
+
+	(printf "220\r\n250\r\n250\r\n250\r\n354\r\n250\r\n221\r\n" \
+		| timeout 5 nc -l "$GOTD_TEST_SMTP_PORT" > $testroot/stdout) &
+
+	got send -b main -q -r $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got send failed unexpectedly" >&2
+		test_done "$testroot" "1"
+		return 1
+	fi
+
+	wait %1 # wait for nc -l
+
+	HOSTNAME=`hostname`
+	printf "HELO localhost\r\n" > $testroot/stdout.expected
+	printf "MAIL FROM:<${GOTD_USER}@${HOSTNAME}>\r\n" \
+		>> $testroot/stdout.expected
+	printf "RCPT TO:<${GOTD_DEVUSER}>\r\n" >> $testroot/stdout.expected
+	printf "DATA\r\n" >> $testroot/stdout.expected
+	printf "From: ${GOTD_USER}@${HOSTNAME}\r\n" >> $testroot/stdout.expected
+	printf "To: ${GOTD_DEVUSER}\r\n" >> $testroot/stdout.expected
+	printf "Subject: $GOTD_TEST_REPO_NAME: " >> $testroot/stdout.expected
+	printf "${GOTD_DEVUSER} changed refs/heads/main\r\n" \
+		>> $testroot/stdout.expected
+	printf "\r\n" >> $testroot/stdout.expected
+	printf "commit $commit_id\n" >> $testroot/stdout.expected
+	printf "from: $GOT_AUTHOR\n" >> $testroot/stdout.expected
+	d=`date -u -r $author_time +"%a %b %e %X %Y UTC"`
+	printf "date: $d\n" >> $testroot/stdout.expected
+	printf " \n" >> $testroot/stdout.expected
+	printf " make changes\n \n" >> $testroot/stdout.expected
+	printf " M  alpha  |  1+  1-\n\n"  >> $testroot/stdout.expected
+	printf "1 file changed, 1 insertion(+), 1 deletion(-)\n\n" \
+		>> $testroot/stdout.expected
+	printf "\r\n" >> $testroot/stdout.expected
+	printf ".\r\n" >> $testroot/stdout.expected
+	printf "QUIT\r\n" >> $testroot/stdout.expected
+
+	grep -v ^Date $testroot/stdout > $testroot/stdout.filtered
+	cmp -s $testroot/stdout.expected $testroot/stdout.filtered
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout.filtered
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	test_done "$testroot" "$ret"
+}
+
+test_many_commits_not_summarized() {
+	local testroot=`test_init many_commits_not_summarized 1`
+
+	got clone -a -q ${GOTD_TEST_REPO_URL} $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got clone failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	got checkout -q $testroot/repo-clone $testroot/wt >/dev/null
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got checkout failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	for i in `seq 1 24`; do
+		echo "alpha $i" > $testroot/wt/alpha
+		(cd $testroot/wt && got commit -m 'make changes' > /dev/null)
+		local commit_id=`git_show_head $testroot/repo-clone`
+		local author_time=`git_show_author_time $testroot/repo-clone`
+		d=`date -u -r $author_time +"%a %b %e %X %Y UTC"`
+		set -- "$@" "$commit_id $d"
+	done
+
+	(printf "220\r\n250\r\n250\r\n250\r\n354\r\n250\r\n221\r\n" \
+		| timeout 5 nc -l "$GOTD_TEST_SMTP_PORT" > $testroot/stdout) &
+
+	got send -b main -q -r $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got send failed unexpectedly" >&2
+		test_done "$testroot" "1"
+		return 1
+	fi
+
+	wait %1 # wait for nc -l
+
+	HOSTNAME=`hostname`
+	printf "HELO localhost\r\n" > $testroot/stdout.expected
+	printf "MAIL FROM:<${GOTD_USER}@${HOSTNAME}>\r\n" \
+		>> $testroot/stdout.expected
+	printf "RCPT TO:<${GOTD_DEVUSER}>\r\n" >> $testroot/stdout.expected
+	printf "DATA\r\n" >> $testroot/stdout.expected
+	printf "From: ${GOTD_USER}@${HOSTNAME}\r\n" \
+		>> $testroot/stdout.expected
+	printf "To: ${GOTD_DEVUSER}\r\n" >> $testroot/stdout.expected
+	printf "Subject: $GOTD_TEST_REPO_NAME: " >> $testroot/stdout.expected
+	printf "${GOTD_DEVUSER} changed refs/heads/main\r\n" \
+		>> $testroot/stdout.expected
+	printf "\r\n" >> $testroot/stdout.expected
+	for i in `seq 1 24`; do
+		s=`pop_idx $i "$@"`
+		commit_id=$(echo $s | cut -d' ' -f1)
+		commit_time=$(echo $s | sed -e "s/^$commit_id //g")
+		printf "commit $commit_id\n" >> $testroot/stdout.expected
+		printf "from: $GOT_AUTHOR\n" >> $testroot/stdout.expected
+		printf "date: $commit_time\n" >> $testroot/stdout.expected
+		printf " \n" >> $testroot/stdout.expected
+		printf " make changes\n \n" >> $testroot/stdout.expected
+		printf " M  alpha  |  1+  1-\n\n"  \
+			>> $testroot/stdout.expected
+		printf "1 file changed, 1 insertion(+), 1 deletion(-)\n\n" \
+			>> $testroot/stdout.expected
+	done
+	printf "\r\n" >> $testroot/stdout.expected
+	printf ".\r\n" >> $testroot/stdout.expected
+	printf "QUIT\r\n" >> $testroot/stdout.expected
+
+	grep -v ^Date $testroot/stdout > $testroot/stdout.filtered
+	cmp -s $testroot/stdout.expected $testroot/stdout.filtered
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout.filtered
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	test_done "$testroot" "$ret"
+}
+
+test_many_commits_summarized() {
+	local testroot=`test_init many_commits_summarized 1`
+
+	got clone -a -q ${GOTD_TEST_REPO_URL} $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got clone failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	got checkout -q $testroot/repo-clone $testroot/wt >/dev/null
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got checkout failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	for i in `seq 1 51`; do
+		echo "alpha $i" > $testroot/wt/alpha
+		(cd $testroot/wt && got commit -m 'make changes' > /dev/null)
+		local commit_id=`git_show_head $testroot/repo-clone`
+		local short_commit_id=`trim_obj_id 33 $commit_id`
+		local author_time=`git_show_author_time $testroot/repo-clone`
+		d=`date -u -r $author_time +"%G-%m-%d"`
+		set -- "$@" "$short_commit_id $d"
+	done
+
+	(printf "220\r\n250\r\n250\r\n250\r\n354\r\n250\r\n221\r\n" \
+		| timeout 5 nc -l "$GOTD_TEST_SMTP_PORT" > $testroot/stdout) &
+
+	got send -b main -q -r $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got send failed unexpectedly" >&2
+		test_done "$testroot" "1"
+		return 1
+	fi
+
+	wait %1 # wait for nc -l
+
+	HOSTNAME=`hostname`
+	printf "HELO localhost\r\n" > $testroot/stdout.expected
+	printf "MAIL FROM:<${GOTD_USER}@${HOSTNAME}>\r\n" \
+		>> $testroot/stdout.expected
+	printf "RCPT TO:<${GOTD_DEVUSER}>\r\n" >> $testroot/stdout.expected
+	printf "DATA\r\n" >> $testroot/stdout.expected
+	printf "From: ${GOTD_USER}@${HOSTNAME}\r\n" \
+		>> $testroot/stdout.expected
+	printf "To: ${GOTD_DEVUSER}\r\n" >> $testroot/stdout.expected
+	printf "Subject: $GOTD_TEST_REPO_NAME: " >> $testroot/stdout.expected
+	printf "${GOTD_DEVUSER} changed refs/heads/main\r\n" \
+		>> $testroot/stdout.expected
+	printf "\r\n" >> $testroot/stdout.expected
+	for i in `seq 1 51`; do
+		s=`pop_idx $i "$@"`
+		commit_id=$(echo $s | cut -d' ' -f1)
+		commit_time=$(echo $s | sed -e "s/^$commit_id //g")
+		printf "$commit_time $commit_id $GOT_AUTHOR_8 make changes\n" \
+			>> $testroot/stdout.expected
+	done
+	printf "\r\n" >> $testroot/stdout.expected
+	printf ".\r\n" >> $testroot/stdout.expected
+	printf "QUIT\r\n" >> $testroot/stdout.expected
+
+	grep -v ^Date $testroot/stdout > $testroot/stdout.filtered
+	cmp -s $testroot/stdout.expected $testroot/stdout.filtered
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout.filtered
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	test_done "$testroot" "$ret"
+}
+
+test_branch_created() {
+	local testroot=`test_init branch_created 1`
+
+	got clone -a -q ${GOTD_TEST_REPO_URL} $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got clone failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	got checkout -q $testroot/repo-clone $testroot/wt >/dev/null
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got checkout failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	(cd $testroot/wt && got branch newbranch > /dev/null)
+
+	echo "change alpha on branch" > $testroot/wt/alpha
+	(cd $testroot/wt && got commit -m 'newbranch' > /dev/null)
+	local commit_id=`git_show_branch_head $testroot/repo-clone newbranch`
+	local author_time=`git_show_author_time $testroot/repo-clone $commit_id`
+
+	(printf "220\r\n250\r\n250\r\n250\r\n354\r\n250\r\n221\r\n" \
+		| timeout 5 nc -l "$GOTD_TEST_SMTP_PORT" > $testroot/stdout) &
+
+	got send -b newbranch -q -r $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got send failed unexpectedly" >&2
+		test_done "$testroot" "1"
+		return 1
+	fi
+
+	wait %1 # wait for nc -l
+
+	HOSTNAME=`hostname`
+	printf "HELO localhost\r\n" > $testroot/stdout.expected
+	printf "MAIL FROM:<${GOTD_USER}@${HOSTNAME}>\r\n" \
+		>> $testroot/stdout.expected
+	printf "RCPT TO:<${GOTD_DEVUSER}>\r\n" >> $testroot/stdout.expected
+	printf "DATA\r\n" >> $testroot/stdout.expected
+	printf "From: ${GOTD_USER}@${HOSTNAME}\r\n" >> $testroot/stdout.expected
+	printf "To: ${GOTD_DEVUSER}\r\n" >> $testroot/stdout.expected
+	printf "Subject: $GOTD_TEST_REPO_NAME: " >> $testroot/stdout.expected
+	printf "${GOTD_DEVUSER} created refs/heads/newbranch\r\n" \
+		>> $testroot/stdout.expected
+	printf "\r\n" >> $testroot/stdout.expected
+	printf "commit $commit_id\n" >> $testroot/stdout.expected
+	printf "from: $GOT_AUTHOR\n" >> $testroot/stdout.expected
+	d=`date -u -r $author_time +"%a %b %e %X %Y UTC"`
+	printf "date: $d\n" >> $testroot/stdout.expected
+	printf " \n" >> $testroot/stdout.expected
+	printf " newbranch\n \n" >> $testroot/stdout.expected
+	printf " M  alpha  |  1+  1-\n\n"  >> $testroot/stdout.expected
+	printf "1 file changed, 1 insertion(+), 1 deletion(-)\n\n" \
+		>> $testroot/stdout.expected
+	printf "\r\n" >> $testroot/stdout.expected
+	printf ".\r\n" >> $testroot/stdout.expected
+	printf "QUIT\r\n" >> $testroot/stdout.expected
+
+	grep -v ^Date $testroot/stdout > $testroot/stdout.filtered
+	cmp -s $testroot/stdout.expected $testroot/stdout.filtered
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout.filtered
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	test_done "$testroot" "$ret"
+}
+
+test_branch_removed() {
+	local testroot=`test_init branch_removed 1`
+
+	got clone -a -q ${GOTD_TEST_REPO_URL} $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got clone failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	(printf "220\r\n250\r\n250\r\n250\r\n354\r\n250\r\n221\r\n" \
+		| timeout 5 nc -l "$GOTD_TEST_SMTP_PORT" > $testroot/stdout) &
+
+	local commit_id=`git_show_branch_head $testroot/repo-clone newbranch`
+
+	got send -d newbranch -q -r $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got send failed unexpectedly" >&2
+		test_done "$testroot" "1"
+		return 1
+	fi
+
+	wait %1 # wait for nc -l
+
+	HOSTNAME=`hostname`
+	printf "HELO localhost\r\n" > $testroot/stdout.expected
+	printf "MAIL FROM:<${GOTD_USER}@${HOSTNAME}>\r\n" \
+		>> $testroot/stdout.expected
+	printf "RCPT TO:<${GOTD_DEVUSER}>\r\n" >> $testroot/stdout.expected
+	printf "DATA\r\n" >> $testroot/stdout.expected
+	printf "From: ${GOTD_USER}@${HOSTNAME}\r\n" >> $testroot/stdout.expected
+	printf "To: ${GOTD_DEVUSER}\r\n" >> $testroot/stdout.expected
+	printf "Subject: $GOTD_TEST_REPO_NAME: " >> $testroot/stdout.expected
+	printf "${GOTD_DEVUSER} removed refs/heads/newbranch\r\n" \
+		>> $testroot/stdout.expected
+	printf "\r\n" >> $testroot/stdout.expected
+	printf "Removed refs/heads/newbranch: $commit_id\n" \
+		>> $testroot/stdout.expected
+	printf "\r\n" >> $testroot/stdout.expected
+	printf ".\r\n" >> $testroot/stdout.expected
+	printf "QUIT\r\n" >> $testroot/stdout.expected
+
+	grep -v ^Date $testroot/stdout > $testroot/stdout.filtered
+	cmp -s $testroot/stdout.expected $testroot/stdout.filtered
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout.filtered
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	test_done "$testroot" "$ret"
+}
+
+test_tag_created() {
+	local testroot=`test_init tag_created 1`
+
+	got clone -a -q ${GOTD_TEST_REPO_URL} $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got clone failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	got tag -r $testroot/repo-clone -m "new tag" 1.0 > /dev/null
+	local commit_id=`git_show_head $testroot/repo-clone`
+	local tagger_time=`git_show_tagger_time $testroot/repo-clone 1.0`
+
+	(printf "220\r\n250\r\n250\r\n250\r\n354\r\n250\r\n221\r\n" \
+		| timeout 5 nc -l "$GOTD_TEST_SMTP_PORT" > $testroot/stdout) &
+
+	got send -t 1.0 -q -r $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got send failed unexpectedly" >&2
+		test_done "$testroot" "1"
+		return 1
+	fi
+
+	wait %1 # wait for nc -l
+
+	HOSTNAME=`hostname`
+	printf "HELO localhost\r\n" > $testroot/stdout.expected
+	printf "MAIL FROM:<${GOTD_USER}@${HOSTNAME}>\r\n" \
+		>> $testroot/stdout.expected
+	printf "RCPT TO:<${GOTD_DEVUSER}>\r\n" >> $testroot/stdout.expected
+	printf "DATA\r\n" >> $testroot/stdout.expected
+	printf "From: ${GOTD_USER}@${HOSTNAME}\r\n" >> $testroot/stdout.expected
+	printf "To: ${GOTD_DEVUSER}\r\n" >> $testroot/stdout.expected
+	printf "Subject: $GOTD_TEST_REPO_NAME: " >> $testroot/stdout.expected
+	printf "${GOTD_DEVUSER} created refs/tags/1.0\r\n" \
+		>> $testroot/stdout.expected
+	printf "\r\n" >> $testroot/stdout.expected
+	printf "tag refs/tags/1.0\n" >> $testroot/stdout.expected
+	printf "from: $GOT_AUTHOR\n" >> $testroot/stdout.expected
+	d=`date -u -r $tagger_time +"%a %b %e %X %Y UTC"`
+	printf "date: $d\n" >> $testroot/stdout.expected
+	printf "object: commit $commit_id\n" >> $testroot/stdout.expected
+	printf " \n" >> $testroot/stdout.expected
+	printf " new tag\n \n" >> $testroot/stdout.expected
+	printf "\r\n" >> $testroot/stdout.expected
+	printf ".\r\n" >> $testroot/stdout.expected
+	printf "QUIT\r\n" >> $testroot/stdout.expected
+
+	grep -v ^Date $testroot/stdout > $testroot/stdout.filtered
+	cmp -s $testroot/stdout.expected $testroot/stdout.filtered
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout.filtered
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	test_done "$testroot" "$ret"
+}
+
+test_tag_changed() {
+	local testroot=`test_init tag_changed 1`
+
+	got clone -a -q ${GOTD_TEST_REPO_URL} $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got clone failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	got checkout -q $testroot/repo-clone $testroot/wt >/dev/null
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got checkout failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	echo "change alpha" > $testroot/wt/alpha
+	(cd $testroot/wt && got commit -m 'make changes' > /dev/null)
+	local commit_id=`git_show_head $testroot/repo-clone`
+
+	got ref -r $testroot/repo-clone -d refs/tags/1.0 >/dev/null
+	got tag -r $testroot/repo-clone -m "new tag" 1.0 > /dev/null
+	local tagger_time=`git_show_tagger_time $testroot/repo-clone 1.0`
+
+	(printf "220\r\n250\r\n250\r\n250\r\n354\r\n250\r\n221\r\n" \
+		| timeout 5 nc -l "$GOTD_TEST_SMTP_PORT" > $testroot/stdout) &
+
+	got send -f -t 1.0 -q -r $testroot/repo-clone
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got send failed unexpectedly" >&2
+		test_done "$testroot" "1"
+		return 1
+	fi
+
+	wait %1 # wait for nc -l
+
+	HOSTNAME=`hostname`
+	printf "HELO localhost\r\n" > $testroot/stdout.expected
+	printf "MAIL FROM:<${GOTD_USER}@${HOSTNAME}>\r\n" \
+		>> $testroot/stdout.expected
+	printf "RCPT TO:<${GOTD_DEVUSER}>\r\n" >> $testroot/stdout.expected
+	printf "DATA\r\n" >> $testroot/stdout.expected
+	printf "From: ${GOTD_USER}@${HOSTNAME}\r\n" >> $testroot/stdout.expected
+	printf "To: ${GOTD_DEVUSER}\r\n" >> $testroot/stdout.expected
+	printf "Subject: $GOTD_TEST_REPO_NAME: " >> $testroot/stdout.expected
+	printf "${GOTD_DEVUSER} changed refs/tags/1.0\r\n" \
+		>> $testroot/stdout.expected
+	printf "\r\n" >> $testroot/stdout.expected
+	printf "tag refs/tags/1.0\n" >> $testroot/stdout.expected
+	printf "from: $GOT_AUTHOR\n" >> $testroot/stdout.expected
+	d=`date -u -r $tagger_time +"%a %b %e %X %Y UTC"`
+	printf "date: $d\n" >> $testroot/stdout.expected
+	printf "object: commit $commit_id\n" >> $testroot/stdout.expected
+	printf " \n" >> $testroot/stdout.expected
+	printf " new tag\n \n" >> $testroot/stdout.expected
+	printf "\r\n" >> $testroot/stdout.expected
+	printf ".\r\n" >> $testroot/stdout.expected
+	printf "QUIT\r\n" >> $testroot/stdout.expected
+
+	grep -v ^Date $testroot/stdout > $testroot/stdout.filtered
+	cmp -s $testroot/stdout.expected $testroot/stdout.filtered
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stdout.expected $testroot/stdout.filtered
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	test_done "$testroot" "$ret"
+}
+
+test_parseargs "$@"
+run_test test_file_changed
+run_test test_many_commits_not_summarized
+run_test test_many_commits_summarized
+run_test test_branch_created
+run_test test_branch_removed
+run_test test_tag_created
+run_test test_tag_changed