init: Create a command to setup a new Gerrit installation

The init command uses an interactive prompting process to help the
user get a basic website configured.

The --import-projects option will automatically search for and import
any Git repositories which are discovered within gerrit.basePath.

Bug: issue 323
Bug: issue 330
Change-Id: I3d6e8f9f5fea8bfc78f6dfb1fc8f284bebfba670
Signed-off-by: Shawn O. Pearce <sop@google.com>
diff --git a/Documentation/pgm-index.txt b/Documentation/pgm-index.txt
index 00246bb..069ec59 100644
--- a/Documentation/pgm-index.txt
+++ b/Documentation/pgm-index.txt
@@ -9,8 +9,8 @@
 [[programs]]Programs
 --------------------
 
-CreateSchema::
-  Initialize a new database schema.
+link:pgm-init.html[init]::
+  Initialize a new Gerrit server installation
 
 link:pgm-daemon.html[daemon]::
   Gerrit HTTP, SSH network server.
@@ -18,6 +18,9 @@
 version::
   Display the release version of Gerrit Code Review.
 
+CreateSchema::
+  Initialize a new database schema.
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/pgm-init.txt b/Documentation/pgm-init.txt
new file mode 100644
index 0000000..b1e2ec8
--- /dev/null
+++ b/Documentation/pgm-init.txt
@@ -0,0 +1,51 @@
+init
+====
+
+NAME
+----
+init - Initialize a new Gerrit server installation
+
+SYNOPSIS
+--------
+[verse]
+'java' -jar gerrit.war 'init'
+	-d <SITE_PATH>
+	[\--batch]
+	[\--import-projects]
+
+DESCRIPTION
+-----------
+Creates a new Gerrit server installation, interactively prompting
+for some basic setup prior to writing default configuration files
+into a newly created `$site_path`.
+
+If run an an existing `$site_path`, init will upgrade some resources
+as necessary.  This can be useful to import newly created projects.
+
+OPTIONS
+-------
+\--batch::
+	Run in batch mode, skipping interactive prompts.  Reasonable
+	configuration defaults are chosen based on the whims of
+	the Gerrit developers.
+
+\--import-projects::
+	Recursively search
+	link:config-gerrit.html#gerrit.basePath[gerrit.basePath]
+	for any Git repositories not yet registered as a project,
+	and initializes a new project for them.
+
+-d::
+\--site-path::
+	Location of the gerrit.config file, and all other per-site
+	configuration data, supporting libaries and log files.
+
+CONTEXT
+-------
+This command can only be run on a server which has direct
+connectivity to the metadata database, and local access to the
+managed Git repositories.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
diff --git a/gerrit-main/src/main/java/com/google/gerrit/main/GerritLauncher.java b/gerrit-main/src/main/java/com/google/gerrit/main/GerritLauncher.java
index c86bd08..c55c1e5 100644
--- a/gerrit-main/src/main/java/com/google/gerrit/main/GerritLauncher.java
+++ b/gerrit-main/src/main/java/com/google/gerrit/main/GerritLauncher.java
@@ -54,6 +54,7 @@
       System.err.println("usage: java -jar " + jar + " command [ARG ...]");
       System.err.println();
       System.err.println("The most commonly used commands are:");
+      System.err.println("  init           Initialize a Gerrit installation");
       System.err.println("  daemon         Run the Gerrit network daemons");
       System.err.println("  version        Display the build version number");
       System.err.println();
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/AbstractProgram.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/AbstractProgram.java
index 4a304a9..ca0bc29 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/AbstractProgram.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/AbstractProgram.java
@@ -72,7 +72,12 @@
       ProxyUtil.configureHttpProxy();
       return run();
     } catch (Die err) {
-      System.err.println("fatal: " + err.getMessage());
+      final Throwable cause = err.getCause();
+      final String diemsg = err.getMessage();
+      if (cause != null && !cause.getMessage().equals(diemsg)) {
+        System.err.println("fatal: " + cause.getMessage());
+      }
+      System.err.println("fatal: " + diemsg);
       return 128;
     }
   }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ConsoleUI.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ConsoleUI.java
