Commit Diff


commit - a72597083f64cda4978a3bce3eb4dba6ae2b6d32
commit + ce1bfad9f1f7c6b1599e0bfed0a1b169d4a30781
blob - 2b1255118b91d4c7edef0a6016130ed9de07bfcd
blob + 385ea8c6903bd7108ab3f4de6767bc377bb43dba
--- gitwrapper/gitwrapper.c
+++ gitwrapper/gitwrapper.c
@@ -170,7 +170,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 - 06930323b2023b42abe628c2dfe3bc4a12762e70
blob + 8ba95cce4988f25eb3c716683e8c3e1952f930e6
--- gotd/gotd.c
+++ gotd/gotd.c
@@ -46,6 +46,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"
@@ -62,6 +63,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]))
@@ -314,6 +316,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) {
@@ -545,7 +550,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,
@@ -557,7 +562,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,
@@ -749,7 +754,8 @@ static const char *gotd_proc_names[PROC_MAX] = {
 	"session_write",
 	"repo_read",
 	"repo_write",
-	"gitwrapper"
+	"gitwrapper",
+	"notify"
 };
 
 static void
@@ -1134,6 +1140,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;
@@ -1234,7 +1306,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;
@@ -1269,6 +1341,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));
 
@@ -1279,14 +1352,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.
@@ -1386,7 +1473,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;
 
@@ -1426,6 +1513,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)
 {
@@ -1489,6 +1610,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:
@@ -1568,6 +1692,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);
 	}
@@ -1626,6 +1753,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)
@@ -1863,6 +2021,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)
 {
@@ -1877,12 +2054,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;
@@ -1899,6 +2080,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)
@@ -1968,6 +2152,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) {
@@ -1996,6 +2181,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);
 
@@ -2068,11 +2260,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:
@@ -2098,6 +2293,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");
@@ -2116,11 +2324,25 @@ main(int argc, char **argv)
 		drop_privs(pw);
 
 		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);
 	}
@@ -2130,6 +2352,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();
 
@@ -2147,10 +2373,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 - c9dacfdc7fc166370fa8e6384c7fef0a6ddc3fac
blob + 5d41c4eb56cc8a5acf41912a51075953aa9f3e79
--- gotd/gotd.h
+++ gotd/gotd.h
@@ -24,6 +24,23 @@
 #define GOTD_EMPTY_PATH	"/var/empty"
 #endif
 
+#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
@@ -44,6 +61,7 @@ enum gotd_procid {
 	PROC_REPO_READ,
 	PROC_REPO_WRITE,
 	PROC_GITWRAPPER,
+	PROC_NOTIFY,
 	PROC_MAX,
 };
 
@@ -72,6 +90,32 @@ struct gotd_access_rule {
 	char *identifier;
 };
 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;
@@ -83,6 +127,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);
 
@@ -96,6 +144,7 @@ enum gotd_session_state {
 	GOTD_STATE_EXPECT_PACKFILE,
 	GOTD_STATE_EXPECT_DONE,
 	GOTD_STATE_DONE,
+	GOTD_STATE_NOTIFY,
 };
 
 struct gotd_client_capability {
@@ -123,6 +172,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;
@@ -196,6 +247,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. */
@@ -428,6 +485,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. */
@@ -446,13 +506,35 @@ struct gotd_imsg_auth {
 	uint32_t client_id;
 };
 
-int enter_chroot(const char *);
+/* 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 + 44ccd53effeef92d860796c2bea93b56f5aa539a (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 %s:%s", hostname, port);
+
+	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 - 7f0792c2d67f76688341bc2d4b073d5d09180b4f
blob + 2d96ebe2bd1575fbb197e903423ccfec7e848614
--- 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))
@@ -1069,12 +1412,193 @@ conf_protect_branch(struct gotd_repo *repo, char *bran
 			yyerror("got_pathlist_insert: %s", error->msg);
 		else
 			yyerror("duplicate protect branch %s", branchname);
+		return -1;
+	}
+
+	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 - de2eb3a42ab209cd4a4e18b35625f81b1d88e57a
blob + 257518e5da203ab6fc6c9aace1cc717c714799ed
--- gotd/privsep_stub.c
+++ gotd/privsep_stub.c
@@ -63,3 +63,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 + 8b7babf0b8bfda73dde136e6486c27085a6c14f4 (mode 644)
--- /dev/null
+++ gotd/notify.c
@@ -0,0 +1,522 @@
+/*
+ * 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/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 - 27ecb3cb1f2dfa87ae9b75649146a015b1e45941
blob + 7e12b3db952dc5257bc13548172003e037a91305
--- gotd/repo_write.c
+++ gotd/repo_write.c
@@ -20,6 +20,7 @@
 #include <sys/stat.h>
 #include <sys/types.h>
 
+#include <ctype.h>
 #include <event.h>
 #include <errno.h>
 #include <imsg.h>
@@ -39,6 +40,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"
@@ -72,6 +77,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 {
@@ -1625,8 +1636,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)
 {
@@ -1723,6 +2302,13 @@ repo_write_dispatch_session(int fd, short event, void 
 			err = update_refs(iev);
 			if (err) {
 				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:
@@ -1838,6 +2424,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)
@@ -1860,6 +2447,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);
 
@@ -1892,6 +2483,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 - 9754b5ea953bf492a5912577904d94d66b1426a8
blob + d764c20d06776aa305d7f4c3e4a40a71cbd913f9
--- gotd/session.c
+++ gotd/session.c
@@ -54,14 +54,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;
@@ -80,6 +91,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;
@@ -398,10 +410,253 @@ begin_ref_updates(struct gotd_session_client *client, 
 		return got_error(GOT_ERR_PRIVSEP_MSG);
 
 	client->nref_updates = istart.nref_updates;
+	return NULL;
+}
+
+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)
 {
@@ -410,6 +665,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;
@@ -452,6 +708,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)
@@ -491,7 +750,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 {
@@ -520,10 +781,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);
@@ -544,7 +807,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;
 		}
 
 	}
@@ -561,6 +834,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)
 {
@@ -597,7 +885,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__);
@@ -628,6 +916,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;
@@ -639,6 +932,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);
@@ -647,8 +942,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);
 	}
@@ -1296,9 +1604,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;
@@ -1306,6 +1617,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;
@@ -1318,7 +1634,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;
@@ -1419,6 +1845,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)
@@ -1455,19 +1884,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;
@@ -1519,10 +1953,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