cmd/gitmirror: refactor

In preparation for adding a new mirror destination, refactor. A lot of
this is subjective, and arguably just my personal taste. Still, I think
it's an improvement.

Generally, I don't intend to change behavior here. Two small things:
first, the list of repositories to watch is read from the repos package
rather than Gerrit; I don't think that matters, but please correct me if
I'm wrong. Second, we start a repo Loop for every repository, not just
those being mirrored. I think this was an oversight, and it resulted in
the repository not being eagerly fetched.

Notable refactorings:
- Remove the package vars: "tickler" channels move into Repo, others
  into the new top-level mirror type.
- Remove some overly fancy logging; I personally don't mind reading
  "attempt 0" in a message, etc.
- Restructure initialization logic to hopefully be a little bit better
  grouped and more readable.
- Introduce the concept of multiple destinations, but don't use it yet.
- Rather than checking for the correct remotes during initialization,
  just blow them away and recreate them.

Change-Id: I906f45fb93e2e4a2552aea5560eeb244174bfe69
Reviewed-on: https://go-review.googlesource.com/c/build/+/323970
Trust: Heschi Kreinick <heschi@google.com>
Run-TryBot: Heschi Kreinick <heschi@google.com>
TryBot-Result: Go Bot <gobot@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/cmd/gitmirror/gitmirror.go b/cmd/gitmirror/gitmirror.go
index 2dfe4b3..2a7d520 100644
--- a/cmd/gitmirror/gitmirror.go
+++ b/cmd/gitmirror/gitmirror.go
@@ -3,7 +3,7 @@
 // license that can be found in the LICENSE file.
 
 // The gitmirror binary watches the specified Gerrit repositories for
-// new commits and syncs them to GitHub.
+// new commits and syncs them to mirror repositories.
 //
 // It also serves tarballs over HTTP for the build system.
 package main
@@ -15,7 +15,6 @@
 	"fmt"
 	"io/ioutil"
 	"log"
-	"net"
 	"net/http"
 	"os"
 	"os/exec"
@@ -34,6 +33,7 @@
 	"golang.org/x/build/maintner"
 	"golang.org/x/build/maintner/godata"
 	repospkg "golang.org/x/build/repos"
+	"golang.org/x/sync/errgroup"
 )
 
 const (
@@ -41,166 +41,189 @@
 )
 
 var (
-	httpAddr     = flag.String("http", "", "If non-empty, the listen address to run an HTTP server on")
-	cacheDir     = flag.String("cachedir", "", "git cache directory. If empty a temp directory is made.")
-	pollInterval = flag.Duration("poll", 60*time.Second, "Remote repo poll interval")
-	mirror       = flag.Bool("mirror", false, "whether to mirror to github; if disabled, it only runs in HTTP archive server mode")
+	flagHTTPAddr     = flag.String("http", "", "If non-empty, the listen address to run an HTTP server on")
+	flagCacheDir     = flag.String("cachedir", "", "git cache directory. If empty a temp directory is made.")
+	flagPollInterval = flag.Duration("poll", 60*time.Second, "Remote repo poll interval")
+	flagMirror       = flag.Bool("mirror", false, "whether to mirror to mirror repos; if disabled, it only runs in HTTP archive server mode")
 )
 
-var gerritClient = gerrit.NewClient("https://go-review.googlesource.com", gerrit.NoAuth)
-
 func main() {
 	flag.Parse()
+
+	if *flagHTTPAddr != "" {
+		go func() {
+			err := http.ListenAndServe(*flagHTTPAddr, nil)
+			log.Fatalf("http server failed: %v", err)
+		}()
+	}
+	http.HandleFunc("/debug/env", handleDebugEnv)
+	http.HandleFunc("/debug/goroutines", handleDebugGoroutines)
+
 	if err := gitauth.Init(); err != nil {
 		log.Fatalf("gitauth: %v", err)
 	}
 
-	log.Printf("gitmirror running.")
+	cacheDir, err := createCacheDir()
+	if err != nil {
+		log.Fatalf("creating cache dir: %v", err)
+	}
 
-	sc := secret.MustNewClient()
-	defer sc.Close()
+	m := &mirror{
+		repos:        map[string]*Repo{},
+		cacheDir:     cacheDir,
+		gerritClient: gerrit.NewClient("https://go-review.googlesource.com", gerrit.NoAuth),
+	}
+	http.HandleFunc("/", m.handleRoot)
 
-	go pollGerritAndTickle()
-	go subscribeToMaintnerAndTickleLoop()
-	err := runGitMirror(sc)
-	log.Fatalf("gitmirror exiting after failure: %v", err)
-}
+	if err := m.addRepos(); err != nil {
+		log.Fatalf("adding repos: %v", err)
+	}
 