new file mode 100644
index 0000000..212f00b
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ConsoleUI.java
@@ -0,0 +1,238 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm;
+
+import static org.eclipse.jgit.util.StringUtils.equalsIgnoreCase;
+
+import java.io.Console;
+import java.lang.reflect.InvocationTargetException;
+
+/** Console based interaction with the invoking user. */
+public abstract class ConsoleUI {
+  /** Get a UI instance, assuming interactive mode. */
+  public static ConsoleUI getInstance() {
+    return getInstance(false);
+  }
+
+  /** Get a UI instance, possibly forcing batch mode. */
+  public static ConsoleUI getInstance(final boolean batchMode) {
+    Console console = batchMode ? null : System.console();
+    return console != null ? new Interactive(console) : new Batch();
+  }
+
+  /** Constructs an exception indicating the user aborted the operation. */
+  protected static Die abort() {
+    return new Die("aborted by user");
+  }
+
+  /** Obtain all values from an enumeration. */
+  @SuppressWarnings("unchecked")
+  protected static <T extends Enum<?>> T[] all(final T value) {
+    try {
+      return (T[]) value.getClass().getMethod("values").invoke(null);
+    } catch (IllegalArgumentException e) {
+      throw new IllegalArgumentException("Cannot obtain enumeration values", e);
+    } catch (SecurityException e) {
+      throw new IllegalArgumentException("Cannot obtain enumeration values", e);
+    } catch (IllegalAccessException e) {
+      throw new IllegalArgumentException("Cannot obtain enumeration values", e);
+    } catch (InvocationTargetException e) {
+      throw new IllegalArgumentException("Cannot obtain enumeration values", e);
+    } catch (NoSuchMethodException e) {
+      throw new IllegalArgumentException("Cannot obtain enumeration values", e);
+    }
+  }
+
+  /** @return true if this is a batch UI that has no user interaction. */
+  public abstract boolean isBatch();
+
+  /** Display a header message before a series of prompts. */
+  public abstract void header(String fmt, Object... args);
+
+  /** Request the user to answer a yes/no question. */
+  public abstract boolean yesno(String fmt, Object... args);
+
+  /** Prints a message asking the user to let us know when its safe to continue. */
+  public abstract void waitForUser();
+
+  /** Prompt the user for a string, suggesting a default, and returning choice. */
+  public final String readString(String def, String fmt, Object... args) {
+    if (def != null && def.isEmpty()) {
+      def = null;
+    }
+    return readStringImpl(def, fmt, args);
+  }
+
+  /** Prompt the user for a string, suggesting a default, and returning choice. */
+  protected abstract String readStringImpl(String def, String fmt,
+      Object... args);
+
+  /** Prompt the user for a password, returning the string; null if blank. */
+  public abstract String password(String fmt, Object... args);
+
+  /** Prompt the user to make a choice from an enumeration's values. */
+  public abstract <T extends Enum<?>> T readEnum(T def, String fmt,
+      Object... args);
+
+
+  private static class Interactive extends ConsoleUI {
+    private final Console console;
+
+    Interactive(final Console console) {
+      this.console = console;
+    }
+
+    @Override
+    public boolean isBatch() {
+      return false;
+    }
+
+    @Override
+    public boolean yesno(String fmt, Object... args) {
+      final String prompt = String.format(fmt, args);
+      for (;;) {
+        final String yn = console.readLine("%-30s [y/n]? ", prompt);
+        if (yn == null) {
+          throw abort();
+        }
+        if (yn.equalsIgnoreCase("y") || yn.equalsIgnoreCase("yes")) {
+          return true;
+        }
+        if (yn.equalsIgnoreCase("n") || yn.equalsIgnoreCase("no")) {
+          return false;
+        }
+      }
+    }
+
+    @Override
+    public void waitForUser() {
+      if (console.readLine("Press enter to continue ") == null) {
+        throw abort();
+      }
+    }
+
+    @Override
+    protected String readStringImpl(String def, String fmt, Object... args) {
+      final String prompt = String.format(fmt, args);
+      String r;
+      if (def != null) {
+        r = console.readLine("%-30s [%s]: ", prompt, def);
+      } else {
+        r = console.readLine("%-30s : ", prompt);
+      }
+      if (r == null) {
+        throw abort();
+      }
+      r = r.trim();
+      if (r.isEmpty()) {
+        return def;
+      }
+      return r;
+    }
+
+    @Override
+    public String password(String fmt, Object... args) {
+      final String prompt = String.format(fmt, args);
+      for (;;) {
+        final char[] a1 = console.readPassword("%-30s : ", prompt);
+        if (a1 == null) {
+          throw abort();
+        }
+
+        final char[] a2 = console.readPassword("%30s : ", "confirm password");
+        if (a2 == null) {
+          throw abort();
+        }
+
+        final String s1 = new String(a1);
+        final String s2 = new String(a2);
+        if (!s1.equals(s2)) {
+          console.printf("error: Passwords did not match; try again\n");
+          continue;
+        }
+        return !s1.isEmpty() ? s1 : null;
+      }
+    }
+
+    @Override
+    public <T extends Enum<?>> T readEnum(T def, String fmt, Object... args) {
+      final String prompt = String.format(fmt, args);
+      final T[] options = all(def);
+      for (;;) {
+        String r = console.readLine("%-30s [%s/?]: ", prompt, def.toString());
+        if (r == null) {
+          throw abort();
+        }
+        r = r.trim();
+        if (r.isEmpty()) {
+          return def;
+        }
+        for (final T e : options) {
+          if (equalsIgnoreCase(e.toString(), r)) {
+            return e;
+          }
+        }
+        if (!"?".equals(r)) {
+          console.printf("error: '%s' is not a valid choice\n", r);
+        }
+        console.printf("       Supported options are:\n");
+        for (final T e : options) {
+          console.printf("         %s\n", e.toString().toLowerCase());
+        }
+      }
+    }
+
+    @Override
+    public void header(String fmt, Object... args) {
+      fmt = fmt.replaceAll("\n", "\n*** ");
+      console.printf("\n*** " + fmt + "\n*** \n\n", args);
+    }
+  }
+
+  private static class Batch extends ConsoleUI {
+    @Override
+    public boolean isBatch() {
+      return true;
+    }
+
+    @Override
+    public boolean yesno(String fmt, Object... args) {
+      return true;
+    }
+
+    @Override
+    protected String readStringImpl(String def, String fmt, Object... args) {
+      return def;
+    }
+
+    @Override
+    public void waitForUser() {
+    }
+
+    @Override
+    public String password(String fmt, Object... args) {
+      return null;
+    }
+
+    @Override
+    public <T extends Enum<?>> T readEnum(T def, String fmt, Object... args) {
+      return def;
+    }
+
+    @Override
+    public void header(String fmt, Object... args) {
+    }
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ErrorLogFile.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ErrorLogFile.java
index 6d2802b..4c19a47 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ErrorLogFile.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ErrorLogFile.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.lifecycle.LifecycleListener;
 
 import org.apache.log4j.Appender;
+import org.apache.log4j.ConsoleAppender;
 import org.apache.log4j.DailyRollingFileAppender;
 import org.apache.log4j.Level;
 import org.apache.log4j.LogManager;
@@ -29,6 +30,22 @@
 import java.io.File;
 
 public class ErrorLogFile {
+  public static void errorOnlyConsole() {
+    LogManager.resetConfiguration();
+
+    final PatternLayout layout = new PatternLayout();
+    layout.setConversionPattern("%-5p %c %x: %m%n");
+
+    final ConsoleAppender dst = new ConsoleAppender();
+    dst.setLayout(layout);
+    dst.setTarget("System.err");
+    dst.setThreshold(Level.ERROR);
+
+    final Logger root = LogManager.getRootLogger();
+    root.removeAllAppenders();
+    root.addAppender(dst);
+  }
+
   public static LifecycleListener start(final File sitePath) {
     final File logdir = new File(sitePath, "logs");
     if (!logdir.exists() && !logdir.mkdirs()) {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Init.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Init.java
new file mode 100644
index 0000000..7716654
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Init.java
@@ -0,0 +1,659 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm;
+
+import static com.google.gerrit.pgm.DataSourceProvider.Type.H2;
+
+import com.google.gerrit.reviewdb.AuthType;
+import com.google.gerrit.reviewdb.Project;
+import com.google.gerrit.reviewdb.ReviewDb;
+import com.google.gerrit.reviewdb.Project.SubmitType;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.mail.SmtpEmailSender.Encryption;
+import com.google.gwtjsonrpc.server.SignedToken;
+import com.google.gwtorm.client.OrmException;
+import com.google.gwtorm.client.SchemaFactory;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Module;
+
+import org.apache.sshd.common.util.SecurityUtils;
+import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.FileBasedConfig;
+import org.eclipse.jgit.lib.LockFile;
+import org.eclipse.jgit.lib.RepositoryCache.FileKey;
+import org.eclipse.jgit.util.SystemReader;
+import org.kohsuke.args4j.Option;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/** Initialize a new Gerrit installation. */
+public class Init extends SiteProgram {
+  @Option(name = "--batch", usage = "Batch mode; skip interactive prompting")
+  private boolean batchMode;
+
+  @Option(name = "--import-projects", usage = "Import git repositories as projects")
+  private boolean importProjects;
+
+  @Inject
+  private GitRepositoryManager repositoryManager;
+
+  @Inject
+  private SchemaFactory<ReviewDb> schema;
+
+  private boolean deleteOnFailure;
+  private ConsoleUI ui;
+  private Injector dbInjector;
+  private Injector sysInjector;
+
+  @Override
+  public int run() throws Exception {
+    ErrorLogFile.errorOnlyConsole();
+    ui = ConsoleUI.getInstance(batchMode);
+
+    try {
+      initSitePath();
+      inject();
+      initGit();
+    } catch (Exception failure) {
+      if (deleteOnFailure) {
+        recursiveDelete(getSitePath());
+      }
+      throw failure;
+    } catch (Error failure) {
+      if (deleteOnFailure) {
+        recursiveDelete(getSitePath());
+      }
+      throw failure;
+    }
+    System.err.println("Initialized " + getSitePath().getCanonicalPath());
+    return 0;
+  }
+
+  private void initSitePath() throws IOException, InterruptedException {
+    final File sitePath = getSitePath();
+
+    final File gerrit_config = new File(sitePath, "gerrit.config");
+    final File secure_config = new File(sitePath, "secure.config");
+    final File replication_config = new File(sitePath, "replication.config");
+    final File lib_dir = new File(sitePath, "lib");
+    final File logs_dir = new File(sitePath, "logs");
+    final File static_dir = new File(sitePath, "static");
+    final File cache_dir = new File(sitePath, "cache");
+
+    if (gerrit_config.exists()) {
+      if (!gerrit_config.exists()) {
+        throw die("'" + sitePath + "' is not a Gerrit server site");
+      }
+    } else if (!gerrit_config.exists()) {
+      ui.header("Gerrit Code Review %s", version());
+      if (!ui.yesno("Initialize '%s'", sitePath.getCanonicalPath())) {
+        throw die("aborted by user");
+      }
+
+      if (!sitePath.mkdirs()) {
+        throw die("Cannot make directory " + sitePath);
+      }
+      deleteOnFailure = true;
+
+      final FileBasedConfig cfg = new FileBasedConfig(gerrit_config);
+      final FileBasedConfig sec = new FileBasedConfig(secure_config);
+      init_gerrit_basepath(cfg);
+      init_database(cfg, sec);
+      init_auth(cfg, sec);
+      init_sendemail(cfg, sec);
+      init_sshd(cfg, sec);
+      init_httpd(cfg, sec);
+
+      cache_dir.mkdir();
+      set(cfg, "cache", "directory", cache_dir.getName());
+
+      cfg.save();
+      saveSecureConfig(sec);
+
+      if (ui != null) {
+        System.err.println();
+      }
+    }
+
+    if (!secure_config.exists()) {
+      chmod600(secure_config);
+    }
+    if (!replication_config.exists()) {
+      replication_config.createNewFile();
+    }
+
+    lib_dir.mkdir();
+    logs_dir.mkdir();
+    static_dir.mkdir();
+  }
+
+  private void initGit() throws OrmException, IOException {
+    final File root = repositoryManager.getBasePath();
+    if (root != null && importProjects) {
+      System.err.println("Scanning projects under " + root);
+      final ReviewDb db = schema.open();
+      try {
+        final HashSet<String> have = new HashSet<String>();
+        for (Project p : db.projects().all()) {
+          have.add(p.getName());
+        }
+        importProjects(root, "", db, have);
+      } finally {
+        db.close();
+      }
+    }
+  }
+
+  private void importProjects(final File dir, final String prefix,
+      final ReviewDb db, final Set<String> have) throws OrmException,
+      IOException {
+    final File[] ls = dir.listFiles();
+    if (ls == null) {
+      return;
+    }
+
+    for (File f : ls) {
+      if (".".equals(f.getName()) || "..".equals(f.getName())) {
+      } else if (FileKey.isGitRepository(f)) {
+        String name = f.getName();
+        if (name.equals(".git")) {
+          name = prefix.substring(0, prefix.length() - 1);
+        } else if (name.endsWith(".git")) {
+          name = prefix + name.substring(0, name.length() - 4);
+        } else {
+          name = prefix + name;
+          System.err.println("Ignoring non-standard name '" + name + "'");
+          continue;
+        }
+
+        if (have.contains(name)) {
+          continue;
+        }
+
+        final Project.NameKey nameKey = new Project.NameKey(name);
+        final Project.Id idKey = new Project.Id(db.nextProjectId());
+        final Project p = new Project(nameKey, idKey);
+
+        p.setDescription(repositoryManager.getProjectDescription(name));
+        p.setSubmitType(SubmitType.MERGE_IF_NECESSARY);
+        p.setUseContributorAgreements(false);
+        p.setUseSignedOffBy(false);
+        db.projects().insert(Collections.singleton(p));
+
+      } else if (f.isDirectory()) {
+        importProjects(f, prefix + f.getName() + "/", db, have);
+      }
+    }
+  }
+
+  private void saveSecureConfig(final FileBasedConfig sec) throws IOException {
+    final byte[] out = Constants.encode(sec.toText());
+    final File path = sec.getFile();
+    final LockFile lf = new LockFile(path);
+    if (!lf.lock()) {
+      throw new IOException("Cannot lock " + path);
+    }
+    try {
+      chmod600(new File(path.getParentFile(), path.getName() + ".lock"));
+      lf.write(out);
+      if (!lf.commit()) {
+        throw new IOException("Cannot commit write to " + path);
+      }
+    } finally {
+      lf.unlock();
+    }
+  }
+
+  private static void chmod600(final File path) throws IOException {
+    if (!path.exists() && !path.createNewFile()) {
+      throw new IOException("Cannot create " + path);
+    }
+    path.setWritable(false, false /* all */);
+    path.setReadable(false, false /* all */);
+    path.setExecutable(false, false /* all */);
+
+    path.setWritable(true, true /* owner only */);
+    path.setReadable(true, true /* owner only */);
+    if (path.isDirectory()) {
+      path.setExecutable(true, true /* owner only */);
+    }
+  }
+
+  private void init_gerrit_basepath(final Config cfg) {
+    ui.header("Git Repositories");
+
+    File d = new File(ui.readString("git", "Location of Git repositories"));
+    set(cfg, "gerrit", "basePath", d.getPath());
+
+    if (d.exists()) {
+      if (!importProjects && d.list() != null && d.list().length > 0) {
+        importProjects = ui.yesno("Import existing repositories");
+      }
+    } else if (!d.mkdirs()) {
+      throw die("Cannot create " + d);
+    }
+  }
+
+  private void init_database(final Config cfg, final Config sec) {
+    ui.header("SQL Database");
+
+    DataSourceProvider.Type db_type = ui.readEnum(H2, "Database server type");
+    if (db_type == DataSourceProvider.Type.DEFAULT) {
+      db_type = H2;
+    }
+    set(cfg, "database", "type", db_type, null);
+
+    switch (db_type) {
+      case MYSQL:
+        createDownloader()
+            .setRequired(true)
+            .setName("MySQL Connector/J 5.1.10")
+            .setJarUrl(
+                "http://repo2.maven.org/maven2/mysql/mysql-connector-java/5.1.10/mysql-connector-java-5.1.10.jar")
+            .setSHA1("b83574124f1a00d6f70d56ba64aa52b8e1588e6d").download();
+        loadSiteLib();
+        break;
+    }
+
+    final boolean userPassAuth;
+    switch (db_type) {
+      case H2:
+        userPassAuth = false;
+        new File(getSitePath(), "db").mkdirs();
+        break;
+
+      case JDBC: {
+        userPassAuth = true;
+        String driver = ui.readString("", "Driver class name");
+        String url = ui.readString("", "url");
+
+        set(cfg, "database", "driver", driver);
+        set(cfg, "database", "url", url);
+        break;
+      }
+
+      case POSTGRES:
+      case POSTGRESQL:
+      case MYSQL: {
+        userPassAuth = true;
+        String def_port = "(" + db_type.toString() + " default)";
+        String hostname = ui.readString("localhost", "Server hostname");
+        String port = ui.readString(def_port, "Server port");
+        String database = ui.readString("reviewdb", "Database name");
+
+        set(cfg, "database", "hostname", hostname);
+        set(cfg, "database", "port", port != def_port ? port : null);
+        set(cfg, "database", "database", database);
+        break;
+      }
+
+      default:
+        throw die("internal bug, database " + db_type + " not supported");
+    }
+
+    if (userPassAuth) {
+      String user = ui.readString(username(), "Database username");
+      String pass = user != null ? ui.password("%s's password", user) : null;
+      set(cfg, "database", "username", user);
+      set(sec, "database", "password", pass);
+    }
+  }
+
+  private void init_auth(final Config cfg, final Config sec) {
+    ui.header("User Authentication");
+
+    AuthType auth_type = ui.readEnum(AuthType.OPENID, "Authentication method");
+    set(cfg, "auth", "type", auth_type, null);
+
+    switch (auth_type) {
+      case HTTP:
+      case HTTP_LDAP: {
+        String def_hdr = "(HTTP Basic)";
+        String hdr = ui.readString(def_hdr, "Username HTTP header");
+        String logoutUrl = ui.readString("", "Single-sign-on logout URL");
+
+        set(cfg, "auth", "httpHeader", hdr != def_hdr ? hdr : null);
+        set(cfg, "auth", "logoutUrl", logoutUrl);
+        break;
+      }
+    }
+
+    switch (auth_type) {
+      case LDAP:
+      case HTTP_LDAP: {
+        String server = ui.readString("ldap://localhost", "LDAP server");
+
+        if (server != null && !server.startsWith("ldap://")
+            && !server.startsWith("ldaps://")) {
+          if (ui.yesno("Use SSL")) {
+            server = "ldaps://" + server;
+          } else {
+            server = "ldap://" + server;
+          }
+        }
+
+        final String def_dn = dnOf(server);
+        String accountBase = ui.readString(def_dn, "Account BaseDN");
+        String groupBase = ui.readString(accountBase, "Group BaseDN");
+
+        String user = ui.readString(null, "LDAP username");
+        String pass = user != null ? ui.password("%s's password", user) : null;
+
+        set(cfg, "ldap", "server", server);
+        set(cfg, "ldap", "username", user);
+        set(sec, "ldap", "password", pass);
+
+        set(cfg, "ldap", "accountBase", accountBase);
+        set(cfg, "ldap", "groupBase", groupBase);
+        break;
+      }
+    }
+  }
+
+  private void init_sendemail(final Config cfg, final Config sec) {
+    ui.header("Email Delivery");
+
+    String def_port = "(default)";
+    String smtpserver = ui.readString("localhost", "SMTP server hostname");
+    String port = ui.readString(def_port, "SMTP server port");
+    Encryption enc = ui.readEnum(Encryption.NONE, "SMTP encryption");
+    String username = null;
+    if (enc != Encryption.NONE || !isLocal(smtpserver)) {
+      username = username();
+    }
+    username = ui.readString(username, "SMTP username");
+    String password =
+        username != null ? ui.password("%s's password", username) : null;
+
+    set(cfg, "sendemail", "smtpServer", smtpserver);
+    set(cfg, "sendemail", "smtpServerPort", port != def_port ? port : null);
+    set(cfg, "sendemail", "smtpEncryption", enc, Encryption.NONE);
+
+    set(cfg, "sendemail", "smtpUser", username);
+    set(sec, "sendemail", "smtpPass", password);
+  }
+
+  private void init_sshd(final Config cfg, final Config sec)
+      throws IOException, InterruptedException {
+    ui.header("SSH Daemon");
+
+    String sshd_hostname = ui.readString("*", "Gerrit SSH listens on address");
+    String sshd_port = ui.readString("29418", "Gerrit SSH listens on port");
+    set(cfg, "sshd", "listenAddress", sshd_hostname + ":" + sshd_port);
+
+    // Download and install BouncyCastle if the user wants to use it.
+    //
+    createDownloader().setRequired(false).setName("Bouncy Castle Crypto v144")
+        .setJarUrl("http://www.bouncycastle.org/download/bcprov-jdk16-144.jar")
+        .setSHA1("6327a5f7a3dc45e0fd735adb5d08c5a74c05c20c").download();
+    loadSiteLib();
+
+    System.err.print("Generating SSH host key ...");
+    System.err.flush();
+    if (SecurityUtils.isBouncyCastleRegistered()) {
+      // Generate the SSH daemon host key using ssh-keygen.
+      //
+      final String comment = "gerrit-code-review@" + hostname();
+      final File rsa = new File(getSitePath(), "ssh_host_rsa_key");
+      final File dsa = new File(getSitePath(), "ssh_host_dsa_key");
+
+      System.err.print(" rsa...");
+      System.err.flush();
+      Runtime.getRuntime().exec(new String[] {"ssh-keygen", //
+          "-q" /* quiet */, //
+          "-t", "rsa", //
+          "-P", "", //
+          "-C", comment, //
+          "-f", rsa.getAbsolutePath() //
+          }).waitFor();
+
+      System.err.print(" dsa...");
+      System.err.flush();
+      Runtime.getRuntime().exec(new String[] {"ssh-keygen", //
+          "-q" /* quiet */, //
+          "-t", "dsa", //
+          "-P", "", //
+          "-C", comment, //
+          "-f", dsa.getAbsolutePath() //
+          }).waitFor();
+
+    } else {
+      // Generate the SSH daemon host key ourselves. This is complex
+      // because SimpleGeneratorHostKeyProvider doesn't mark the data
+      // file as only readable by us, exposing the private key for a
+      // short period of time. We try to reduce that risk by creating
+      // the key within a temporary directory.
+      //
+      final File tmpdir = new File(getSitePath(), "tmp.sshkeygen");
+      if (!tmpdir.mkdir()) {
+        throw die("Cannot create directory " + tmpdir);
+      }
+      chmod600(tmpdir);
+
+      final String keyname = "ssh_host_key";
+      final File tmpkey = new File(tmpdir, keyname);
+      final SimpleGeneratorHostKeyProvider p;
+
+      System.err.print(" rsa(simple)...");
+      System.err.flush();
+      p = new SimpleGeneratorHostKeyProvider();
+      p.setPath(tmpkey.getAbsolutePath());
+      p.setAlgorithm("RSA");
+      p.loadKeys(); // forces the key to generate.
+      chmod600(tmpkey);
+
+      final File key = new File(getSitePath(), keyname);
+      if (!tmpkey.renameTo(key)) {
+        throw die("Cannot rename " + tmpkey + " to " + key);
+      }
+      if (!tmpdir.delete()) {
+        throw die("Cannot delete " + tmpdir);
+      }
+    }
+    System.err.println(" done");
+  }
+
+  private void init_httpd(final Config cfg, final Config sec)
+      throws IOException, InterruptedException {
+    ui.header("HTTP Daemon");
+
+    final boolean reverseProxy =
+        ui.yesno("Behind reverse HTTP proxy (e.g. Apache mod_proxy)");
+
+    final boolean useSSL;
+    if (reverseProxy) {
+      useSSL = ui.yesno("Does the proxy server use https:// (SSL)");
+    } else {
+      useSSL = ui.yesno("Use https:// (SSL)");
+    }
+
+    final String scheme = useSSL ? "https" : "http";
+    final String port_def = useSSL ? "8443" : "8080";
+    String httpd_hostname = ui.readString(reverseProxy ? "localhost" : "*", //
+        "Gerrit HTTP listens on address");
+    String httpd_port = ui.readString(reverseProxy ? "8081" : port_def, //
+        "Gerrit HTTP listens on port");
+
+    String context = "/";
+    if (reverseProxy) {
+      context = ui.readString("/", "Gerrit's subdirectory on proxy server");
+      if (!context.endsWith("/")) {
+        context += "/";
+      }
+    }
+
+    final String httpd_url = (reverseProxy ? "proxy-" : "") //
+        + scheme + "://" + httpd_hostname + ":" + httpd_port + context;
+    set(cfg, "httpd", "listenUrl", httpd_url);
+
+    if (useSSL && !reverseProxy
+        && ui.yesno("Create self-signed SSL certificate")) {
+      final String certName =
+          ui.readString("*".equals(httpd_hostname) ? hostname()
+              : httpd_hostname, "Certificate server name");
+      final String validity =
+          ui.readString("365", "Certificate expires in (days)");
+
+      final String ssl_pass = SignedToken.generateRandomKey();
+      final String dname =
+          "CN=" + certName + ",OU=Gerrit Code Review,O=" + domainOf(certName);
+
+      final File tmpdir = new File(getSitePath(), "tmp.sslcertgen");
+      if (!tmpdir.mkdir()) {
+        throw die("Cannot create directory " + tmpdir);
+      }
+      chmod600(tmpdir);
+
+      final File tmpstore = new File(tmpdir, "keystore");
+      Runtime.getRuntime().exec(new String[] {"keytool", //
+          "-keystore", tmpstore.getAbsolutePath(), //
+          "-storepass", ssl_pass, //
+          "-genkeypair", //
+          "-alias", certName, //
+          "-keyalg", "RSA", //
+          "-validity", validity, //
+          "-dname", dname, //
+          "-keypass", ssl_pass, //
+      }).waitFor();
+      chmod600(tmpstore);
+
+      final File store = new File(getSitePath(), "keystore");
+      if (!tmpstore.renameTo(store)) {
+        throw die("Cannot rename " + tmpstore + " to " + store);
+      }
+      if (!tmpdir.delete()) {
+        throw die("Cannot delete " + tmpdir);
+      }
+
+      set(sec, "httpd", "sslKeyPassword", ssl_pass);
+      set(cfg, "gerrit", "canonicalWebUrl", "https://" + certName + ":"
+          + httpd_port + context);
+    }
+  }
+
+  private <T extends Enum<?>> void set(Config cfg, String section, String name,
+      T value, T def) {
+    if (value != null && value != def) {
+      cfg.setString(section, null, name, value.toString());
+    } else {
+      cfg.unset(section, null, name);
+    }
+  }
+
+  private void set(Config cfg, String section, String name, String value) {
+    if (value != null && !value.isEmpty()) {
+      cfg.setString(section, null, name, value);
+    } else {
+      cfg.unset(section, null, name);
+    }
+  }
+
+  private void inject() {
+    dbInjector = createDbInjector();
+    sysInjector = createSysInjector();
+    sysInjector.injectMembers(this);
+  }
+
+  private Injector createSysInjector() {
+    final List<Module> modules = new ArrayList<Module>();
+    modules.add(new AbstractModule() {
+      @Override
+      protected void configure() {
+        bind(GitRepositoryManager.class);
+      }
+    });
+    return dbInjector.createChildInjector(modules);
+  }
+
+  private LibraryDownloader createDownloader() {
+    return new LibraryDownloader(ui, getSitePath());
+  }
+
+  private static String version() {
+    return com.google.gerrit.common.Version.getVersion();
+  }
+
+  private static String username() {
+    return System.getProperty("user.name");
+  }
+
+  private static String hostname() {
+    return SystemReader.getInstance().getHostname();
+  }
+
+  private static boolean isLocal(final String hostname) {
+    try {
+      return InetAddress.getByName(hostname).isLoopbackAddress();
+    } catch (UnknownHostException e) {
+      return false;
+    }
+  }
+
+  private static String dnOf(String name) {
+    if (name != null) {
+      int p = name.indexOf("://");
+      if (0 < p) {
+        name = name.substring(p + 3);
+      }
+
+      p = name.indexOf(".");
+      if (0 < p) {
+        name = name.substring(p + 1);
+        name = "DC=" + name.replaceAll("\\.", ",DC=");
+      } else {
+        name = null;
+      }
+    }
+    return name;
+  }
+
+  private static String domainOf(String name) {
+    if (name != null) {
+      int p = name.indexOf("://");
+      if (0 < p) {
+        name = name.substring(p + 3);
+      }
+      p = name.indexOf(".");
+      if (0 < p) {
+        name = name.substring(p + 1);
+      }
+    }
+    return name;
+  }
+
+  private static void recursiveDelete(File path) {
+    File[] entries = path.listFiles();
+    if (entries != null) {
+      for (File e : entries) {
+        recursiveDelete(e);
+      }
+    }
+    if (!path.delete() && path.exists()) {
+      System.err.println("warn: Cannot remove " + path);
+    }
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LibraryDownloader.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LibraryDownloader.java
new file mode 100644
index 0000000..d72c948
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LibraryDownloader.java
@@ -0,0 +1,233 @@
+// Copyright (C) 2009 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm;
+
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.util.HttpSupport;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.Proxy;
+import java.net.ProxySelector;
+import java.net.URL;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+/** Get optional or required 3rd party library files into $site_path/lib. */
+class LibraryDownloader {
+  private final ConsoleUI console;
+  private final File libDirectory;
+  private boolean required;
+  private String name;
+  private String jarUrl;
+  private String sha1;
+  private File dst;
+
+  LibraryDownloader(final ConsoleUI console, final File sitePath) {
+    this.console = console;
+    this.libDirectory = new File(sitePath, "lib");
+  }
+
+  LibraryDownloader setRequired(final boolean required) {
+    this.required = required;
+    return this;
+  }
+
+  LibraryDownloader setName(final String name) {
+    this.name = name;
+    return this;
+  }
+
+  LibraryDownloader setJarUrl(final String url) {
+    this.jarUrl = url;
+    return this;
+  }
+
+  LibraryDownloader setSHA1(final String sha1) {
+    this.sha1 = sha1;
+    return this;
+  }
+
+  void download() {
+    if (jarUrl == null || !jarUrl.contains("/")) {
+      throw new IllegalStateException("Invalid JarUrl for " + name);
+    }
+
+    final String jarName = jarUrl.substring(jarUrl.lastIndexOf('/') + 1);
+    if (jarName.contains("/") || jarName.contains("\\")) {
+      throw new IllegalStateException("Invalid JarUrl: " + jarUrl);
+    }
+
+    if (name == null) {
+      name = jarName;
+    }
+
+    dst = new File(libDirectory, jarName);
+    if (!dst.exists() && shouldGet()) {
+      doGet();
+    }
+  }
+
+  private boolean shouldGet() {
+    if (console.isBatch()) {
+      return required;
+
+    } else {
+      final StringBuilder msg = new StringBuilder();
+      msg.append("\n");
+      msg.append("Gerrit Code Review is not shipped with %s\n");
+      if (required) {
+        msg.append("**  This library is required for your configuration. **\n");
+      } else {
+        msg.append("  If available, Gerrit can take advantage of features\n");
+        msg.append("  in the library, but will also function without it.\n");
+      }
+      msg.append("Download and install it now");
+      return console.yesno(msg.toString(), name);
+    }
+  }
+
+  private void doGet() {
+    if (!libDirectory.exists() && !libDirectory.mkdirs()) {
+      throw new Die("Cannot create " + libDirectory);
+    }
+
+    try {
+      doGetByHttp();
+      verifyFileChecksum();
+    } catch (IOException err) {
+      dst.delete();
+
+      if (console.isBatch()) {
+        throw new Die("error: Cannot get " + jarUrl, err);
+      }
+
+      System.err.println();
+      System.err.println();
+      System.err.println("error: " + err.getMessage());
+      System.err.println("Please download:");
+      System.err.println();
+      System.err.println("  " + jarUrl);
+      System.err.println();
+      System.err.println("and save as:");
+      System.err.println();
+      System.err.println("  " + dst.getAbsolutePath());
+      System.err.println();
+      System.err.flush();
+
+      console.waitForUser();
+
+      if (dst.exists()) {
+        verifyFileChecksum();
+
+      } else if (!console.yesno("Continue without this library")) {
+        throw new Die("aborted by user");
+      }
+    }
+  }
+
+  private void doGetByHttp() throws IOException {
+    System.err.print("Downloading " + jarUrl + " ...");
+    System.err.flush();
+    try {
+      final ProxySelector proxySelector = ProxySelector.getDefault();
+      final URL url = new URL(jarUrl);
+      final Proxy proxy = HttpSupport.proxyFor(proxySelector, url);
+      final HttpURLConnection c = (HttpURLConnection) url.openConnection(proxy);
+      final InputStream in;
+
+      switch (HttpSupport.response(c)) {
+        case HttpURLConnection.HTTP_OK:
+          in = c.getInputStream();
+          break;
+
+        case HttpURLConnection.HTTP_NOT_FOUND:
+          throw new FileNotFoundException(url.toString());
+
+        default:
+          throw new IOException(url.toString() + ": " + HttpSupport.response(c)
+              + " " + c.getResponseMessage());
+      }
+
+      try {
+        final OutputStream out = new FileOutputStream(dst);
+        try {
+          final byte[] buf = new byte[8192];
+          int n;
+          while ((n = in.read(buf)) > 0) {
+            out.write(buf, 0, n);
+          }
+        } finally {
+          out.close();
+        }
+      } finally {
+        in.close();
+      }
+      System.err.println(" OK");
+      System.err.flush();
+    } catch (IOException err) {
+      dst.delete();
+      System.err.println(" !! FAIL !!");
+      System.err.flush();
+      throw err;
+    }
+  }
+
+  private void verifyFileChecksum() {
+    if (sha1 != null) {
+      try {
+        final MessageDigest md = MessageDigest.getInstance("SHA-1");
+        final FileInputStream in = new FileInputStream(dst);
+        try {
+          final byte[] buf = new byte[8192];
+          int n;
+          while ((n = in.read(buf)) > 0) {
+            md.update(buf, 0, n);
+          }
+        } finally {
+          in.close();
+        }
+
+        if (sha1.equals(ObjectId.fromRaw(md.digest()).name())) {
+          System.err.println("Checksum " + dst.getName() + " OK");
+          System.err.flush();
+
+        } else if (console.isBatch()) {
+          dst.delete();
+          throw new Die(dst + " SHA-1 checksum does not match");
+
+        } else if (!console.yesno("error: SHA-1 checksum does not match\n"
+            + "Use %s anyway", dst.getName())) {
+          dst.delete();
+          throw new Die("aborted by user");
+        }
+
+      } catch (IOException checksumError) {
+        dst.delete();
+        throw new Die("cannot checksum " + dst, checksumError);
+
+      } catch (NoSuchAlgorithmException checksumError) {
+        dst.delete();
+        throw new Die("cannot checksum " + dst, checksumError);
+      }
+    }
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/SiteProgram.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/SiteProgram.java
index 7124a6a..8387eeb 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/SiteProgram.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/SiteProgram.java
@@ -31,19 +31,22 @@
 import org.kohsuke.args4j.Option;
 
 import java.io.File;
+import java.io.FileFilter;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 import java.net.MalformedURLException;
 import java.net.URL;
 import java.net.URLClassLoader;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 
 import javax.sql.DataSource;
 
 public abstract class SiteProgram extends AbstractProgram {
-  private boolean siteLibLoaded;
-
   @Option(name = "--site-path", aliases = {"-d"}, usage = "Local directory containing site data")
   private File sitePath = new File(".");
 
@@ -58,30 +61,38 @@
 
   /** Load extra JARs from {@code lib/} subdirectory of {@link #getSitePath()} */
   protected void loadSiteLib() {
-    if (!siteLibLoaded) {
-      final File libdir = new File(getSitePath(), "lib");
-      final File[] list = libdir.listFiles();
-      if (list != null) {
-        final List<File> toLoad = new ArrayList<File>();
-        for (final File u : list) {
-          if (u.isFile() && (u.getName().endsWith(".jar") //
-              || u.getName().endsWith(".zip"))) {
-            toLoad.add(u);
-          }
+    final File libdir = new File(getSitePath(), "lib");
+    final File[] list = libdir.listFiles(new FileFilter() {
+      @Override
+      public boolean accept(File path) {
+        if (!path.isFile()) {
+          return false;
         }
-        addToClassLoader(toLoad);
+        return path.getName().endsWith(".jar") //
+            || path.getName().endsWith(".zip");
       }
-
-      siteLibLoaded = true;
+    });
+    if (list != null && 0 < list.length) {
+      Arrays.sort(list, new Comparator<File>() {
+        @Override
+        public int compare(File a, File b) {
+          return a.getName().compareTo(b.getName());
+        }
+      });
+      addToClassLoader(list);
     }
   }
 
-  private void addToClassLoader(final List<File> additionalLocations) {
+  private void addToClassLoader(final File[] additionalLocations) {
     final ClassLoader cl = getClass().getClassLoader();
     if (!(cl instanceof URLClassLoader)) {
       throw noAddURL("Not loaded by URLClassLoader", null);
     }
 
+    final URLClassLoader ucl = (URLClassLoader) cl;
+    final Set<URL> have = new HashSet<URL>();
+    have.addAll(Arrays.asList(ucl.getURLs()));
+
     final Method m;
     try {
       m = URLClassLoader.class.getDeclaredMethod("addURL", URL.class);
@@ -92,17 +103,20 @@
       throw noAddURL("Method addURL not available", e);
     }
 
-    for (final File u : additionalLocations) {
+    for (final File path : additionalLocations) {
       try {
-        m.invoke(cl, u.toURI().toURL());
+        final URL url = path.toURI().toURL();
+        if (have.add(url)) {
+          m.invoke(cl, url);
+        }
       } catch (MalformedURLException e) {
-        throw noAddURL("addURL " + u + " failed", e);
+        throw noAddURL("addURL " + path + " failed", e);
       } catch (IllegalArgumentException e) {
-        throw noAddURL("addURL " + u + " failed", e);
+        throw noAddURL("addURL " + path + " failed", e);
       } catch (IllegalAccessException e) {
-        throw noAddURL("addURL " + u + " failed", e);
+        throw noAddURL("addURL " + path + " failed", e);
       } catch (InvocationTargetException e) {
-        throw noAddURL("addURL " + u + " failed", e.getCause());
+        throw noAddURL("addURL " + path + " failed", e.getCause());
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManager.java
index 33a1f58..2d48568 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitRepositoryManager.java
@@ -29,10 +29,13 @@
 import org.eclipse.jgit.lib.WindowCache;
 import org.eclipse.jgit.lib.WindowCacheConfig;
 import org.eclipse.jgit.lib.RepositoryCache.FileKey;
+import org.eclipse.jgit.util.IO;
+import org.eclipse.jgit.util.RawParseUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.io.File;
+import java.io.FileNotFoundException;
 import java.io.IOException;
 
 /** Class managing Git repositories. */
@@ -40,6 +43,9 @@
 public class GitRepositoryManager {
   private static final Logger log = LoggerFactory.getLogger(GitRepositoryManager.class);
 
+  private static final String UNNAMED =
+      "Unnamed repository; edit this file to name it for gitweb.";
+
   public static class Lifecycle implements LifecycleListener {
     private final Config cfg;
 
@@ -148,9 +154,45 @@
   }
 
   /**
+   * Read the {@code GIT_DIR/description} file for gitweb.
+   * <p>
+   * NB: This code should really be in JGit, as a member of the Repository
+   * object. Until it moves there, its here.
+   *
+   * @param name the repository name, relative to the base directory.
+   * @return description text; null if no description has been configured.
+   * @throws RepositoryNotFoundException the named repository does not exist.
+   * @throws IOException the description file exists, but is not readable by
+   *         this process.
+   */
+  public String getProjectDescription(final String name)
+      throws RepositoryNotFoundException, IOException {
+    final Repository e = openRepository(name);
+    final File d = new File(e.getDirectory(), "description");
+
+    String description;
+    try {
+      description = RawParseUtils.decode(IO.readFully(d));
+    } catch (FileNotFoundException err) {
+      return null;
+    }
+
+    if (description != null) {
+      description = description.trim();
+      if (description.isEmpty()) {
+        description = null;
+      }
+      if (UNNAMED.equals(description)) {
+        description = null;
+      }
+    }
+    return description;
+  }
+
+  /**
    * Set the {@code GIT_DIR/description} file for gitweb.
    * <p>
-   * NB: This code should really be in JGit, as a member of the Repostiory
+   * NB: This code should really be in JGit, as a member of the Repository
    * object. Until it moves there, its here.
    *
    * @param name the repository name, relative to the base directory.