Commit Diff


commit - 0b0f79d722c25dc127717eb805d8c6eaf2ca592d
commit + 9c59e004ed81c97dd7a7e1f2160159772fab42e7
blob - f2e9ba498f11eb6bad0c249cab63002a4f30e4b0
blob + 1214a7249122f0f5ea9308b391fc7e87997df6b5
--- gotsys/gotsys.conf.5
+++ gotsys/gotsys.conf.5
@@ -251,6 +251,23 @@ If no rule matches, access to the repository is denied
 .Pp
 The available repository configuration directives are as follows:
 .Bl -tag -width Ds
+.It Ic head Ar branch
+Point the repository's symbolic
+.Pa HEAD
+reference at the specified
+.Ar branch .
+If not specified,
+.Pa HEAD
+will point at the branch
+.Dq main ,
+regardless of whether this branch actually exists in the repository.
+.Pp
+If
+.Pa HEAD
+points at a non-existent branch then clients may fail to clone the repository
+because they rely on
+.Pa HEAD
+to determine which branch to fetch by default.
 .It Ic deny Ar identity
 Deny repository access to users with the username
 .Ar identity .
@@ -524,6 +541,7 @@ repository "openbsd/ports" {
 
 repository "secret" {
 	permit rw flan_hacker
+	head "refs/heads/private"
 .\"
 .\"	protect branch "main"
 .\"	protect tag namespace "refs/tags/"
blob - a902536f3326eb4b8fb5c450d12d842ed62b7c9d
blob + 707cce7d91deba214fd9735be9c66dd0b08d64fa
--- gotsys/gotsys.h
+++ gotsys/gotsys.h
@@ -94,6 +94,7 @@ struct gotsys_repo {
 	TAILQ_ENTRY(gotsys_repo) entry;
 
 	char name[NAME_MAX];
+	char *headref;
 
 	struct gotsys_access_rule_list access_rules;
 
blob - f69319dfbc7554ce40536131df9eba7954eed253
blob + 3b62d252431acad5bdf4e042b6138ebb898c4a88
--- gotsys/parse.y
+++ gotsys/parse.y
@@ -129,7 +129,7 @@ typedef struct {
 
 %token	ERROR USER GROUP REPOSITORY PERMIT DENY RO RW AUTHORIZED KEY
 %token	PROTECT NAMESPACE BRANCH TAG REFERENCE RELAY PORT PASSWORD
-%token	NOTIFY EMAIL FROM REPLY TO URL INSECURE HMAC
+%token	NOTIFY EMAIL FROM REPLY TO URL INSECURE HMAC HEAD
 
 %token	<v.string>	STRING
 %token	<v.number>	NUMBER
@@ -675,6 +675,65 @@ repoopts1	: PERMIT RO numberstring {
 		}
 		| protect
 		| notify
+		| HEAD STRING {
+			const struct got_error *err;
+			char *branchname = $2;
+
+			if (!got_ref_name_is_valid($2)) {
+				err = got_error_path($2, GOT_ERR_BAD_REF_NAME);
+				yyerror("%s", err->msg);
+				free($2);
+				YYERROR;
+			}
+
+			if (strncmp($2, "refs/heads/", 11) == 0) {
+				branchname += 11;
+			} else if (strncmp($2, "refs/", 5) == 0) {
+				err = got_error_fmt(GOT_ERR_BAD_REF_NAME,
+				    "HEAD branch must be in the "
+				    "\"refs/heads/\" reference namespace: %s",
+				    $2);
+				yyerror("%s", err->msg);
+				free($2);
+				YYERROR;
+			}
+
+			while (branchname[0] == '/')
+				branchname++;
+			got_path_strip_trailing_slashes(branchname);
+			if (strlen(branchname) == 0) {
+				err = got_error_path($2, GOT_ERR_BAD_REF_NAME);
+				yyerror("%s", err->msg);
+				free($2);
+				YYERROR;
+			}
+			if (branchname[0] == '-') {
+				err = got_error_path(branchname,
+				    GOT_ERR_REF_NAME_MINUS);
+				yyerror("%s", err->msg);
+				free($2);
+				YYERROR;
+			}
+
+			if (strcmp(new_repo->name, "gotsys") == 0 ||
+			    strcmp(new_repo->name, "gotsys.git") == 0) {
+				err = got_error_msg(GOT_ERR_BAD_REF_NAME,
+				    "HEAD of the \"gotsys\" repository "
+				    "cannot be overridden");
+				yyerror("%s", err->msg);
+				free($2);
+				YYERROR;
+			}
+
+			if (asprintf(&new_repo->headref, "refs/heads/%s",
+			    branchname) == -1) {
+				err = got_error_from_errno("asprintf");
+				yyerror("%s", err->msg);
+				free($2);
+				YYERROR;
+			}
+			free($2);
+		}
 		;
 
 repoopts2	: repoopts2 repoopts1 nl
@@ -740,6 +799,7 @@ lookup(char *s)
 		{ "email",			EMAIL },
 		{ "from",			FROM },
 		{ "group",			GROUP },
+		{ "head",			HEAD },
 		{ "hmac",			HMAC },
 		{ "insecure",			INSECURE },
 		{ "key",			KEY },
blob - c6eadc64e2347a901f38d067acfc2afa71a423a9
blob + d9b5ab290a0fa6d2902a648ad02a8e43a6f9e7e3
--- gotsysd/gotsysd.h
+++ gotsysd/gotsysd.h
@@ -314,6 +314,14 @@ struct gotsysd_imsg_sysconf_rmkeys_param {
 	uid_t uid_end;
 };
 
+/* Structture for GOTSYSD_IMSG_SYSCONF_REPO_CREATE. */
+struct gotsysd_imsg_sysconf_repo_create {
+	size_t name_len;
+	size_t headref_len;
+
+	/* Followed by name_len + headref_len bytes. */
+};
+
 /* 
  * Structure for messages sent via gotsys_imsg_send_users():
  * GOTSYSD_IMSG_SYSCONF_USERS
@@ -373,8 +381,9 @@ struct gotsysd_imsg_sysconf_authorized_key {
 /* Structure for GOTSYSD_IMSG_SYSCONF_REPO, */
 struct gotsysd_imsg_sysconf_repo {
 	size_t name_len;
+	size_t headref_len;
 
-	/* Followed by name_len bytes. */
+	/* Followed by name_len + headref_len bytes. */
 
 	/*
 	 * Followed by GOTSYSD_IMSG_SYSCONF_ACCESS_RULE for access rules,
blob - fa24c3c16fd02207056aba199ffb61cbcf1a97cc
blob + 1fb9acda00b5b1a23dce239dab2f445698ef8859
--- gotsysd/libexec/gotsys-repo-create/Makefile
+++ gotsysd/libexec/gotsys-repo-create/Makefile
@@ -4,7 +4,8 @@
 
 PROG=		gotsys-repo-create
 SRCS=		gotsys-repo-create.c error.c hash.c pollfd.c path.c \
-		imsg.c gotsys_conf.c gotsys_imsg.c repository_init.c
+		imsg.c gotsys_conf.c gotsys_imsg.c repository_init.c \
+		reference_parse.c lockfile.c
 
 CPPFLAGS = -I${.CURDIR}/../../../include -I${.CURDIR}/../../../lib \
 	-I${.CURDIR}/../../../gotsys -I${.CURDIR}/../../ -I${.CURDIR}
blob - a8b1c77ee85881667f37d0204cf050732b9083ac
blob + c4c4a136dcda41c4d605d1b4da5f06dc94e3517a
--- gotsysd/libexec/gotsys-repo-create/gotsys-repo-create.c
+++ gotsysd/libexec/gotsys-repo-create/gotsys-repo-create.c
@@ -40,7 +40,16 @@
 #include "got_path.h"
 #include "got_object.h"
 #include "got_repository.h"
+#include "got_reference.h"
 
+#include "got_lib_hash.h"
+#include "got_lib_delta.h"
+#include "got_lib_object.h"
+#include "got_lib_pack.h"
+#include "got_lib_object_cache.h"
+#include "got_lib_repository.h"
+#include "got_lib_lockfile.h"
+
 #include "gotsysd.h"
 #include "gotsys.h"
 
@@ -96,11 +105,103 @@ chmod_700_repo(const char *repo_name)
 }
 
 static const struct got_error *
+set_head_ref(int repos_dir_fd, const char *repo_name, const char *refname)
+{
+	const struct got_error *err = NULL;
+	char relpath[_POSIX_PATH_MAX];
+	struct got_lockfile *lf = NULL;
+	int ret, fd = -1;
+	struct stat sb;
+	char *content = NULL, *buf = NULL;
+	size_t content_len;
+	ssize_t w;
+
+	ret = snprintf(relpath, sizeof(relpath),
+	    "%s/%s", repo_name, GOT_HEAD_FILE);
+	if (ret == -1)
+		return got_error_from_errno("snprintf");
+	if ((size_t)ret >= sizeof(relpath)) {
+		return got_error_msg(GOT_ERR_NO_SPACE,
+		    "repository path too long");
+	}
+
+	ret = asprintf(&content, "ref: %s\n", refname);
+	if (ret == -1)
+		return got_error_from_errno("asprintf");
+	content_len = ret;
+
+	err = got_lockfile_lock(&lf, relpath, repos_dir_fd);
+	if (err && (err->code != GOT_ERR_ERRNO || errno != ENOENT))
+		goto done;
+	err = NULL;
+	
+	fd = openat(repos_dir_fd, relpath,
+	    O_RDWR | O_CREAT | O_NOFOLLOW | O_CLOEXEC,
+	    GOT_DEFAULT_FILE_MODE);
+	if (fd == -1) {
+		err = got_error_from_errno2("open", relpath);
+		goto done;
+	}
+
+	if (fstat(fd, &sb) == -1) {
+		err = got_error_from_errno2("stat", relpath);
+		goto done;
+	}
+
+	if (sb.st_size == content_len) {
+		ssize_t r;
+
+		buf = malloc(content_len);
+		if (buf == NULL) {
+			err = got_error_from_errno("malloc");
+			goto done;
+		}
+
+		r = read(fd, buf, content_len);
+		if (r == -1) {
+			err = got_error_from_errno2("read", relpath);
+			goto done;
+		}
+
+		if (r == content_len && memcmp(buf, content, content_len) == 0)
+			goto done; /* HEAD already has the desired content */
+	}
+
+	if (ftruncate(fd, 0L) == -1) {
+		err = got_error_from_errno2("ftruncate", relpath);
+		goto done;
+	}
+
+	w = write(fd, content, content_len);
+	if (w == -1)
+		err = got_error_from_errno("write");
+	else if (w != content_len) {
+		err = got_error_fmt(GOT_ERR_IO,
+		    "wrote %zd of %zu bytes to %s", w, content_len, relpath);
+	}
+done:
+	free(content);
+	free(buf);
+	if (lf) {
+		const struct got_error *unlock_err;
+
+		unlock_err = got_lockfile_unlock(lf, repos_dir_fd);
+		if (unlock_err && err == NULL)
+			err = unlock_err;
+	}
+	if (fd != -1 && close(fd) == -1 && err == NULL)
+		err = got_error_from_errno("close");
+	return err;
+}
+
+static const struct got_error *
 create_repo(struct imsg *imsg)
 {
 	const struct got_error *err = NULL;
 	size_t datalen, namelen;
+	struct gotsysd_imsg_sysconf_repo_create param;
 	char *repo_name = NULL;
+	char *headref = NULL;
 	char *fullname = NULL;
 	char *abspath = NULL;
 
@@ -108,13 +209,39 @@ create_repo(struct imsg *imsg)
 		return got_error(GOT_ERR_PRIVSEP_MSG);
 
 	datalen = imsg->hdr.len - IMSG_HEADER_SIZE;
-	if (datalen == 0 || datalen > NAME_MAX)
+	if (datalen < sizeof(param))
 		return got_error(GOT_ERR_PRIVSEP_LEN);
+	memcpy(&param, imsg->data, sizeof(param));
 
-	repo_name = strndup(imsg->data, datalen);
+	if (datalen != sizeof(param) + param.name_len + param.headref_len ||
+	    param.name_len == 0)
+		return got_error(GOT_ERR_PRIVSEP_LEN);
+
+	repo_name = strndup(imsg->data + sizeof(param), param.name_len);
 	if (repo_name == NULL)
 		return got_error_from_errno("strndup");
+	if (strlen(repo_name) != param.name_len) {
+		err = got_error(GOT_ERR_PRIVSEP_LEN);
+		goto done;
+	}
 
+	if (param.headref_len > 0) {
+		headref = strndup(imsg->data + sizeof(param) + param.name_len,
+		    param.headref_len);
+		if (headref == NULL) {
+			err = got_error_from_errno("strndup");
+			goto done;
+		}
+		if (strlen(headref) != param.headref_len) {
+			err = got_error(GOT_ERR_PRIVSEP_LEN);
+			goto done;
+		}
+		if (!got_ref_name_is_valid(headref)) {
+			err = got_error_path(headref, GOT_ERR_BAD_REF_NAME);
+			goto done;
+		}
+	}
+
 	err = gotsys_conf_validate_repo_name(repo_name);
 	if (err)
 		goto done;
@@ -137,12 +264,18 @@ create_repo(struct imsg *imsg)
 	}
 
 	if (mkdirat(repos_dir_fd, fullname, S_IRWXU) == -1) {
-		if (errno == EEXIST)
+		if (errno == EEXIST) {
 			err = chmod_700_repo(fullname);
-		else
+			if (err)
+				goto done;
+			if (headref) {
+				err = set_head_ref(repos_dir_fd, fullname,
+				    headref);
+			}
+		} else
 			err = got_error_from_errno2("mkdir", abspath);
 	} else
-		err = got_repo_init(abspath, NULL, GOT_HASH_SHA1);
+		err = got_repo_init(abspath, headref, GOT_HASH_SHA1);
 done:
 	free(repo_name);
 	free(fullname);
@@ -249,7 +382,7 @@ main(int argc, char **argv)
 	gotsys_conf_init(&gotsysconf);
 
 #ifndef PROFILE
-	if (pledge("stdio rpath wpath cpath fattr chown getpw id unveil",
+	if (pledge("stdio rpath wpath cpath fattr chown flock getpw id unveil",
 	    NULL) == -1)
 		err(1, "pledge");
 #endif
@@ -286,7 +419,8 @@ main(int argc, char **argv)
 	warn("running as %s", username);
 
 #ifndef PROFILE
-	if (pledge("stdio rpath wpath cpath fattr chown unveil", NULL) == -1) {
+	if (pledge("stdio rpath wpath cpath fattr chown flock unveil",
+	    NULL) == -1) {
 		error = got_error_from_errno("pledge");
 		goto done;
 	}
blob - eeef346dec6e5b64679f262f7edd5f89fc800fcc
blob + f168e6413061769bae46da78c65caefaf141384f
--- gotsysd/sysconf.c
+++ gotsysd/sysconf.c
@@ -546,13 +546,38 @@ static const struct got_error *
 create_repos(struct gotsysd_imsgev *iev)
 {
 	struct gotsys_repo *repo;
+	struct gotsysd_imsg_sysconf_repo_create ireq;
 
 	TAILQ_FOREACH(repo, &gotsysconf.repos, entry) {
-		if (gotsysd_imsg_compose_event(iev,
-		    GOTSYSD_IMSG_SYSCONF_REPO_CREATE, GOTSYSD_PROC_SYSCONF,
-		    -1, repo->name, strlen(repo->name)) == -1)
-			return got_error_from_errno("imsg compose "
+		struct ibuf *wbuf = NULL;
+		size_t len;
+
+		memset(&ireq, 0, sizeof(ireq));
+
+		ireq.name_len = strlen(repo->name);
+		if (repo->headref)
+			ireq.headref_len = strlen(repo->headref);
+
+		len = sizeof(ireq) + ireq.name_len + ireq.headref_len;
+		wbuf = imsg_create(&iev->ibuf,
+		    GOTSYSD_IMSG_SYSCONF_REPO_CREATE,
+		    GOTSYSD_PROC_SYSCONF, gotsysd_sysconf.pid, len);
+		if (wbuf == NULL)
+			return got_error_from_errno("imsg_create "
 			    "SYSCONF_REPO_CREATE");
+
+		if (imsg_add(wbuf, &ireq, sizeof(ireq)) == -1)
+			return got_error_from_errno("imsg_add "
+			    "SYSCONF_REPO_CREATE");
+		if (imsg_add(wbuf, repo->name, ireq.name_len) == -1)
+			return got_error_from_errno("imsg_add "
+			    "SYSCONF_REPO_CREATE");
+		if (ireq.headref_len > 0 &&
+		    imsg_add(wbuf, repo->headref, ireq.headref_len) == -1)
+			return got_error_from_errno("imsg_add "
+			    "SYSCONF_REPO_CREATE");
+		imsg_close(&iev->ibuf, wbuf);
+		gotsysd_imsg_event_add(iev);
 	}
 
 	if (gotsysd_imsg_compose_event(iev,
blob - 4f83dff8806c61dfbf581ddad2bc5e74b275a967
blob + fa11004e9e14a9712ef0b3c3ea8e2c4dfc98123a
--- lib/gotsys_conf.c
+++ lib/gotsys_conf.c
@@ -174,6 +174,9 @@ gotsys_repo_free(struct gotsys_repo *repo)
 		gotsys_notification_target_free(target);
 	}
 
+	free(repo->headref);
+	repo->headref = NULL;
+
 	free(repo);
 }
 
blob - 87abeb7c7095519d423cde7b9a91554386478b15
blob + 28bdc76a4a5c4aadfcb1362a99a3ed0a9f5c6c71
--- lib/gotsys_imsg.c
+++ lib/gotsys_imsg.c
@@ -666,10 +666,14 @@ send_repo(struct gotsysd_imsgev *iev, struct gotsys_re
 	struct gotsys_access_rule *rule;
 	struct ibuf *wbuf = NULL;
 
+	memset(&irepo, 0, sizeof(irepo));
+
 	irepo.name_len = strlen(repo->name);
+	if (repo->headref)
+		irepo.headref_len = strlen(repo->headref);
 
 	wbuf = imsg_create(&iev->ibuf, GOTSYSD_IMSG_SYSCONF_REPO,
-	    0, 0, sizeof(irepo) + irepo.name_len);
+	    0, 0, sizeof(irepo) + irepo.name_len + irepo.headref_len);
 	if (wbuf == NULL)
 		return got_error_from_errno("imsg_create SYSCONF_REPO");
 
@@ -677,6 +681,9 @@ send_repo(struct gotsysd_imsgev *iev, struct gotsys_re
 		return got_error_from_errno("imsg_add SYSCONF_REPO");
 	if (imsg_add(wbuf, repo->name, irepo.name_len) == -1)
 		return got_error_from_errno("imsg_add SYSCONF_REPO");
+	if (repo->headref &&
+	    imsg_add(wbuf, repo->headref, irepo.headref_len) == -1)
+		return got_error_from_errno("imsg_add SYSCONF_REPO");
 
 	imsg_close(&iev->ibuf, wbuf);
 	err = gotsysd_imsg_flush(&iev->ibuf);
@@ -727,7 +734,7 @@ gotsys_imsg_recv_repository(struct gotsys_repo **repo,
 	const struct got_error *err;
 	struct gotsysd_imsg_sysconf_repo irepo;
 	size_t datalen;
-	char *name = NULL;
+	char *name = NULL, *headref = NULL;
 
 	*repo = NULL;
 
@@ -736,7 +743,8 @@ gotsys_imsg_recv_repository(struct gotsys_repo **repo,
 		return got_error(GOT_ERR_PRIVSEP_LEN);
 	
 	memcpy(&irepo, imsg->data, sizeof(irepo));
-	if (datalen != sizeof(irepo) + irepo.name_len)
+	if (datalen != sizeof(irepo) + irepo.name_len + irepo.headref_len ||
+	    irepo.name_len == 0)
 		return got_error(GOT_ERR_PRIVSEP_LEN);
 
 	name = strndup(imsg->data + sizeof(irepo), irepo.name_len);
@@ -744,12 +752,31 @@ gotsys_imsg_recv_repository(struct gotsys_repo **repo,
 		return got_error_from_errno("strndup");
 	if (strlen(name) != irepo.name_len) {
 		err = got_error(GOT_ERR_PRIVSEP_LEN);
-		free(name);
-		return err;
+		goto done;
 	}
 
+	if (irepo.headref_len > 0) {
+		headref = strndup(imsg->data + sizeof(irepo) + irepo.name_len,
+		    irepo.headref_len);
+		if (headref == NULL) {
+			err = got_error_from_errno("strndup");
+			goto done;
+		}
+		if (strlen(headref) != irepo.headref_len) {
+			err = got_error(GOT_ERR_PRIVSEP_LEN);
+			goto done;
+		}
+	}
+
 	err = gotsys_conf_new_repo(repo, name);
+	if (err)
+		goto done;
+
+	(*repo)->headref = headref;
+done:
 	free(name);
+	if (err)
+		free(headref);
 	return err;
 }
 
blob - e31173ba47a03503c2f655a30d71c826508b06c2
blob + 1e68e76f694a8b64e2cac3adcdd9e8140c38e3fc
--- regress/gotsysd/test_gotsysd.sh
+++ regress/gotsysd/test_gotsysd.sh
@@ -1183,6 +1183,137 @@ EOF
 	test_done "$testroot" "0"
 }
 
+test_set_head() {
+	local testroot=`test_init set_head 1`
+
+	# An attempt to set the HEAD of gotsys.git is an error.
+	cat > ${testroot}/bad-gotsys.conf <<EOF
+user ${GOTSYSD_TEST_USER} {
+	password "${crypted_vm_pw}" 
+	authorized key ${sshkey}
+}
+
+repository "gotsys" {
+	permit rw ${GOTSYSD_TEST_USER}
+	head "refs/heads/foo"
+}
+EOF
+	gotsys check -f ${testroot}/bad-gotsys.conf \
+		> $testroot/stdout  2> $testroot/stderr
+	ret=$?
+	if [ $ret -eq 0 ]; then
+		echo "gotsys check succeeded unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	cat > $testroot/stderr.expected <<EOF
+gotsys: ${testroot}/bad-gotsys.conf: line 8: HEAD of the "gotsys" repository cannot be overridden
+EOF
+	cmp -s $testroot/stderr.expected $testroot/stderr
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		diff -u $testroot/stderr.expected $testroot/stderr
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	got checkout -q $testroot/${GOTSYS_REPO} $testroot/wt >/dev/null
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got checkout failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	crypted_vm_pw=`echo ${GOTSYSD_VM_PASSWORD} | encrypt | tr -d '\n'`
+	crypted_pw=`echo ${GOTSYSD_DEV_PASSWORD} | encrypt | tr -d '\n'`
+	sshkey=`cat ${GOTSYSD_SSH_PUBKEY}`
+	cat > ${testroot}/wt/gotsys.conf <<EOF
+group slackers
+
+user ${GOTSYSD_TEST_USER} {
+	password "${crypted_vm_pw}" 
+	authorized key ${sshkey}
+}
+user ${GOTSYSD_DEV_USER} {
+	password "${crypted_pw}" 
+	authorized key ${sshkey}
+}
+repository gotsys.git {
+	permit rw ${GOTSYSD_TEST_USER}
+	permit rw ${GOTSYSD_DEV_USER}
+}
+repository "foo" {
+	permit rw ${GOTSYSD_DEV_USER}
+	permit ro anonymous
+	head foo
+}
+EOF
+	(cd ${testroot}/wt && got commit -m "set foo as head" >/dev/null)
+	local commit_id=`git_show_head $testroot/${GOTSYS_REPO}`
+
+	got send -q -i ${GOTSYSD_SSH_KEY} -r ${testroot}/${GOTSYS_REPO}
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got send failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	# Wait for gotsysd to apply the new configuration.
+	echo "$commit_id" > $testroot/stdout.expected
+	for i in 1 2 3 4 5; do
+		sleep 1
+		ssh -i ${GOTSYSD_SSH_KEY} root@${VMIP} \
+			cat /var/db/gotsysd/commit > $testroot/stdout
+		if cmp -s $testroot/stdout.expected $testroot/stdout; then
+			break;
+		fi
+	done
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "gotsysd failed to apply configuration" >&2
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	# Create branch "foo" in foo.git.
+	got clone -q -i ${GOTSYSD_SSH_KEY} -b main \
+		${GOTSYSD_DEV_USER}@${VMIP}:foo.git $testroot/foo.git
+	got branch -r $testroot/foo.git -c main foo
+	got send -q -i ${GOTSYSD_SSH_KEY} -r $testroot/foo.git -b foo
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got send failed unexpectedly" >&2
+		return 1
+	fi
+
+	# The foo repository should now advertise refs/heads/foo as HEAD.
+	got clone -q -l anonymous@${VMIP}:foo.git | egrep '^HEAD:' \
+		> $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "got clone -l failed unexpectedly" >&2
+		test_done "$testroot" 1
+		return 1
+	fi
+
+	echo "HEAD: refs/heads/foo" > $testroot/stdout.expected
+	cmp -s $testroot/stdout.expected $testroot/stdout
+	ret=$?
+	if [ $ret -ne 0 ]; then
+		echo "gotsysd failed to apply configuration" >&2
+		diff -u $testroot/stdout.expected $testroot/stdout
+		test_done "$testroot" "$ret"
+		return 1
+	fi
+
+	test_done "$testroot" "$ret"
+}
+
 test_parseargs "$@"
 run_test test_user_add
 run_test test_user_mod
@@ -1192,3 +1323,4 @@ run_test test_group_del
 run_test test_repo_create
 run_test test_user_anonymous
 run_test test_bad_gotsysconf
+run_test test_set_head