-// runGitMirror is a little wrapper so we can use defer and return to signal
-// errors. It should only return a non-nil error.
-func runGitMirror(sc *secret.Client) error {
-	if *mirror {
-		sshDir := filepath.Join(homeDir(), ".ssh")
-		sshKey := filepath.Join(sshDir, "id_ed25519")
-		if _, err := os.Stat(sshKey); err == nil {
-			log.Printf("Using github ssh key at %v", sshKey)
-		} else {
-
-			ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
-			defer cancel()
-
-			if privKey, err := sc.Retrieve(ctx, secret.NameGitHubSSHKey); err == nil && len(privKey) > 0 {
-				if err := os.MkdirAll(sshDir, 0700); err != nil {
-					return err
-				}
-				if err := ioutil.WriteFile(sshKey, []byte(privKey+"\n"), 0600); err != nil {
-					return err
-				}
-				log.Printf("Wrote %s from GCP secret manager.", sshKey)
-			} else {
-				return fmt.Errorf("Can't mirror to github without %q GCP secret manager or file %v", secret.NameGitHubSSHKey, sshKey)
-			}
+	if *flagMirror {
+		if err := writeCredentials(); err != nil {
+			log.Fatalf("writing ssh credentials: %v", err)
+		}
+		if err := m.runMirrors(); err != nil {
+			log.Fatalf("running mirror: %v", err)
 		}
 	}
 
-	if *cacheDir == "" {
+	for _, repo := range m.repos {
+		go repo.Loop()
+	}
+	go m.pollGerritAndTickle()
+	go m.subscribeToMaintnerAndTickleLoop()
+
+	select {}
+}
+
+func writeCredentials() error {
+	sc := secret.MustNewClient()
+	defer sc.Close()
+
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return err
+	}
+	sshDir := filepath.Join(home, ".ssh")
+	sshKey := filepath.Join(sshDir, "id_ed25519")
+	if _, err := os.Stat(sshKey); err == nil {
+		log.Printf("Using github ssh key at %v", sshKey)
+		return nil
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+
+	privKey, err := sc.Retrieve(ctx, secret.NameGitHubSSHKey)
+	if err != nil || len(privKey) == 0 {
+		return fmt.Errorf("can't mirror to github without %q GCP secret manager or file %v", secret.NameGitHubSSHKey, sshKey)
+	}
+	if err := os.MkdirAll(sshDir, 0700); err != nil {
+		return err
+	}
+	if err := ioutil.WriteFile(sshKey, []byte(privKey+"\n"), 0600); err != nil {
+		return err
+	}
+	log.Printf("Wrote %s from GCP secret manager.", sshKey)
+	return nil
+}
+
+func createCacheDir() (string, error) {
+	if *flagCacheDir == "" {
 		dir, err := ioutil.TempDir("", "gitmirror")
 		if err != nil {
 			log.Fatal(err)
 		}
 		defer os.RemoveAll(dir)
-		*cacheDir = dir
+		return dir, nil
+	}
+
+	fi, err := os.Stat(*flagCacheDir)
+	if os.IsNotExist(err) {
+		if err := os.MkdirAll(*flagCacheDir, 0755); err != nil {
+			return "", fmt.Errorf("failed to create watcher's git cache dir: %v", err)
+		}
 	} else {
-		fi, err := os.Stat(*cacheDir)
-		if os.IsNotExist(err) {
-			if err := os.MkdirAll(*cacheDir, 0755); err != nil {
-				return fmt.Errorf("failed to create watcher's git cache dir: %v", err)
-			}
-		} else {
-			if err != nil {
-				return fmt.Errorf("invalid -cachedir: %v", err)
-			}
-			if !fi.IsDir() {
-				return fmt.Errorf("invalid -cachedir=%q; not a directory", *cacheDir)
-			}
-		}
-	}
-
-	if *httpAddr != "" {
-		http.HandleFunc("/debug/env", handleDebugEnv)
-		http.HandleFunc("/debug/goroutines", handleDebugGoroutines)
-		ln, err := net.Listen("tcp", *httpAddr)
 		if err != nil {
-			return err
+			return "", fmt.Errorf("invalid -cachedir: %v", err)
 		}
-		go http.Serve(ln, nil)
-	}
-
-	errc := make(chan error)
-
-	startRepo := func(name string) {
-		log.Printf("Starting watch of repo %s", name)
-		url := goBase + name
-		var dst string
-		if *mirror {
-			dst = shouldMirrorTo(name)
-			if dst != "" {
-				log.Printf("Starting mirror of subrepo %s", name)
-			} else {
-				log.Printf("Not mirroring repo %s", name)
-			}
-		}
-		r, err := NewRepo(url, dst)
-		if err != nil {
-			errc <- err
-			return
-		}
-		http.Handle("/"+name+".tar.gz", r)
-		reposMu.Lock()
-		repos = append(repos, r)
-		sort.Slice(repos, func(i, j int) bool { return repos[i].name() < repos[j].name() })
-		reposMu.Unlock()
-		r.Loop()
-	}
-
-	if *mirror {
-		gerritRepos, err := gerritMetaMap()
-		if err != nil {
-			return fmt.Errorf("gerritMetaMap: %v", err)
-		}
-		for name := range gerritRepos {
-			go startRepo(name)
+		if !fi.IsDir() {
+			return "", fmt.Errorf("invalid -cachedir=%q; not a directory", *flagCacheDir)
 		}
 	}
-
-	http.HandleFunc("/", handleRoot)
-
-	// Blocks forever if all the NewRepo calls succeed:
-	return <-errc
+	return *flagCacheDir, nil
 }
 
-var (
-	reposMu sync.Mutex
-	repos   []*Repo
-)
+// A mirror watches Gerrit repositories, fetching the latest commits and
+// optionally mirroring them.
+type mirror struct {
+	repos        map[string]*Repo
+	cacheDir     string
+	gerritClient *gerrit.Client
+}
+
+// addRepos adds known repositories to the mirror.
+func (m *mirror) addRepos() error {
+	var eg errgroup.Group
+	for name := range repospkg.ByGerritProject {
+		name := name
+		eg.Go(func() error {
+			r, err := NewRepo(goBase+name, m.cacheDir)
+			if err != nil {
+				return err
+			}
+			http.Handle("/"+name+".tar.gz", r)
+			m.repos[name] = r
+			return nil
+		})
+	}
+	return eg.Wait()
+}
+
+// runMirrors sets up and starts mirroring for the repositories that are
+// configured to be mirrored.
+func (m *mirror) runMirrors() error {
+	for name, repo := range m.repos {
+		meta, ok := repospkg.ByGerritProject[name]
+		if !ok || !meta.MirrorToGitHub {
+			continue
+		}
+		if err := repo.addRemote("github", "git@github.com:"+meta.GitHubRepo()+".git",
+			// We want to include only the refs/heads/* and refs/tags/* namespaces
+			// in the mirrors. They correspond to published branches and tags.
+			// Leave out internal Gerrit namespaces such as refs/changes/*,
+			// refs/users/*, etc., because they're not helpful on other hosts.
+			"push = +refs/heads/*:refs/heads/*",
+			"push = +refs/tags/*:refs/tags/*",
+		); err != nil {
+			return fmt.Errorf("adding remote: %v", err)
+		}
+	}
+	return nil
+}
 
 // GET /
 // or:
 // GET /debug/watcher/
-func handleRoot(w http.ResponseWriter, r *http.Request) {
+func (m *mirror) handleRoot(w http.ResponseWriter, r *http.Request) {
 	if r.URL.Path != "/" && r.URL.Path != "/debug/watcher/" {
 		http.NotFound(w, r)
 		return
 	}
-	reposMu.Lock()
-	defer reposMu.Unlock()
 	w.Header().Set("Content-Type", "text/html; charset=utf-8")
 	fmt.Fprint(w, "<html><body><pre>")
-	for _, r := range repos {
-		fmt.Fprintf(w, "<a href='/debug/watcher/%s'>%s</a> - %s\n", r.name(), r.name(), r.statusLine())
+	var names []string
+	for name := range m.repos {
+		names = append(names, name)
+	}
+	sort.Strings(names)
+	for _, name := range names {
+		fmt.Fprintf(w, "<a href='/debug/watcher/%s'>%s</a> - %s\n", name, name, m.repos[name].statusLine())
 	}
 	fmt.Fprint(w, "</pre></body></html>")
 }
 
-// shouldMirrorTo returns the GitHub repository the named repo should be
-// mirrored to or "" if it should not be mirrored.
-func shouldMirrorTo(name string) (dst string) {
-	if r, ok := repospkg.ByGerritProject[name]; ok && r.MirrorToGitHub {
-		return "git@github.com:" + r.GitHubRepo() + ".git"
-	}
-	return ""
-}
-
 // a statusEntry is a status string at a specific time.
 type statusEntry struct {
 	status string
@@ -244,9 +267,10 @@
 
 // Repo represents a repository to be watched.
 type Repo struct {
-	root   string // on-disk location of the git repo, *cacheDir/name
-	mirror bool   // push new commits to 'dest' remote
-	status statusRing
+	root    string    // on-disk location of the git repo, *cacheDir/name
+	changed chan bool // sent to when a change comes in
+	status  statusRing
+	dests   []string // destination remotes to mirror to
 
 	mu        sync.Mutex
 	err       error
@@ -256,23 +280,23 @@
 	lastGood  time.Time
 }
 
-// NewRepo checks out a new instance of the git repository
-// specified by srcURL.
-//
-// If dstURL is not empty, changes from the source repository will
-// be mirrored to the specified destination repository.
-func NewRepo(srcURL, dstURL string) (*Repo, error) {
-	name := path.Base(srcURL) // "go", "net", etc
-	root := filepath.Join(*cacheDir, name)
+// NewRepo checks out an instance of the git repository at url to dir.
+func NewRepo(url, dir string) (*Repo, error) {
+	name := path.Base(url) // "go", "net", etc
+	root := filepath.Join(dir, name)
 	r := &Repo{
-		root:   root,
-		mirror: dstURL != "",
+		root:    root,
+		changed: make(chan bool, 1),
 	}
 
 	http.Handle("/debug/watcher/"+r.name(), r)
 
-	needClone := true
-	if r.shouldTryReuseGitDir(dstURL) {
+	canReuse := true
+	if _, err := os.Stat(filepath.Join(r.root, "FETCH_HEAD")); err != nil {
+		canReuse = false
+		r.logf("can't reuse git dir, no FETCH_HEAD: %v", err)
+	}
+	if canReuse {
 		r.setStatus("reusing git dir; running git fetch")
 		cmd := exec.Command("git", "fetch", "--prune", "origin")
 		cmd.Dir = r.root
@@ -281,43 +305,25 @@
 		var stderr bytes.Buffer
 		cmd.Stderr = &stderr
 		err := cmd.Run()
+		r.logf("ran git fetch in %v", time.Since(t0))
 		if err != nil {
+			canReuse = false
 			r.logf("git fetch failed; proceeding to wipe + clone instead; err: %v, stderr: %s", err, stderr.Bytes())
-		} else {
-			needClone = false
-			r.logf("ran git fetch in %v", time.Since(t0))
 		}
 	}
-	if needClone {
+	if !canReuse {
 		r.setStatus("need clone; removing cache root")
 		os.RemoveAll(r.root)
 		t0 := time.Now()
 		r.setStatus("running fresh git clone --mirror")
-		r.logf("cloning %v into %s", srcURL, r.root)
-		cmd := exec.Command("git", "clone", "--mirror", srcURL, r.root)
+		r.logf("cloning %v into %s", url, r.root)
+		cmd := exec.Command("git", "clone", "--mirror", url, r.root)
 		if out, err := cmd.CombinedOutput(); err != nil {
-			return nil, fmt.Errorf("cloning %s: %v\n\n%s", srcURL, err, out)
+			return nil, fmt.Errorf("cloning %s: %v\n\n%s", url, err, out)
 		}
 		r.setStatus("cloned")
 		r.logf("cloned in %v", time.Since(t0))
 	}
-
-	if r.mirror {
-		r.setStatus("adding dest remote")
-		if err := r.addRemote("dest", dstURL,
-			// We want to include only the refs/heads/* and refs/tags/* namespaces
-			// in the GitHub mirrors. They correspond to published branches and tags.
-			// Leave out internal Gerrit namespaces such as refs/changes/*,
-			// refs/users/*, etc., because they're not helpful on github.com/golang.
-			"push = +refs/heads/*:refs/heads/*",
-			"push = +refs/tags/*:refs/tags/*",
-		); err != nil {
-			r.setStatus("failed to add dest")
-			return nil, fmt.Errorf("adding remote: %v", err)
-		}
-		r.setStatus("added dest remote")
-	}
-
 	return r, nil
 }
 
@@ -371,55 +377,16 @@
 	r.status.add(status)
 }
 
-// shouldTryReuseGitDir reports whether we should try to reuse r.root as the git
-// directory. (The directory may be corrupt, though.)
-// dstURL is optional, and is the desired remote URL for a remote named "dest".
-func (r *Repo) shouldTryReuseGitDir(dstURL string) bool {
-	if _, err := os.Stat(filepath.Join(r.root, "FETCH_HEAD")); err != nil {
-		if os.IsNotExist(err) {
-			r.logf("not reusing git dir; no FETCH_HEAD at %s", r.root)
-		} else {
-			r.logf("not reusing git dir; %v", err)
-		}
-		return false
-	}
-	if dstURL == "" {
-		r.logf("not reusing git dir because dstURL is empty")
-		return true
-	}
-
-	// Does the "dest" remote match? If not, we return false and nuke
-	// the world and re-clone out of laziness.
-	cmd := exec.Command("git", "remote", "-v")
-	cmd.Dir = r.root
-	out, err := cmd.Output()
-	if err != nil {
-		log.Printf("git remote -v: %v", err)
-	}
-	foundWrong := false
-	for _, ln := range strings.Split(string(out), "\n") {
-		if !strings.HasPrefix(ln, "dest") {
-			continue
-		}
-		f := strings.Fields(ln)
-		if len(f) < 2 {
-			continue
-		}
-		if f[0] == "dest" {
-			if f[1] == dstURL {
-				return true
-			}
-			if !foundWrong {
-				foundWrong = true
-				r.logf("found dest of %q, which doesn't equal sought %q", f[1], dstURL)
-			}
-		}
-	}
-	r.logf("not reusing old repo: remote \"dest\" URL doesn't match")
-	return false
-}
-
 func (r *Repo) addRemote(name, url string, opts ...string) error {
+	r.dests = append(r.dests, name)
+	cmd := exec.Command("git", "remote", "remove", name)
+	cmd.Dir = r.root
+	if err := cmd.Run(); err != nil {
+		// Exit status 2 means not found, which is fine.
+		if ee, ok := err.(*exec.ExitError); !ok || ee.ExitCode() != 2 {
+			return err
+		}
+	}
 	gitConfig := filepath.Join(r.root, "config")
 	f, err := os.OpenFile(gitConfig, os.O_WRONLY|os.O_APPEND, os.ModePerm)
 	if err != nil {
@@ -443,7 +410,7 @@
 // Loop continuously runs "git fetch" in the repo, checks for new
 // commits and mirrors commits to a destination repo (if enabled).
 func (r *Repo) Loop() {
-	tickler := repoTickler(r.name())
+outer:
 	for {
 		if err := r.fetch(); err != nil {
 			r.logf("fetch failed in repo loop: %v", err)
@@ -451,12 +418,12 @@
 			time.Sleep(10 * time.Second)
 			continue
 		}
-		if r.mirror {
-			if err := r.push(); err != nil {
+		for _, dest := range r.dests {
+			if err := r.push(dest); err != nil {
 				r.logf("push failed in repo loop: %v", err)
 				r.setErr(err)
 				time.Sleep(10 * time.Second)
-				continue
+				continue outer
 			}
 		}
 
@@ -467,7 +434,7 @@
 		// breaks for some reason.
 		timer := time.NewTimer(5 * time.Minute)
 		select {
-		case <-tickler:
+		case <-r.changed:
 			r.setStatus("got update tickle")
 			timer.Stop()
 		case <-timer.C:
@@ -486,21 +453,9 @@
 
 // fetch runs "git fetch" in the repository root.
 // It tries three times, just in case it failed because of a transient error.
-func (r *Repo) fetch() (err error) {
-	n := 0
-	r.setStatus("running git fetch origin")
-	defer func() {
-		if err != nil {
-			r.setStatus("git fetch failed")
-		} else {
-			r.setStatus("ran git fetch")
-		}
-	}()
-	return try(3, func() error {
-		n++
-		if n > 1 {
-			r.setStatus(fmt.Sprintf("running git fetch origin, attempt %d", n))
-		}
+func (r *Repo) fetch() error {
+	err := try(3, func(attempt int) error {
+		r.setStatus(fmt.Sprintf("running git fetch origin, attempt %d", attempt))
 		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
 		defer cancel()
 		cmd := exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
@@ -512,26 +467,20 @@
 		}
 		return nil
 	})
+	if err != nil {
+		r.setStatus("git fetch failed")
+	} else {
+		r.setStatus("ran git fetch")
+	}
+	return err
 }
 
 // push runs "git push -f --mirror dest" in the repository root.
 // It tries three times, just in case it failed because of a transient error.
-func (r *Repo) push() (err error) {
-	n := 0
-	r.setStatus("syncing to github")
-	defer func() {
-		if err != nil {
-			r.setStatus("sync to github failed")
-		} else {
-			r.setStatus("did sync to github")
-		}
-	}()
-	return try(3, func() error {
-		n++
-		if n > 1 {
-			r.setStatus(fmt.Sprintf("syncing to github, attempt %d", n))
-		}
-		cmd := exec.Command("git", "push", "-f", "--mirror", "dest")
+func (r *Repo) push(dest string) error {
+	err := try(3, func(attempt int) error {
+		r.setStatus(fmt.Sprintf("syncing to %v, attempt %d", dest, attempt))
+		cmd := exec.Command("git", "push", "-f", "--mirror", dest)
 		cmd.Dir = r.root
 		if out, err := cmd.CombinedOutput(); err != nil {
 			err = fmt.Errorf("%v\n\n%s", err, out)
@@ -540,6 +489,12 @@
 		}
 		return nil
 	})
+	if err != nil {
+		r.setStatus("sync to " + dest + " failed")
+	} else {
+		r.setStatus("did sync to " + dest)
+	}
+	return err
 }
 
 // hasRev returns true if the repo contains the commit-ish rev.
@@ -635,52 +590,36 @@
 	fmt.Fprintf(w, "\n</pre></body></html>")
 }
 
-func try(n int, fn func() error) error {
+func try(n int, fn func(attempt int) error) error {
 	var err error
 	for tries := 0; tries < n; tries++ {
 		time.Sleep(time.Duration(tries) * 5 * time.Second) // Linear back-off.
-		if err = fn(); err == nil {
+		if err = fn(tries); err == nil {
 			break
 		}
 	}
 	return err
 }
 
-func homeDir() string {
-	switch runtime.GOOS {
-	case "plan9":
-		return os.Getenv("home")
-	case "windows":
-		return os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH")
+func (m *mirror) notifyChanged(name string) {
+	repo, ok := m.repos[name]
+	if !ok {
+		return
 	}
-	return os.Getenv("HOME")
-}
-
-var (
-	ticklerMu sync.Mutex
-	ticklers  = make(map[string]chan bool)
-)
-
-// repo is the gerrit repo: e.g. "go", "net", "crypto", ...
-func repoTickler(repo string) chan bool {
-	ticklerMu.Lock()
-	defer ticklerMu.Unlock()
-	if c, ok := ticklers[repo]; ok {
-		return c
+	select {
+	case repo.changed <- true:
+	default:
 	}
-	c := make(chan bool, 1)
-	ticklers[repo] = c
-	return c
 }
 
 // pollGerritAndTickle polls Gerrit's JSON meta URL of all its URLs
 // and their current branch heads.  When this sees that one has
 // changed, it tickles the channel for that repo and wakes up its
 // poller, if its poller is in a sleep.
-func pollGerritAndTickle() {
+func (m *mirror) pollGerritAndTickle() {
 	last := map[string]string{} // repo -> last seen hash
 	for {
-		gerritRepos, err := gerritMetaMap()
+		gerritRepos, err := m.gerritMetaMap()
 		if err != nil {
 			log.Printf("pollGerritAndTickle: gerritMetaMap failed, skipping: %v", err)
 			gerritRepos = nil
@@ -688,28 +627,25 @@
 		for repo, hash := range gerritRepos {
 			if hash != last[repo] {
 				last[repo] = hash
-				select {
-				case repoTickler(repo) <- true:
-				default:
-				}
+				m.notifyChanged(repo)
 			}
 		}
-		time.Sleep(*pollInterval)
+		time.Sleep(*flagPollInterval)
 	}
 }
 
 // subscribeToMaintnerAndTickleLoop subscribes to maintner.golang.org
 // and watches for any ref changes in realtime.
-func subscribeToMaintnerAndTickleLoop() {
+func (m *mirror) subscribeToMaintnerAndTickleLoop() {
 	for {
-		if err := subscribeToMaintnerAndTickle(); err != nil {
+		if err := m.subscribeToMaintnerAndTickle(); err != nil {
 			log.Printf("maintner loop: %v; retrying in 30 seconds", err)
 			time.Sleep(30 * time.Second)
 		}
 	}
 }
 
-func subscribeToMaintnerAndTickle() error {
+func (m *mirror) subscribeToMaintnerAndTickle() error {
 	ctx := context.Background()
 	retryTicker := time.NewTicker(10 * time.Second)
 	defer retryTicker.Stop() // we never return, though
@@ -720,10 +656,7 @@
 				if strings.HasPrefix(gm.Project, "go.googlesource.com/") {
 					proj := strings.TrimPrefix(gm.Project, "go.googlesource.com/")
 					log.Printf("maintner refs for %s changed", gm.Project)
-					select {
-					case repoTickler(proj) <- true:
-					default:
-					}
+					m.notifyChanged(proj)
 				}
 			}
 			return e.Err
@@ -739,20 +672,20 @@
 
 // gerritMetaMap returns the map from repo name (e.g. "go") to its
 // latest master hash.
-func gerritMetaMap() (map[string]string, error) {
+func (m *mirror) gerritMetaMap() (map[string]string, error) {
 	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 	defer cancel()
-	meta, err := gerritClient.GetProjects(ctx, "master")
+	meta, err := m.gerritClient.GetProjects(ctx, "master")
 	if err != nil {
 		return nil, fmt.Errorf("gerritClient.GetProjects: %v", err)
 	}
-	m := map[string]string{}
+	result := map[string]string{}
 	for repo, v := range meta {
 		if master, ok := v.Branches["master"]; ok {
-			m[repo] = master
+			result[repo] = master
 		}
 	}
-	return m, nil
+	return result, nil
 }
 
 // GET /debug/goroutines
diff --git a/cmd/gitmirror/gitmirror_test.go b/cmd/gitmirror/gitmirror_test.go
index 4831737..1db2e3b 100644
--- a/cmd/gitmirror/gitmirror_test.go
+++ b/cmd/gitmirror/gitmirror_test.go
@@ -39,15 +39,14 @@
 
 func newTestRepo() *Repo {
 	return &Repo{
-		root:   tempRepoRoot,
-		mirror: false,
+		root: tempRepoRoot,
 	}
 }
 
 func TestHomepage(t *testing.T) {
 	req := httptest.NewRequest("GET", "/", nil)
 	w := httptest.NewRecorder()
-	handleRoot(w, req)
+	(&mirror{}).handleRoot(w, req)
 	if w.Code != 200 {
 		t.Fatalf("GET /: want code 200, got %d", w.Code)
 	}