From 7613df52959b6e2ac1094d2263be310fb3e2723b Mon Sep 17 00:00:00 2001
From: David Ostrovsky <david@ostrovsky.org>
Date: Thu, 10 Apr 2014 18:58:07 -0400
Subject: [PATCH] SSHD: Add support for generic commands

---
 src/main/java/com/gitblit/transport/ssh/SshSession.java                |  102 ++
 src/main/java/com/gitblit/utils/WorkQueue.java                         |  340 ++++++++
 src/main/java/com/gitblit/utils/TaskInfoFactory.java                   |   19 
 src/main/java/com/gitblit/utils/cli/CmdLineParser.java                 |  440 +++++++++++
 src/main/java/com/gitblit/transport/ssh/AbstractSshCommand.java        |   11 
 src/main/java/com/gitblit/transport/ssh/CommandMetaData.java           |   31 
 src/main/java/com/gitblit/transport/ssh/commands/BaseCommand.java      |  430 ++++++++++
 src/main/java/com/gitblit/transport/ssh/CommandDispatcher.java         |   44 +
 src/main/java/com/gitblit/utils/IdGenerator.java                       |   91 ++
 src/main/java/com/gitblit/transport/ssh/SshCommandServer.java          |   12 
 /dev/null                                                              |   37 
 src/main/java/com/gitblit/utils/cli/SubcommandHandler.java             |   43 +
 src/main/java/com/gitblit/transport/ssh/commands/VersionCommand.java   |   35 
 src/main/java/com/gitblit/transport/ssh/SshDaemon.java                 |   80 +
 src/main/java/com/gitblit/transport/ssh/SshKeyCacheEntry.java          |   26 
 src/main/java/com/gitblit/transport/ssh/commands/CreateRepository.java |   36 
 src/main/java/com/gitblit/transport/ssh/commands/DispatchCommand.java  |  156 +++
 src/main/java/com/gitblit/transport/ssh/SshKeyAuthenticator.java       |  116 ++
 src/main/java/com/gitblit/transport/ssh/commands/SshCommand.java       |   45 +
 src/main/java/log4j.properties                                         |    1 
 src/main/java/com/gitblit/transport/ssh/SshCommandFactory.java         |  255 +++++
 21 files changed, 2,260 insertions(+), 90 deletions(-)

diff --git a/src/main/java/com/gitblit/transport/ssh/AbstractSshCommand.java b/src/main/java/com/gitblit/transport/ssh/AbstractSshCommand.java
index e4741ed..a6681f5 100644
--- a/src/main/java/com/gitblit/transport/ssh/AbstractSshCommand.java
+++ b/src/main/java/com/gitblit/transport/ssh/AbstractSshCommand.java
@@ -15,9 +15,12 @@
  */
 package com.gitblit.transport.ssh;
 
+import java.io.BufferedWriter;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
 
 import org.apache.sshd.server.Command;
 import org.apache.sshd.server.Environment;
@@ -25,12 +28,14 @@
 import org.apache.sshd.server.SessionAware;
 import org.apache.sshd.server.session.ServerSession;
 
+import com.google.common.base.Charsets;
+
 /**
  *
  * @author Eric Myrhe
  *
  */
-abstract class AbstractSshCommand implements Command, SessionAware {
+public abstract class AbstractSshCommand implements Command, SessionAware {
 
 	protected InputStream in;
 
@@ -70,6 +75,10 @@
 	@Override
 	public void destroy() {}
 
+    protected static PrintWriter toPrintWriter(final OutputStream o) {
+        return new PrintWriter(new BufferedWriter(new OutputStreamWriter(o, Charsets.UTF_8)));
+    }
+
 	@Override
 	public abstract void start(Environment env) throws IOException;
 }
diff --git a/src/main/java/com/gitblit/transport/ssh/CommandDispatcher.java b/src/main/java/com/gitblit/transport/ssh/CommandDispatcher.java
new file mode 100644
index 0000000..18c1c33
--- /dev/null
+++ b/src/main/java/com/gitblit/transport/ssh/CommandDispatcher.java
@@ -0,0 +1,44 @@
+package com.gitblit.transport.ssh;
+
+import java.util.Map;
+import java.util.Set;
+
+import javax.inject.Inject;
+import javax.inject.Named;
+import javax.inject.Provider;
+
+import org.apache.sshd.server.Command;
+
+import com.gitblit.transport.ssh.commands.DispatchCommand;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+
+public class CommandDispatcher extends DispatchCommand {
+
+  Provider<Command> repo;
+  Provider<Command> version;
+
+  @Inject
+  public CommandDispatcher(final @Named("create-repository") Provider<Command> repo,
+      final @Named("version") Provider<Command> version) {
+    this.repo = repo;
+    this.version = version;
+  }
+
+  public DispatchCommand get() {
+    DispatchCommand root = new DispatchCommand();
+    Map<String, Provider<Command>> origin = Maps.newHashMapWithExpectedSize(2);
+    origin.put("gitblit", new Provider<Command>() {
+      @Override
+      public Command get() {
+        Set<Provider<Command>> gitblit = Sets.newHashSetWithExpectedSize(2);
+        gitblit.add(repo);
+        gitblit.add(version);
+        Command cmd = new DispatchCommand(gitblit);
+        return cmd;
+      }
+    });
+    root.setMap(origin);
+    return root;
+  }
+}
diff --git a/src/main/java/com/gitblit/transport/ssh/CommandMetaData.java b/src/main/java/com/gitblit/transport/ssh/CommandMetaData.java
new file mode 100644
index 0000000..52231b3
--- /dev/null
+++ b/src/main/java/com/gitblit/transport/ssh/CommandMetaData.java
@@ -0,0 +1,31 @@
+//Copyright (C) 2013 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.gitblit.transport.ssh;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+* Annotation tagged on a concrete Command to describe what it is doing
+*/
+@Target({ElementType.TYPE})
+@Retention(RUNTIME)
+public @interface CommandMetaData {
+String name();
+String description() default "";
+}
diff --git a/src/main/java/com/gitblit/transport/ssh/SshCommandFactory.java b/src/main/java/com/gitblit/transport/ssh/SshCommandFactory.java
index c0b4930..85c503d 100644
--- a/src/main/java/com/gitblit/transport/ssh/SshCommandFactory.java
+++ b/src/main/java/com/gitblit/transport/ssh/SshCommandFactory.java
@@ -16,11 +16,23 @@
 package com.gitblit.transport.ssh;
 
 import java.io.IOException;
-import java.util.Scanner;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import javax.inject.Inject;
 
 import org.apache.sshd.server.Command;
 import org.apache.sshd.server.CommandFactory;
 import org.apache.sshd.server.Environment;
+import org.apache.sshd.server.ExitCallback;
+import org.apache.sshd.server.SessionAware;
+import org.apache.sshd.server.session.ServerSession;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.PacketLineOut;
@@ -31,8 +43,13 @@
 import org.eclipse.jgit.transport.resolver.ServiceNotAuthorizedException;
 import org.eclipse.jgit.transport.resolver.ServiceNotEnabledException;
 import org.eclipse.jgit.transport.resolver.UploadPackFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import com.gitblit.git.RepositoryResolver;
+import com.gitblit.transport.ssh.commands.DispatchCommand;
+import com.gitblit.utils.WorkQueue;
+import com.google.common.util.concurrent.Atomics;
 
 /**
  *
@@ -40,30 +57,232 @@
  *
  */
 public class SshCommandFactory implements CommandFactory {
-	public SshCommandFactory(RepositoryResolver<SshDaemonClient> repositoryResolver, UploadPackFactory<SshDaemonClient> uploadPackFactory, ReceivePackFactory<SshDaemonClient> receivePackFactory) {
+  private static final Logger logger = LoggerFactory
+      .getLogger(SshCommandFactory.class);
+  private RepositoryResolver<SshSession> repositoryResolver;
+
+  private UploadPackFactory<SshSession> uploadPackFactory;
+
+  private ReceivePackFactory<SshSession> receivePackFactory;
+  private final ScheduledExecutorService startExecutor;
+
+  private CommandDispatcher dispatcher;
+
+    @Inject
+	public SshCommandFactory(RepositoryResolver<SshSession> repositoryResolver,
+	    UploadPackFactory<SshSession> uploadPackFactory,
+	    ReceivePackFactory<SshSession> receivePackFactory,
+	    WorkQueue workQueue,
+	    CommandDispatcher d) {
 		this.repositoryResolver = repositoryResolver;
 		this.uploadPackFactory = uploadPackFactory;
 		this.receivePackFactory = receivePackFactory;
+		this.dispatcher = d;
+		int threads = 2;//cfg.getInt("sshd","commandStartThreads", 2);
+	    startExecutor = workQueue.createQueue(threads, "SshCommandStart");
 	}
-
-	private RepositoryResolver<SshDaemonClient> repositoryResolver;
-
-	private UploadPackFactory<SshDaemonClient> uploadPackFactory;
-
-	private ReceivePackFactory<SshDaemonClient> receivePackFactory;
 
 	@Override
 	public Command createCommand(final String commandLine) {
-		Scanner commandScanner = new Scanner(commandLine);
-		final String command = commandScanner.next();
-		final String argument = commandScanner.nextLine();
-
+	  return new Trampoline(commandLine);
+        /*
 		if ("git-upload-pack".equals(command))
 			return new UploadPackCommand(argument);
 		if ("git-receive-pack".equals(command))
 			return new ReceivePackCommand(argument);
 		return new NonCommand();
+		*/
 	}
+
+	  private class Trampoline implements Command, SessionAware {
+	    private final String[] argv;
+	    private InputStream in;
+	    private OutputStream out;
+	    private OutputStream err;
+	    private ExitCallback exit;
+	    private Environment env;
+	    private DispatchCommand cmd;
+	    private final AtomicBoolean logged;
+	    private final AtomicReference<Future<?>> task;
+
+	    Trampoline(final String cmdLine) {
+	      argv = split(cmdLine);
+	      logged = new AtomicBoolean();
+	      task = Atomics.newReference();
+	    }
+
+	    @Override
+	    public void setSession(ServerSession session) {
+	    // TODO Auto-generated method stub
+	    }
+
+	    public void setInputStream(final InputStream in) {
+	      this.in = in;
+	    }
+
+	    public void setOutputStream(final OutputStream out) {
+	      this.out = out;
+	    }
+
+	    public void setErrorStream(final OutputStream err) {
+	      this.err = err;
+	    }
+
+	    public void setExitCallback(final ExitCallback callback) {
+	      this.exit = callback;
+	    }
+
+	    public void start(final Environment env) throws IOException {
+	      this.env = env;
+	      task.set(startExecutor.submit(new Runnable() {
+	        public void run() {
+	          try {
+	            onStart();
+	          } catch (Exception e) {
+	            logger.warn("Cannot start command ", e);
+	          }
+	        }
+
+	        @Override
+	        public String toString() {
+	          //return "start (user " + ctx.getSession().getUsername() + ")";
+	          return "start (user TODO)";
+	        }
+	      }));
+	    }
+
+	    private void onStart() throws IOException {
+	      synchronized (this) {
+	        //final Context old = sshScope.set(ctx);
+	        try {
+	          cmd = dispatcher.get();
+	          cmd.setArguments(argv);
+	          cmd.setInputStream(in);
+	          cmd.setOutputStream(out);
+	          cmd.setErrorStream(err);
+	          cmd.setExitCallback(new ExitCallback() {
+	            @Override
+	            public void onExit(int rc, String exitMessage) {
+	              exit.onExit(translateExit(rc), exitMessage);
+	              log(rc);
+	            }
+
+	            @Override
+	            public void onExit(int rc) {
+	              exit.onExit(translateExit(rc));
+	              log(rc);
+	            }
+	          });
+	          cmd.start(env);
+	        } finally {
+	          //sshScope.set(old);
+	        }
+	      }
+	    }
+
+	    private int translateExit(final int rc) {
+	      return rc;
+//
+//	      switch (rc) {
+//	        case BaseCommand.STATUS_NOT_ADMIN:
+//	          return 1;
+//
+//	        case BaseCommand.STATUS_CANCEL:
+//	          return 15 /* SIGKILL */;
+//
+//	        case BaseCommand.STATUS_NOT_FOUND:
+//	          return 127 /* POSIX not found */;
+//
+//	        default:
+//	          return rc;
+//	      }
+
+	    }
+
+	    private void log(final int rc) {
+	      if (logged.compareAndSet(false, true)) {
+	        //log.onExecute(cmd, rc);
+	        logger.info("onExecute: {} exits with: {}", cmd.getClass().getSimpleName(), rc);
+	      }
+	    }
+
+	    @Override
+	    public void destroy() {
+	      Future<?> future = task.getAndSet(null);
+	      if (future != null) {
+	        future.cancel(true);
+//	        destroyExecutor.execute(new Runnable() {
+//	          @Override
+//	          public void run() {
+//	            onDestroy();
+//	          }
+//	        });
+	      }
+	    }
+
+	    private void onDestroy() {
+	      synchronized (this) {
+	        if (cmd != null) {
+	          //final Context old = sshScope.set(ctx);
+	          try {
+	            cmd.destroy();
+	            //log(BaseCommand.STATUS_CANCEL);
+	          } finally {
+	            //ctx = null;
+	            cmd = null;
+	            //sshScope.set(old);
+	          }
+	        }
+	      }
+	    }
+	  }
+
+	  /** Split a command line into a string array. */
+	  static public String[] split(String commandLine) {
+	    final List<String> list = new ArrayList<String>();
+	    boolean inquote = false;
+	    boolean inDblQuote = false;
+	    StringBuilder r = new StringBuilder();
+	    for (int ip = 0; ip < commandLine.length();) {
+	      final char b = commandLine.charAt(ip++);
+	      switch (b) {
+	        case '\t':
+	        case ' ':
+	          if (inquote || inDblQuote)
+	            r.append(b);
+	          else if (r.length() > 0) {
+	            list.add(r.toString());
+	            r = new StringBuilder();
+	          }
+	          continue;
+	        case '\"':
+	          if (inquote)
+	            r.append(b);
+	          else
+	            inDblQuote = !inDblQuote;
+	          continue;
+	        case '\'':
+	          if (inDblQuote)
+	            r.append(b);
+	          else
+	            inquote = !inquote;
+	          continue;
+	        case '\\':
+	          if (inquote || ip == commandLine.length())
+	            r.append(b); // literal within a quote
+	          else
+	            r.append(commandLine.charAt(ip++));
+	          continue;
+	        default:
+	          r.append(b);
+	          continue;
+	      }
+	    }
+	    if (r.length() > 0) {
+	      list.add(r.toString());
+	    }
+	    return list.toArray(new String[list.size()]);
+	  }
 
 	public abstract class RepositoryCommand extends AbstractSshCommand {
 		protected final String repositoryName;
@@ -76,7 +295,7 @@
 		public void start(Environment env) throws IOException {
 			Repository db = null;
 			try {
-				SshDaemonClient client = session.getAttribute(SshDaemonClient.ATTR_KEY);
+				SshSession client = session.getAttribute(SshSession.KEY);
 				db = selectRepository(client, repositoryName);
 				if (db == null) return;
 				run(client, db);
@@ -92,7 +311,7 @@
 			}
 		}
 
-		protected Repository selectRepository(SshDaemonClient client, String name) throws IOException {
+		protected Repository selectRepository(SshSession client, String name) throws IOException {
 			try {
 				return openRepository(client, name);
 			} catch (ServiceMayNotContinueException e) {
@@ -104,7 +323,7 @@
 			}
 		}
 
-		protected Repository openRepository(SshDaemonClient client, String name)
+		protected Repository openRepository(SshSession client, String name)
 				throws ServiceMayNotContinueException {
 			// Assume any attempt to use \ was by a Windows client
 			// and correct to the more typical / used in Git URIs.
@@ -129,7 +348,7 @@
 			}
 		}
 
-		protected abstract void run(SshDaemonClient client, Repository db)
+		protected abstract void run(SshSession client, Repository db)
 			throws IOException, ServiceNotEnabledException, ServiceNotAuthorizedException;
 	}
 
@@ -137,7 +356,7 @@
 		public UploadPackCommand(String repositoryName) { super(repositoryName); }
 
 		@Override
-		protected void run(SshDaemonClient client, Repository db)
+		protected void run(SshSession client, Repository db)
 				throws IOException, ServiceNotEnabledException, ServiceNotAuthorizedException {
 			UploadPack up = uploadPackFactory.create(client, db);
 			up.upload(in, out, null);
@@ -148,7 +367,7 @@
 		public ReceivePackCommand(String repositoryName) { super(repositoryName); }
 
 		@Override
-		protected void run(SshDaemonClient client, Repository db)
+		protected void run(SshSession client, Repository db)
 				throws IOException, ServiceNotEnabledException, ServiceNotAuthorizedException {
 			ReceivePack rp = receivePackFactory.create(client, db);
 			rp.receive(in, out, null);
diff --git a/src/main/java/com/gitblit/transport/ssh/SshCommandServer.java b/src/main/java/com/gitblit/transport/ssh/SshCommandServer.java
index 26e3d67..7186737 100644
--- a/src/main/java/com/gitblit/transport/ssh/SshCommandServer.java
+++ b/src/main/java/com/gitblit/transport/ssh/SshCommandServer.java
@@ -17,11 +17,14 @@
 
 import java.io.IOException;
 import java.net.InetSocketAddress;
+import java.net.SocketAddress;
 import java.security.InvalidKeyException;
 import java.util.Arrays;
 import java.util.Iterator;
 import java.util.LinkedList;
 import java.util.List;
+
+import javax.inject.Inject;
 
 import org.apache.mina.core.future.IoFuture;
 import org.apache.mina.core.future.IoFutureListener;
@@ -69,6 +72,8 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import com.gitblit.utils.IdGenerator;
+
 /**
  *
  * @author Eric Myhre
@@ -78,7 +83,8 @@
 
 	private static final Logger log = LoggerFactory.getLogger(SshCommandServer.class);
 
-	public SshCommandServer() {
+	@Inject
+	public SshCommandServer(final IdGenerator idGenerator) {
 		setSessionFactory(new SessionFactory() {
 			@Override
 			protected ServerSession createSession(final IoSession io) throws Exception {
@@ -90,7 +96,9 @@
 				}
 
 				final ServerSession s = (ServerSession) super.createSession(io);
-				s.setAttribute(SshDaemonClient.ATTR_KEY, new SshDaemonClient());
+				SocketAddress peer = io.getRemoteAddress();
+				SshSession session = new SshSession(idGenerator.next(), peer);
+				s.setAttribute(SshSession.KEY, session);
 
 				io.getCloseFuture().addListener(new IoFutureListener<IoFuture>() {
 					@Override
diff --git a/src/main/java/com/gitblit/transport/ssh/SshDaemon.java b/src/main/java/com/gitblit/transport/ssh/SshDaemon.java
index 6f5d5f9..056735a 100644
--- a/src/main/java/com/gitblit/transport/ssh/SshDaemon.java
+++ b/src/main/java/com/gitblit/transport/ssh/SshDaemon.java
@@ -21,6 +21,10 @@
 import java.text.MessageFormat;
 import java.util.concurrent.atomic.AtomicBoolean;
 
+import javax.inject.Named;
+import javax.inject.Singleton;
+
+import org.apache.sshd.server.Command;
 import org.apache.sshd.server.keyprovider.PEMGeneratorHostKeyProvider;
 import org.eclipse.jgit.internal.JGitText;
 import org.eclipse.jgit.transport.resolver.ReceivePackFactory;
@@ -34,7 +38,14 @@
 import com.gitblit.git.GitblitUploadPackFactory;
 import com.gitblit.git.RepositoryResolver;
 import com.gitblit.manager.IGitblit;
+import com.gitblit.transport.ssh.commands.CreateRepository;
+import com.gitblit.transport.ssh.commands.VersionCommand;
+import com.gitblit.utils.IdGenerator;
 import com.gitblit.utils.StringUtils;
+
+import dagger.Module;
+import dagger.ObjectGraph;
+import dagger.Provides;
 
 /**
  * Manager for the ssh transport. Roughly analogous to the
@@ -62,11 +73,7 @@
 
 	private SshCommandServer sshd;
 
-	private RepositoryResolver<SshDaemonClient> repositoryResolver;
-
-	private UploadPackFactory<SshDaemonClient> uploadPackFactory;
-
-	private ReceivePackFactory<SshDaemonClient> receivePackFactory;
+	private IGitblit gitblit;
 
 	/**
 	 * Construct the Gitblit SSH daemon.
@@ -75,6 +82,7 @@
 	 */
 	public SshDaemon(IGitblit gitblit) {
 
+	    this.gitblit = gitblit;
 		IStoredSettings settings = gitblit.getSettings();
 		int port = settings.getInteger(Keys.git.sshPort, 0);
 		String bindInterface = settings.getString(Keys.git.sshBindInterface, "localhost");
@@ -85,7 +93,8 @@
 			myAddress = new InetSocketAddress(bindInterface, port);
 		}
 
-		sshd = new SshCommandServer();
+		ObjectGraph graph = ObjectGraph.create(new SshModule());
+		sshd = graph.get(SshCommandServer.class);
 		sshd.setPort(myAddress.getPort());
 		sshd.setHost(myAddress.getHostName());
 		sshd.setup();
@@ -93,15 +102,8 @@
 		sshd.setPublickeyAuthenticator(new SshKeyAuthenticator(gitblit));
 
 		run = new AtomicBoolean(false);
-		repositoryResolver = new RepositoryResolver<SshDaemonClient>(gitblit);
-		uploadPackFactory = new GitblitUploadPackFactory<SshDaemonClient>(gitblit);
-		receivePackFactory = new GitblitReceivePackFactory<SshDaemonClient>(gitblit);
-
-		sshd.setCommandFactory(new SshCommandFactory(
-				repositoryResolver,
-				uploadPackFactory,
-				receivePackFactory
-		));
+        SshCommandFactory f = graph.get(SshCommandFactory.class);
+		sshd.setCommandFactory(f);
 	}
 
 	public int getPort() {
@@ -156,4 +158,52 @@
 			}
 		}
 	}
+
+	@Module(library = true,
+	    injects = {
+        IGitblit.class,
+        SshCommandFactory.class,
+        SshCommandServer.class,
+	    })
+	public class SshModule {
+	  @Provides @Named("create-repository") Command provideCreateRepository() {
+	    return new CreateRepository();
+	  }
+
+	  @Provides @Named("version") Command provideVersion() {
+        return new VersionCommand();
+      }
+
+//	   @Provides(type=Type.SET) @Named("git") Command provideVersionCommand2() {
+//	        return new CreateRepository();
+//	   }
+
+//	  @Provides @Named("git") DispatchCommand providesGitCommand() {
+//	    return new DispatchCommand("git");
+//	  }
+
+//	  @Provides (type=Type.SET) Provider<Command> provideNonCommand() {
+//	      return new SshCommandFactory.NonCommand();
+//	  }
+
+	  @Provides @Singleton IdGenerator provideIdGenerator() {
+	     return new IdGenerator();
+	  }
+
+	  @Provides @Singleton RepositoryResolver<SshSession> provideRepositoryResolver() {
+	    return new RepositoryResolver<SshSession>(provideGitblit());
+	  }
+
+      @Provides @Singleton UploadPackFactory<SshSession> provideUploadPackFactory() {
+        return new GitblitUploadPackFactory<SshSession>(provideGitblit());
+      }
+
+      @Provides @Singleton ReceivePackFactory<SshSession> provideReceivePackFactory() {
+        return new GitblitReceivePackFactory<SshSession>(provideGitblit());
+      }
+
+	  @Provides @Singleton IGitblit provideGitblit() {
+	      return SshDaemon.this.gitblit;
+	  }
+	}
 }
diff --git a/src/main/java/com/gitblit/transport/ssh/SshDaemonClient.java b/src/main/java/com/gitblit/transport/ssh/SshDaemonClient.java
deleted file mode 100644
index 2e8008a..0000000
--- a/src/main/java/com/gitblit/transport/ssh/SshDaemonClient.java
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
- * Copyright 2014 gitblit.com.
- *
- * 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.gitblit.transport.ssh;
-
-import java.net.InetAddress;
-
-import org.apache.sshd.common.Session.AttributeKey;
-
-/**
- *
- * @author Eric Myrhe
- *
- */
-public class SshDaemonClient {
-	public static final AttributeKey<SshDaemonClient> ATTR_KEY = new AttributeKey<SshDaemonClient>();
-
-	public InetAddress getRemoteAddress() {
-		return null;
-	}
-
-	public String getRemoteUser() {
-		return null;
-	}
-}
diff --git a/src/main/java/com/gitblit/transport/ssh/SshKeyAuthenticator.java b/src/main/java/com/gitblit/transport/ssh/SshKeyAuthenticator.java
index 4c97c58..4ab20f3 100644
--- a/src/main/java/com/gitblit/transport/ssh/SshKeyAuthenticator.java
+++ b/src/main/java/com/gitblit/transport/ssh/SshKeyAuthenticator.java
@@ -1,26 +1,39 @@
 /*
  * Copyright 2014 gitblit.com.
  *
- * 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
+ * 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
+ * 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.
+ * 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.gitblit.transport.ssh;
 
+import java.io.File;
+import java.io.IOException;
 import java.security.PublicKey;
+import java.util.Locale;
+import java.util.concurrent.ExecutionException;
 
+import org.apache.commons.codec.binary.Base64;
+import org.apache.sshd.common.util.Buffer;
 import org.apache.sshd.server.PublickeyAuthenticator;
 import org.apache.sshd.server.session.ServerSession;
+import org.eclipse.jgit.lib.Constants;
 
 import com.gitblit.manager.IGitblit;
+import com.google.common.base.Charsets;
+import com.google.common.cache.CacheBuilder;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.cache.Weigher;
+import com.google.common.io.Files;
 
 /**
  *
@@ -29,15 +42,84 @@
  */
 public class SshKeyAuthenticator implements PublickeyAuthenticator {
 
-	protected final IGitblit gitblit;
+  protected final IGitblit gitblit;
 
-	public SshKeyAuthenticator(IGitblit gitblit) {
-		this.gitblit = gitblit;
-	}
+  LoadingCache<String, SshKeyCacheEntry> sshKeyCache = CacheBuilder
+      .newBuilder().maximumWeight(2 << 20).weigher(new SshKeyCacheWeigher())
+      .build(new CacheLoader<String, SshKeyCacheEntry>() {
+        public SshKeyCacheEntry load(String key) throws Exception {
+          return loadKey(key);
+        }
 
-	@Override
-	public boolean authenticate(String username, PublicKey key, ServerSession session) {
-		// TODO actually authenticate
-		return true;
-	}
+        private SshKeyCacheEntry loadKey(String key) {
+          try {
+            // TODO(davido): retrieve absolute path to public key directory:
+            //String dir = gitblit.getSettings().getString("public_key_dir", "data/ssh");
+            String dir = "/tmp/";
+            // Expect public key file name in form: <username.pub> in
+            File file = new File(dir + key + ".pub");
+            String str = Files.toString(file, Charsets.ISO_8859_1);
+            final String[] parts = str.split(" ");
+            final byte[] bin =
+                Base64.decodeBase64(Constants.encodeASCII(parts[1]));
+            return new SshKeyCacheEntry(key, new Buffer(bin).getRawPublicKey());
+          } catch (IOException e) {
+            throw new RuntimeException("Canot read public key", e);
+          }
+        }
+      });
+
+  public SshKeyAuthenticator(IGitblit gitblit) {
+    this.gitblit = gitblit;
+  }
+
+  @Override
+  public boolean authenticate(String username, final PublicKey suppliedKey,
+      final ServerSession session) {
+    final SshSession sd = session.getAttribute(SshSession.KEY);
+
+    // if (config.getBoolean("auth", "userNameToLowerCase", false)) {
+    username = username.toLowerCase(Locale.US);
+    // }
+    try {
+      // TODO: allow multiple public keys per user
+      SshKeyCacheEntry key = sshKeyCache.get(username);
+      if (key == null) {
+        sd.authenticationError(username, "no-matching-key");
+        return false;
+      }
+
+      if (key.match(suppliedKey)) {
+        return success(username, session, sd);
+      }
+      return false;
+    } catch (ExecutionException e) {
+      sd.authenticationError(username, "user-not-found");
+      return false;
+    }
+  }
+
+  boolean success(String username, ServerSession session, SshSession sd) {
+    sd.authenticationSuccess(username);
+    /*
+     * sshLog.onLogin();
+     *
+     * GerritServerSession s = (GerritServerSession) session;
+     * s.addCloseSessionListener( new SshFutureListener<CloseFuture>() {
+     *
+     * @Override public void operationComplete(CloseFuture future) { final
+     * Context ctx = sshScope.newContext(null, sd, null); final Context old =
+     * sshScope.set(ctx); try { sshLog.onLogout(); } finally {
+     * sshScope.set(old); } } }); }
+     */
+    return true;
+  }
+
+  private static class SshKeyCacheWeigher implements
+      Weigher<String, SshKeyCacheEntry> {
+    @Override
+    public int weigh(String key, SshKeyCacheEntry value) {
+      return key.length() + value.weigh();
+    }
+  }
 }
diff --git a/src/main/java/com/gitblit/transport/ssh/SshKeyCacheEntry.java b/src/main/java/com/gitblit/transport/ssh/SshKeyCacheEntry.java
new file mode 100644
index 0000000..ddc48b3
--- /dev/null
+++ b/src/main/java/com/gitblit/transport/ssh/SshKeyCacheEntry.java
@@ -0,0 +1,26 @@
+
+package com.gitblit.transport.ssh;
+
+import java.security.PublicKey;
+
+class SshKeyCacheEntry {
+  private final String user;
+  private final PublicKey publicKey;
+
+  SshKeyCacheEntry(String user, PublicKey publicKey) {
+    this.user = user;
+    this.publicKey = publicKey;
+  }
+
+  String getUser() {
+    return user;
+  }
+
+  boolean match(PublicKey inkey) {
+    return publicKey.equals(inkey);
+  }
+
+  int weigh() {
+    return publicKey.getEncoded().length;
+  }
+}
diff --git a/src/main/java/com/gitblit/transport/ssh/SshSession.java b/src/main/java/com/gitblit/transport/ssh/SshSession.java
new file mode 100644
index 0000000..9f18a19
--- /dev/null
+++ b/src/main/java/com/gitblit/transport/ssh/SshSession.java
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2014 gitblit.com.
+ *
+ * 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.gitblit.transport.ssh;
+
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+
+import org.apache.sshd.common.Session.AttributeKey;
+
+/**
+ *
+ * @author Eric Myrhe
+ *
+ */
+public class SshSession {
+  public static final AttributeKey<SshSession> KEY =
+      new AttributeKey<SshSession>();
+
+  private final int sessionId;
+  private final SocketAddress remoteAddress;
+  private final String remoteAsString;
+
+  private volatile String username;
+  private volatile String authError;
+
+  SshSession(int sessionId, SocketAddress peer) {
+    this.sessionId = sessionId;
+    this.remoteAddress = peer;
+    this.remoteAsString = format(remoteAddress);
+  }
+
+  public SocketAddress getRemoteAddress() {
+    return remoteAddress;
+  }
+
+  String getRemoteAddressAsString() {
+    return remoteAsString;
+  }
+
+  public String getRemoteUser() {
+    return username;
+  }
+
+  /** Unique session number, assigned during connect. */
+  public int getSessionId() {
+    return sessionId;
+  }
+
+  String getUsername() {
+    return username;
+  }
+
+  String getAuthenticationError() {
+    return authError;
+  }
+
+  void authenticationSuccess(String user) {
+    username = user;
+    authError = null;
+  }
+
+  void authenticationError(String user, String error) {
+    username = user;
+    authError = error;
+  }
+
+  /** @return {@code true} if the authentication did not succeed. */
+  boolean isAuthenticationError() {
+    return authError != null;
+  }
+
+  private static String format(final SocketAddress remote) {
+    if (remote instanceof InetSocketAddress) {
+      final InetSocketAddress sa = (InetSocketAddress) remote;
+
+      final InetAddress in = sa.getAddress();
+      if (in != null) {
+        return in.getHostAddress();
+      }
+
+      final String hostName = sa.getHostName();
+      if (hostName != null) {
+        return hostName;
+      }
+    }
+    return remote.toString();
+  }
+}
diff --git a/src/main/java/com/gitblit/transport/ssh/commands/BaseCommand.java b/src/main/java/com/gitblit/transport/ssh/commands/BaseCommand.java
new file mode 100644
index 0000000..fd73ccf
--- /dev/null
+++ b/src/main/java/com/gitblit/transport/ssh/commands/BaseCommand.java
@@ -0,0 +1,430 @@
+// 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.gitblit.transport.ssh.commands;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InterruptedIOException;
+import java.io.OutputStream;
+import java.io.StringWriter;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.sshd.common.SshException;
+import org.apache.sshd.server.Command;
+import org.apache.sshd.server.Environment;
+import org.apache.sshd.server.ExitCallback;
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.CmdLineException;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.gitblit.transport.ssh.AbstractSshCommand;
+import com.gitblit.utils.IdGenerator;
+import com.gitblit.utils.WorkQueue;
+import com.gitblit.utils.cli.CmdLineParser;
+import com.google.common.base.Charsets;
+import com.google.common.util.concurrent.Atomics;
+
+public abstract class BaseCommand extends AbstractSshCommand {
+  private static final Logger log = LoggerFactory
+      .getLogger(BaseCommand.class);
+
+  /** Text of the command line which lead up to invoking this instance. */
+  private String commandName = "";
+
+  /** Unparsed command line options. */
+  private String[] argv;
+
+  /** The task, as scheduled on a worker thread. */
+  private final AtomicReference<Future<?>> task;
+
+  private final WorkQueue.Executor executor;
+
+  public BaseCommand() {
+    task = Atomics.newReference();
+    IdGenerator gen = new IdGenerator();
+    WorkQueue w = new WorkQueue(gen);
+    this.executor = w.getDefaultQueue();
+  }
+
+  public void setInputStream(final InputStream in) {
+    this.in = in;
+  }
+
+  public void setOutputStream(final OutputStream out) {
+    this.out = out;
+  }
+
+  public void setErrorStream(final OutputStream err) {
+    this.err = err;
+  }
+
+  public void setExitCallback(final ExitCallback callback) {
+    this.exit = callback;
+  }
+
+  protected void provideStateTo(final Command cmd) {
+    cmd.setInputStream(in);
+    cmd.setOutputStream(out);
+    cmd.setErrorStream(err);
+    cmd.setExitCallback(exit);
+  }
+
+  protected String getName() {
+    return commandName;
+  }
+
+  void setName(final String prefix) {
+    this.commandName = prefix;
+  }
+
+  public String[] getArguments() {
+    return argv;
+  }
+
+  public void setArguments(final String[] argv) {
+    this.argv = argv;
+  }
+
+  /**
+   * Parses the command line argument, injecting parsed values into fields.
+   * <p>
+   * This method must be explicitly invoked to cause a parse.
+   *
+   * @throws UnloggedFailure if the command line arguments were invalid.
+   * @see Option
+   * @see Argument
+   */
+  protected void parseCommandLine() throws UnloggedFailure {
+    parseCommandLine(this);
+  }
+
+  /**
+   * Parses the command line argument, injecting parsed values into fields.
+   * <p>
+   * This method must be explicitly invoked to cause a parse.
+   *
+   * @param options object whose fields declare Option and Argument annotations
+   *        to describe the parameters of the command. Usually {@code this}.
+   * @throws UnloggedFailure if the command line arguments were invalid.
+   * @see Option
+   * @see Argument
+   */
+  protected void parseCommandLine(Object options) throws UnloggedFailure {
+    final CmdLineParser clp = newCmdLineParser(options);
+    try {
+      clp.parseArgument(argv);
+    } catch (IllegalArgumentException err) {
+      if (!clp.wasHelpRequestedByOption()) {
+        throw new UnloggedFailure(1, "fatal: " + err.getMessage());
+      }
+    } catch (CmdLineException err) {
+      if (!clp.wasHelpRequestedByOption()) {
+        throw new UnloggedFailure(1, "fatal: " + err.getMessage());
+      }
+    }
+
+    if (clp.wasHelpRequestedByOption()) {
+      StringWriter msg = new StringWriter();
+      clp.printDetailedUsage(commandName, msg);
+      msg.write(usage());
+      throw new UnloggedFailure(1, msg.toString());
+    }
+  }
+
+  /** Construct a new parser for this command's received command line. */
+  protected CmdLineParser newCmdLineParser(Object options) {
+    return new CmdLineParser(options);
+  }
+
+  protected String usage() {
+    return "";
+  }
+
+  private final class TaskThunk implements com.gitblit.utils.WorkQueue.CancelableRunnable {
+    private final CommandRunnable thunk;
+    private final String taskName;
+
+    private TaskThunk(final CommandRunnable thunk) {
+      this.thunk = thunk;
+
+      // TODO
+//      StringBuilder m = new StringBuilder("foo");
+//      m.append(context.getCommandLine());
+//      if (userProvider.get().isIdentifiedUser()) {
+//        IdentifiedUser u = (IdentifiedUser) userProvider.get();
+//        m.append(" (").append(u.getAccount().getUserName()).append(")");
+//      }
+      this.taskName = "foo";//m.toString();
+    }
+
+    @Override
+    public void cancel() {
+      synchronized (this) {
+        //final Context old = sshScope.set(context);
+        try {
+          //onExit(/*STATUS_CANCEL*/);
+        } finally {
+          //sshScope.set(old);
+        }
+      }
+    }
+
+    @Override
+    public void run() {
+      synchronized (this) {
+        final Thread thisThread = Thread.currentThread();
+        final String thisName = thisThread.getName();
+        int rc = 0;
+        //final Context old = sshScope.set(context);
+        try {
+          //context.started = TimeUtil.nowMs();
+          thisThread.setName("SSH " + taskName);
+
+          thunk.run();
+
+          out.flush();
+          err.flush();
+        } catch (Throwable e) {
+          try {
+            out.flush();
+          } catch (Throwable e2) {
+          }
+          try {
+            err.flush();
+          } catch (Throwable e2) {
+          }
+          rc = handleError(e);
+        } finally {
+          try {
+            onExit(rc);
+          } finally {
+            thisThread.setName(thisName);
+          }
+        }
+      }
+    }
+
+    @Override
+    public String toString() {
+      return taskName;
+    }
+  }
+
+  /** Runnable function which can throw an exception. */
+  public static interface CommandRunnable {
+    public void run() throws Exception;
+  }
+
+
+  /**
+   * Spawn a function into its own thread.
+   * <p>
+   * Typically this should be invoked within {@link Command#start(Environment)},
+   * such as:
+   *
+   * <pre>
+   * startThread(new Runnable() {
+   *   public void run() {
+   *     runImp();
+   *   }
+   * });
+   * </pre>
+   *
+   * @param thunk the runnable to execute on the thread, performing the
+   *        command's logic.
+   */
+  protected void startThread(final Runnable thunk) {
+    startThread(new CommandRunnable() {
+      @Override
+      public void run() throws Exception {
+        thunk.run();
+      }
+    });
+  }
+
+  /**
+   * Terminate this command and return a result code to the remote client.
+   * <p>
+   * Commands should invoke this at most once. Once invoked, the command may
+   * lose access to request based resources as any callbacks previously
+   * registered with {@link RequestCleanup} will fire.
+   *
+   * @param rc exit code for the remote client.
+   */
+  protected void onExit(final int rc) {
+    exit.onExit(rc);
+//    if (cleanup != null) {
+//      cleanup.run();
+//    }
+  }
+
+  private int handleError(final Throwable e) {
+    if ((e.getClass() == IOException.class
+         && "Pipe closed".equals(e.getMessage()))
+        || //
+        (e.getClass() == SshException.class
+         && "Already closed".equals(e.getMessage()))
+        || //
+        e.getClass() == InterruptedIOException.class) {
+      // This is sshd telling us the client just dropped off while
+      // we were waiting for a read or a write to complete. Either
+      // way its not really a fatal error. Don't log it.
+      //
+      return 127;
+    }
+
+    if (e instanceof UnloggedFailure) {
+    } else {
+      final StringBuilder m = new StringBuilder();
+      m.append("Internal server error");
+//      if (userProvider.get().isIdentifiedUser()) {
+//        final IdentifiedUser u = (IdentifiedUser) userProvider.get();
+//        m.append(" (user ");
+//        m.append(u.getAccount().getUserName());
+//        m.append(" account ");
+//        m.append(u.getAccountId());
+//        m.append(")");
+//      }
+//      m.append(" during ");
+//      m.append(contextProvider.get().getCommandLine());
+      log.error(m.toString(), e);
+    }
+
+    if (e instanceof Failure) {
+      final Failure f = (Failure) e;
+      try {
+        err.write((f.getMessage() + "\n").getBytes(Charsets.UTF_8));
+        err.flush();
+      } catch (IOException e2) {
+      } catch (Throwable e2) {
+        log.warn("Cannot send failure message to client", e2);
+      }
+      return f.exitCode;
+
+    } else {
+      try {
+        err.write("fatal: internal server error\n".getBytes(Charsets.UTF_8));
+        err.flush();
+      } catch (IOException e2) {
+      } catch (Throwable e2) {
+        log.warn("Cannot send internal server error message to client", e2);
+      }
+      return 128;
+    }
+  }
+
+  /**
+   * Spawn a function into its own thread.
+   * <p>
+   * Typically this should be invoked within {@link Command#start(Environment)},
+   * such as:
+   *
+   * <pre>
+   * startThread(new CommandRunnable() {
+   *   public void run() throws Exception {
+   *     runImp();
+   *   }
+   * });
+   * </pre>
+   * <p>
+   * If the function throws an exception, it is translated to a simple message
+   * for the client, a non-zero exit code, and the stack trace is logged.
+   *
+   * @param thunk the runnable to execute on the thread, performing the
+   *        command's logic.
+   */
+  protected void startThread(final CommandRunnable thunk) {
+    final TaskThunk tt = new TaskThunk(thunk);
+      task.set(executor.submit(tt));
+  }
+
+  /** Thrown from {@link CommandRunnable#run()} with client message and code. */
+  public static class Failure extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    final int exitCode;
+
+    /**
+     * Create a new failure.
+     *
+     * @param exitCode exit code to return the client, which indicates the
+     *        failure status of this command. Should be between 1 and 255,
+     *        inclusive.
+     * @param msg message to also send to the client's stderr.
+     */
+    public Failure(final int exitCode, final String msg) {
+      this(exitCode, msg, null);
+    }
+
+    /**
+     * Create a new failure.
+     *
+     * @param exitCode exit code to return the client, which indicates the
+     *        failure status of this command. Should be between 1 and 255,
+     *        inclusive.
+     * @param msg message to also send to the client's stderr.
+     * @param why stack trace to include in the server's log, but is not sent to
+     *        the client's stderr.
+     */
+    public Failure(final int exitCode, final String msg, final Throwable why) {
+      super(msg, why);
+      this.exitCode = exitCode;
+    }
+  }
+
+  /** Thrown from {@link CommandRunnable#run()} with client message and code. */
+  public static class UnloggedFailure extends Failure {
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * Create a new failure.
+     *
+     * @param msg message to also send to the client's stderr.
+     */
+    public UnloggedFailure(final String msg) {
+      this(1, msg);
+    }
+
+    /**
+     * Create a new failure.
+     *
+     * @param exitCode exit code to return the client, which indicates the
+     *        failure status of this command. Should be between 1 and 255,
+     *        inclusive.
+     * @param msg message to also send to the client's stderr.
+     */
+    public UnloggedFailure(final int exitCode, final String msg) {
+      this(exitCode, msg, null);
+    }
+
+    /**
+     * Create a new failure.
+     *
+     * @param exitCode exit code to return the client, which indicates the
+     *        failure status of this command. Should be between 1 and 255,
+     *        inclusive.
+     * @param msg message to also send to the client's stderr.
+     * @param why stack trace to include in the server's log, but is not sent to
+     *        the client's stderr.
+     */
+    public UnloggedFailure(final int exitCode, final String msg,
+        final Throwable why) {
+      super(exitCode, msg, why);
+    }
+  }
+}
diff --git a/src/main/java/com/gitblit/transport/ssh/commands/CreateRepository.java b/src/main/java/com/gitblit/transport/ssh/commands/CreateRepository.java
new file mode 100644
index 0000000..802905f
--- /dev/null
+++ b/src/main/java/com/gitblit/transport/ssh/commands/CreateRepository.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2014 gitblit.com.
+ *
+ * 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.gitblit.transport.ssh.commands;
+
+import org.kohsuke.args4j.Option;
+
+import com.gitblit.transport.ssh.CommandMetaData;
+
+@CommandMetaData(name = "create-repository", description = "Create new GIT repository")
+public class CreateRepository extends SshCommand {
+
+  @Option(name = "--name", aliases = {"-n"}, required = true, metaVar = "NAME", usage = "name of repository to be created")
+  private String name;
+
+  @Option(name = "--description", aliases = {"-d"}, metaVar = "DESCRIPTION", usage = "description of repository")
+  private String repositoryDescription;
+
+  @Override
+  public void run() {
+    stdout.println(String.format("Repository <%s> was created", name));
+  }
+}
diff --git a/src/main/java/com/gitblit/transport/ssh/commands/DispatchCommand.java b/src/main/java/com/gitblit/transport/ssh/commands/DispatchCommand.java
new file mode 100644
index 0000000..672f024
--- /dev/null
+++ b/src/main/java/com/gitblit/transport/ssh/commands/DispatchCommand.java
@@ -0,0 +1,156 @@
+// 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.gitblit.transport.ssh.commands;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import javax.inject.Provider;
+
+import org.apache.sshd.server.Command;
+import org.apache.sshd.server.Environment;
+import org.kohsuke.args4j.Argument;
+
+import com.gitblit.transport.ssh.CommandMetaData;
+import com.gitblit.utils.cli.SubcommandHandler;
+import com.google.common.base.Charsets;
+import com.google.common.base.Strings;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+
+public class DispatchCommand extends BaseCommand {
+
+  @Argument(index = 0, required = false, metaVar = "COMMAND", handler = SubcommandHandler.class)
+  private String commandName;
+
+  @Argument(index = 1, multiValued = true, metaVar = "ARG")
+  private List<String> args = new ArrayList<String>();
+
+  private Set<Provider<Command>> commands;
+  private Map<String, Provider<Command>> map;
+
+  public DispatchCommand() {}
+
+  public DispatchCommand(Map<String, Provider<Command>> map) {
+    this.map = map;
+  }
+
+  public void setMap(Map<String, Provider<Command>> m) {
+    map = m;
+  }
+
+  public DispatchCommand(Set<Provider<Command>> commands) {
+    this.commands = commands;
+  }
+
+  private Map<String, Provider<Command>> getMap() {
+    if (map == null) {
+      map = Maps.newHashMapWithExpectedSize(commands.size());
+      for (Provider<Command> cmd : commands) {
+        CommandMetaData meta = cmd.get().getClass().getAnnotation(CommandMetaData.class);
+        map.put(meta.name(), cmd);
+      }
+    }
+    return map;
+  }
+
+  @Override
+  public void start(Environment env) throws IOException {
+    try {
+      parseCommandLine();
+      if (Strings.isNullOrEmpty(commandName)) {
+        StringWriter msg = new StringWriter();
+        msg.write(usage());
+        throw new UnloggedFailure(1, msg.toString());
+      }
+
+      final Provider<Command> p = getMap().get(commandName);
+      if (p == null) {
+        String msg =
+            (getName().isEmpty() ? "Gitblit" : getName()) + ": "
+                + commandName + ": not found";
+        throw new UnloggedFailure(1, msg);
+      }
+
+      final Command cmd = p.get();
+      if (cmd instanceof BaseCommand) {
+        BaseCommand bc = (BaseCommand) cmd;
+        if (getName().isEmpty()) {
+          bc.setName(commandName);
+        } else {
+          bc.setName(getName() + " " + commandName);
+        }
+        bc.setArguments(args.toArray(new String[args.size()]));
+      } else if (!args.isEmpty()) {
+        throw new UnloggedFailure(1, commandName + " does not take arguments");
+      }
+
+      provideStateTo(cmd);
+      //atomicCmd.set(cmd);
+      cmd.start(env);
+
+    } catch (UnloggedFailure e) {
+      String msg = e.getMessage();
+      if (!msg.endsWith("\n")) {
+        msg += "\n";
+      }
+      err.write(msg.getBytes(Charsets.UTF_8));
+      err.flush();
+      exit.onExit(e.exitCode);
+    }
+  }
+
+  protected String usage() {
+    final StringBuilder usage = new StringBuilder();
+    usage.append("Available commands");
+    if (!getName().isEmpty()) {
+      usage.append(" of ");
+      usage.append(getName());
+    }
+    usage.append(" are:\n");
+    usage.append("\n");
+
+    int maxLength = -1;
+    Map<String, Provider<Command>> m = getMap();
+    for (String name : m.keySet()) {
+      maxLength = Math.max(maxLength, name.length());
+    }
+    String format = "%-" + maxLength + "s   %s";
+    for (String name : Sets.newTreeSet(m.keySet())) {
+      final Provider<Command> p = m.get(name);
+      usage.append("   ");
+      CommandMetaData meta = p.get().getClass().getAnnotation(CommandMetaData.class);
+      if (meta != null) {
+        usage.append(String.format(format, name,
+            Strings.nullToEmpty(meta.description())));
+      }
+      usage.append("\n");
+    }
+    usage.append("\n");
+
+    usage.append("See '");
+    if (getName().indexOf(' ') < 0) {
+      usage.append(getName());
+      usage.append(' ');
+    }
+    usage.append("COMMAND --help' for more information.\n");
+    usage.append("\n");
+    return usage.toString();
+  }
+}
diff --git a/src/main/java/com/gitblit/transport/ssh/commands/SshCommand.java b/src/main/java/com/gitblit/transport/ssh/commands/SshCommand.java
new file mode 100644
index 0000000..44618f3
--- /dev/null
+++ b/src/main/java/com/gitblit/transport/ssh/commands/SshCommand.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2012 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.gitblit.transport.ssh.commands;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+
+import org.apache.sshd.server.Environment;
+
+public abstract class SshCommand extends BaseCommand {
+  protected PrintWriter stdout;
+  protected PrintWriter stderr;
+
+  @Override
+  public void start(Environment env) throws IOException {
+    startThread(new CommandRunnable() {
+      @Override
+      public void run() throws Exception {
+        parseCommandLine();
+        stdout = toPrintWriter(out);
+        stderr = toPrintWriter(err);
+        try {
+          SshCommand.this.run();
+        } finally {
+          stdout.flush();
+          stderr.flush();
+        }
+      }
+    });
+  }
+
+  protected abstract void run() throws UnloggedFailure, Failure, Exception;
+}
diff --git a/src/main/java/com/gitblit/transport/ssh/commands/VersionCommand.java b/src/main/java/com/gitblit/transport/ssh/commands/VersionCommand.java
new file mode 100644
index 0000000..baae6a2
--- /dev/null
+++ b/src/main/java/com/gitblit/transport/ssh/commands/VersionCommand.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2014 gitblit.com.
+ *
+ * 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.gitblit.transport.ssh.commands;
+
+import org.kohsuke.args4j.Option;
+
+import com.gitblit.Constants;
+import com.gitblit.transport.ssh.CommandMetaData;
+
+@CommandMetaData(name="version", description = "Print Gitblit version")
+public class VersionCommand extends SshCommand {
+
+  @Option(name = "--verbose", aliases = {"-v"},  metaVar = "VERBOSE", usage = "Print verbose versions")
+  private boolean verbose;
+
+  @Override
+  public void run() {
+    stdout.println(String.format("Version: %s", Constants.getGitBlitVersion(),
+        verbose));
+  }
+}
diff --git a/src/main/java/com/gitblit/utils/IdGenerator.java b/src/main/java/com/gitblit/utils/IdGenerator.java
new file mode 100644
index 0000000..d2c1cb2
--- /dev/null
+++ b/src/main/java/com/gitblit/utils/IdGenerator.java
@@ -0,0 +1,91 @@
+// 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.gitblit.utils;
+
+import java.util.Random;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import javax.inject.Inject;
+
+/** Simple class to produce 4 billion keys randomly distributed. */
+public class IdGenerator {
+  /** Format an id created by this class as a hex string. */
+  public static String format(int id) {
+    final char[] r = new char[8];
+    for (int p = 7; 0 <= p; p--) {
+      final int h = id & 0xf;
+      r[p] = h < 10 ? (char) ('0' + h) : (char) ('a' + (h - 10));
+      id >>= 4;
+    }
+    return new String(r);
+  }
+
+  private final AtomicInteger gen;
+
+  @Inject
+  public IdGenerator() {
+    gen = new AtomicInteger(new Random().nextInt());
+  }
+
+  /** Produce the next identifier. */
+  public int next() {
+    return mix(gen.getAndIncrement());
+  }
+
+  private static final int salt = 0x9e3779b9;
+
+  static int mix(int in) {
+    return mix(salt, in);
+  }
+
+  /** A very simple bit permutation to mask a simple incrementer. */
+  public static int mix(final int salt, final int in) {
+    short v0 = hi16(in);
+    short v1 = lo16(in);
+    v0 += ((v1 << 2) + 0 ^ v1) + (salt ^ (v1 >>> 3)) + 1;
+    v1 += ((v0 << 2) + 2 ^ v0) + (salt ^ (v0 >>> 3)) + 3;
+    return result(v0, v1);
+  }
+
+  /* For testing only. */
+  static int unmix(final int in) {
+    short v0 = hi16(in);
+    short v1 = lo16(in);
+    v1 -= ((v0 << 2) + 2 ^ v0) + (salt ^ (v0 >>> 3)) + 3;
+    v0 -= ((v1 << 2) + 0 ^ v1) + (salt ^ (v1 >>> 3)) + 1;
+    return result(v0, v1);
+  }
+
+  private static short hi16(final int in) {
+    return (short) ( //
+    ((in >>> 24 & 0xff)) | //
+    ((in >>> 16 & 0xff) << 8) //
+    );
+  }
+
+  private static short lo16(final int in) {
+    return (short) ( //
+    ((in >>> 8 & 0xff)) | //
+    ((in & 0xff) << 8) //
+    );
+  }
+
+  private static int result(final short v0, final short v1) {
+    return ((v0 & 0xff) << 24) | //
+        (((v0 >>> 8) & 0xff) << 16) | //
+        ((v1 & 0xff) << 8) | //
+        ((v1 >>> 8) & 0xff);
+  }
+}
diff --git a/src/main/java/com/gitblit/utils/TaskInfoFactory.java b/src/main/java/com/gitblit/utils/TaskInfoFactory.java
new file mode 100644
index 0000000..111af27
--- /dev/null
+++ b/src/main/java/com/gitblit/utils/TaskInfoFactory.java
@@ -0,0 +1,19 @@
+// Copyright (C) 2013 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.gitblit.utils;
+
+public interface TaskInfoFactory<T> {
+  T getTaskInfo(WorkQueue.Task<?> task);
+}
diff --git a/src/main/java/com/gitblit/utils/WorkQueue.java b/src/main/java/com/gitblit/utils/WorkQueue.java
new file mode 100644
index 0000000..778e754
--- /dev/null
+++ b/src/main/java/com/gitblit/utils/WorkQueue.java
@@ -0,0 +1,340 @@
+// 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.gitblit.utils;
+
+import com.google.common.collect.Lists;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.lang.Thread.UncaughtExceptionHandler;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.Delayed;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.RunnableScheduledFuture;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import javax.inject.Inject;
+
+/** Delayed execution of tasks using a background thread pool. */
+public class WorkQueue {
+  private static final Logger log = LoggerFactory.getLogger(WorkQueue.class);
+  private static final UncaughtExceptionHandler LOG_UNCAUGHT_EXCEPTION =
+      new UncaughtExceptionHandler() {
+        @Override
+        public void uncaughtException(Thread t, Throwable e) {
+          log.error("WorkQueue thread " + t.getName() + " threw exception", e);
+        }
+      };
+
+  private Executor defaultQueue;
+  private final IdGenerator idGenerator;
+  private final CopyOnWriteArrayList<Executor> queues;
+
+  @Inject
+  public WorkQueue(final IdGenerator idGenerator) {
+    this.idGenerator = idGenerator;
+    this.queues = new CopyOnWriteArrayList<Executor>();
+  }
+
+  /** Get the default work queue, for miscellaneous tasks. */
+  public synchronized Executor getDefaultQueue() {
+    if (defaultQueue == null) {
+      defaultQueue = createQueue(1, "WorkQueue");
+    }
+    return defaultQueue;
+  }
+
+  /** Create a new executor queue with one thread. */
+  public Executor createQueue(final int poolsize, final String prefix) {
+    final Executor r = new Executor(poolsize, prefix);
+    r.setContinueExistingPeriodicTasksAfterShutdownPolicy(false);
+    r.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
+    queues.add(r);
+    return r;
+  }
+
+  /** Get all of the tasks currently scheduled in any work queue. */
+  public List<Task<?>> getTasks() {
+    final List<Task<?>> r = new ArrayList<Task<?>>();
+    for (final Executor e : queues) {
+      e.addAllTo(r);
+    }
+    return r;
+  }
+
+  public <T> List<T> getTaskInfos(TaskInfoFactory<T> factory) {
+    List<T> taskInfos = Lists.newArrayList();
+    for (Executor exe : queues) {
+      for (Task<?> task : exe.getTasks()) {
+        taskInfos.add(factory.getTaskInfo(task));
+      }
+    }
+    return taskInfos;
+  }
+
+  /** Locate a task by its unique id, null if no task matches. */
+  public Task<?> getTask(final int id) {
+    Task<?> result = null;
+    for (final Executor e : queues) {
+      final Task<?> t = e.getTask(id);
+      if (t != null) {
+        if (result != null) {
+          // Don't return the task if we have a duplicate. Lie instead.
+          return null;
+        } else {
+          result = t;
+        }
+      }
+    }
+    return result;
+  }
+
+  public void stop() {
+    for (final Executor p : queues) {
+      p.shutdown();
+      boolean isTerminated;
+      do {
+        try {
+          isTerminated = p.awaitTermination(10, TimeUnit.SECONDS);
+        } catch (InterruptedException ie) {
+          isTerminated = false;
+        }
+      } while (!isTerminated);
+    }
+    queues.clear();
+  }
+
+  /** An isolated queue. */
+  public class Executor extends ScheduledThreadPoolExecutor {
+    private final ConcurrentHashMap<Integer, Task<?>> all;
+
+    Executor(final int corePoolSize, final String prefix) {
+      super(corePoolSize, new ThreadFactory() {
+        private final ThreadFactory parent = Executors.defaultThreadFactory();
+        private final AtomicInteger tid = new AtomicInteger(1);
+
+        @Override
+        public Thread newThread(final Runnable task) {
+          final Thread t = parent.newThread(task);
+          t.setName(prefix + "-" + tid.getAndIncrement());
+          t.setUncaughtExceptionHandler(LOG_UNCAUGHT_EXCEPTION);
+          return t;
+        }
+      });
+
+      all = new ConcurrentHashMap<Integer, Task<?>>( //
+          corePoolSize << 1, // table size
+          0.75f, // load factor
+          corePoolSize + 4 // concurrency level
+          );
+    }
+
+    public void unregisterWorkQueue() {
+      queues.remove(this);
+    }
+
+    @Override
+    protected <V> RunnableScheduledFuture<V> decorateTask(
+        final Runnable runnable, RunnableScheduledFuture<V> r) {
+      r = super.decorateTask(runnable, r);
+      for (;;) {
+        final int id = idGenerator.next();
+
+        Task<V> task;
+        task = new Task<V>(runnable, r, this, id);
+
+        if (all.putIfAbsent(task.getTaskId(), task) == null) {
+          return task;
+        }
+      }
+    }
+
+    @Override
+    protected <V> RunnableScheduledFuture<V> decorateTask(
+        final Callable<V> callable, final RunnableScheduledFuture<V> task) {
+      throw new UnsupportedOperationException("Callable not implemented");
+    }
+
+    void remove(final Task<?> task) {
+      all.remove(task.getTaskId(), task);
+    }
+
+    Task<?> getTask(final int id) {
+      return all.get(id);
+    }
+
+    void addAllTo(final List<Task<?>> list) {
+      list.addAll(all.values()); // iterator is thread safe
+    }
+
+    Collection<Task<?>> getTasks() {
+      return all.values();
+    }
+  }
+
+  /** Runnable needing to know it was canceled. */
+  public interface CancelableRunnable extends Runnable {
+    /** Notifies the runnable it was canceled. */
+    public void cancel();
+  }
+
+  /** A wrapper around a scheduled Runnable, as maintained in the queue. */
+  public static class Task<V> implements RunnableScheduledFuture<V> {
+    /**
+     * Summarized status of a single task.
+     * <p>
+     * Tasks have the following state flow:
+     * <ol>
+     * <li>{@link #SLEEPING}: if scheduled with a non-zero delay.</li>
+     * <li>{@link #READY}: waiting for an available worker thread.</li>
+     * <li>{@link #RUNNING}: actively executing on a worker thread.</li>
+     * <li>{@link #DONE}: finished executing, if not periodic.</li>
+     * </ol>
+     */
+    public static enum State {
+      // Ordered like this so ordinal matches the order we would
+      // prefer to see tasks sorted in: done before running,
+      // running before ready, ready before sleeping.
+      //
+      DONE, CANCELLED, RUNNING, READY, SLEEPING, OTHER
+    }
+
+    private final Runnable runnable;
+    private final RunnableScheduledFuture<V> task;
+    private final Executor executor;
+    private final int taskId;
+    private final AtomicBoolean running;
+    private final Date startTime;
+
+    Task(Runnable runnable, RunnableScheduledFuture<V> task, Executor executor,
+        int taskId) {
+      this.runnable = runnable;
+      this.task = task;
+      this.executor = executor;
+      this.taskId = taskId;
+      this.running = new AtomicBoolean();
+      this.startTime = new Date();
+    }
+
+    public int getTaskId() {
+      return taskId;
+    }
+
+    public State getState() {
+      if (isCancelled()) {
+        return State.CANCELLED;
+      } else if (isDone() && !isPeriodic()) {
+        return State.DONE;
+      } else if (running.get()) {
+        return State.RUNNING;
+      }
+
+      final long delay = getDelay(TimeUnit.MILLISECONDS);
+      if (delay <= 0) {
+        return State.READY;
+      } else if (0 < delay) {
+        return State.SLEEPING;
+      }
+
+      return State.OTHER;
+    }
+
+    public Date getStartTime() {
+      return startTime;
+    }
+
+    public boolean cancel(boolean mayInterruptIfRunning) {
+      if (task.cancel(mayInterruptIfRunning)) {
+        // Tiny abuse of running: if the task needs to know it was
+        // canceled (to clean up resources) and it hasn't started
+        // yet the task's run method won't execute. So we tag it
+        // as running and allow it to clean up. This ensures we do
+        // not invoke cancel twice.
+        //
+        if (runnable instanceof CancelableRunnable
+            && running.compareAndSet(false, true)) {
+          ((CancelableRunnable) runnable).cancel();
+        }
+        executor.remove(this);
+        executor.purge();
+        return true;
+
+      } else {
+        return false;
+      }
+    }
+
+    public int compareTo(Delayed o) {
+      return task.compareTo(o);
+    }
+
+    public V get() throws InterruptedException, ExecutionException {
+      return task.get();
+    }
+
+    public V get(long timeout, TimeUnit unit) throws InterruptedException,
+        ExecutionException, TimeoutException {
+      return task.get(timeout, unit);
+    }
+
+    public long getDelay(TimeUnit unit) {
+      return task.getDelay(unit);
+    }
+
+    public boolean isCancelled() {
+      return task.isCancelled();
+    }
+
+    public boolean isDone() {
+      return task.isDone();
+    }
+
+    public boolean isPeriodic() {
+      return task.isPeriodic();
+    }
+
+    public void run() {
+      if (running.compareAndSet(false, true)) {
+        try {
+          task.run();
+        } finally {
+          if (isPeriodic()) {
+            running.set(false);
+          } else {
+            executor.remove(this);
+          }
+        }
+      }
+    }
+
+    @Override
+    public String toString() {
+      return runnable.toString();
+    }
+  }
+}
diff --git a/src/main/java/com/gitblit/utils/cli/CmdLineParser.java b/src/main/java/com/gitblit/utils/cli/CmdLineParser.java
new file mode 100644
index 0000000..def76df
--- /dev/null
+++ b/src/main/java/com/gitblit/utils/cli/CmdLineParser.java
@@ -0,0 +1,440 @@
+/*
+ * Copyright (C) 2008, Shawn O. Pearce <spearce@spearce.org>
+ *
+ * (Taken from JGit org.eclipse.jgit.pgm.opt.CmdLineParser.)
+ *
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * - Redistributions of source code must retain the above copyright notice, this
+ * list of conditions and the following disclaimer.
+ *
+ * - Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ *
+ * - Neither the name of the Git Development Community nor the names of its
+ * contributors may be used to endorse or promote products derived from this
+ * software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+ * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+ * POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package com.gitblit.utils.cli;
+
+import java.io.StringWriter;
+import java.io.Writer;
+import java.lang.annotation.Annotation;
+import java.lang.reflect.AnnotatedElement;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.ResourceBundle;
+
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.CmdLineException;
+import org.kohsuke.args4j.IllegalAnnotationError;
+import org.kohsuke.args4j.NamedOptionDef;
+import org.kohsuke.args4j.Option;
+import org.kohsuke.args4j.OptionDef;
+import org.kohsuke.args4j.spi.BooleanOptionHandler;
+import org.kohsuke.args4j.spi.EnumOptionHandler;
+import org.kohsuke.args4j.spi.FieldSetter;
+import org.kohsuke.args4j.spi.OptionHandler;
+import org.kohsuke.args4j.spi.Setter;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.LinkedHashMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Multimap;
+
+/**
+ * Extended command line parser which handles --foo=value arguments.
+ * <p>
+ * The args4j package does not natively handle --foo=value and instead prefers
+ * to see --foo value on the command line. Many users are used to the GNU style
+ * --foo=value long option, so we convert from the GNU style format to the
+ * args4j style format prior to invoking args4j for parsing.
+ */
+public class CmdLineParser {
+  public interface Factory {
+    CmdLineParser create(Object bean);
+  }
+
+  private final MyParser parser;
+
+  @SuppressWarnings("rawtypes")
+  private Map<String, OptionHandler> options;
+
+  /**
+   * Creates a new command line owner that parses arguments/options and set them
+   * into the given object.
+   *
+   * @param bean instance of a class annotated by
+   *        {@link org.kohsuke.args4j.Option} and
+   *        {@link org.kohsuke.args4j.Argument}. this object will receive
+   *        values.
+   *
+   * @throws IllegalAnnotationError if the option bean class is using args4j
+   *         annotations incorrectly.
+   */
+  public CmdLineParser(Object bean)
+      throws IllegalAnnotationError {
+    this.parser = new MyParser(bean);
+  }
+
+  public void addArgument(Setter<?> setter, Argument a) {
+    parser.addArgument(setter, a);
+  }
+
+  public void addOption(Setter<?> setter, Option o) {
+    parser.addOption(setter, o);
+  }
+
+  public void printSingleLineUsage(Writer w, ResourceBundle rb) {
+    parser.printSingleLineUsage(w, rb);
+  }
+
+  public void printUsage(Writer out, ResourceBundle rb) {
+    parser.printUsage(out, rb);
+  }
+
+  public void printDetailedUsage(String name, StringWriter out) {
+    out.write(name);
+    printSingleLineUsage(out, null);
+    out.write('\n');
+    out.write('\n');
+    printUsage(out, null);
+    out.write('\n');
+  }
+
+  public void printQueryStringUsage(String name, StringWriter out) {
+    out.write(name);
+
+    char next = '?';
+    List<NamedOptionDef> booleans = new ArrayList<NamedOptionDef>();
+    for (@SuppressWarnings("rawtypes") OptionHandler handler : parser.options) {
+      if (handler.option instanceof NamedOptionDef) {
+        NamedOptionDef n = (NamedOptionDef) handler.option;
+
+        if (handler instanceof BooleanOptionHandler) {
+          booleans.add(n);
+          continue;
+        }
+
+        if (!n.required()) {
+          out.write('[');
+        }
+        out.write(next);
+        next = '&';
+        if (n.name().startsWith("--")) {
+          out.write(n.name().substring(2));
+        } else if (n.name().startsWith("-")) {
+          out.write(n.name().substring(1));
+        } else {
+          out.write(n.name());
+        }
+        out.write('=');
+
+        out.write(metaVar(handler, n));
+        if (!n.required()) {
+          out.write(']');
+        }
+        if (n.isMultiValued()) {
+          out.write('*');
+        }
+      }
+    }
+    for (NamedOptionDef n : booleans) {
+      if (!n.required()) {
+        out.write('[');
+      }
+      out.write(next);
+      next = '&';
+      if (n.name().startsWith("--")) {
+        out.write(n.name().substring(2));
+      } else if (n.name().startsWith("-")) {
+        out.write(n.name().substring(1));
+      } else {
+        out.write(n.name());
+      }
+      if (!n.required()) {
+        out.write(']');
+      }
+    }
+  }
+
+  private static String metaVar(OptionHandler<?> handler, NamedOptionDef n) {
+    String var = n.metaVar();
+    if (Strings.isNullOrEmpty(var)) {
+      var = handler.getDefaultMetaVariable();
+      if (handler instanceof EnumOptionHandler) {
+        var = var.substring(1, var.length() - 1).replace(" ", "");
+      }
+    }
+    return var;
+  }
+
+  public boolean wasHelpRequestedByOption() {
+    return parser.help.value;
+  }
+
+  public void parseArgument(final String... args) throws CmdLineException {
+    List<String> tmp = Lists.newArrayListWithCapacity(args.length);
+    for (int argi = 0; argi < args.length; argi++) {
+      final String str = args[argi];
+      if (str.equals("--")) {
+        while (argi < args.length)
+          tmp.add(args[argi++]);
+        break;
+      }
+
+      if (str.startsWith("--")) {
+        final int eq = str.indexOf('=');
+        if (eq > 0) {
+          tmp.add(str.substring(0, eq));
+          tmp.add(str.substring(eq + 1));
+          continue;
+        }
+      }
+
+      tmp.add(str);
+    }
+    parser.parseArgument(tmp.toArray(new String[tmp.size()]));
+  }
+
+  public void parseOptionMap(Map<String, String[]> parameters)
+      throws CmdLineException {
+    Multimap<String, String> map = LinkedHashMultimap.create();
+    for (Map.Entry<String, String[]> ent : parameters.entrySet()) {
+      for (String val : ent.getValue()) {
+        map.put(ent.getKey(), val);
+      }
+    }
+    parseOptionMap(map);
+  }
+
+  public void parseOptionMap(Multimap<String, String> params)
+      throws CmdLineException {
+    List<String> tmp = Lists.newArrayListWithCapacity(2 * params.size());
+    for (final String key : params.keySet()) {
+      String name = makeOption(key);
+
+      if (isBoolean(name)) {
+        boolean on = false;
+        for (String value : params.get(key)) {
+          on = toBoolean(key, value);
+        }
+        if (on) {
+          tmp.add(name);
+        }
+      } else {
+        for (String value : params.get(key)) {
+          tmp.add(name);
+          tmp.add(value);
+        }
+      }
+    }
+    parser.parseArgument(tmp.toArray(new String[tmp.size()]));
+  }
+
+  public boolean isBoolean(String name) {
+    return findHandler(makeOption(name)) instanceof BooleanOptionHandler;
+  }
+
+  private String makeOption(String name) {
+    if (!name.startsWith("-")) {
+      if (name.length() == 1) {
+        name = "-" + name;
+      } else {
+        name = "--" + name;
+      }
+    }
+    return name;
+  }
+
+  @SuppressWarnings("rawtypes")
+  private OptionHandler findHandler(String name) {
+    if (options == null) {
+      options = index(parser.options);
+    }
+    return options.get(name);
+  }
+
+  @SuppressWarnings("rawtypes")
+  private static Map<String, OptionHandler> index(List<OptionHandler> in) {
+    Map<String, OptionHandler> m = Maps.newHashMap();
+    for (OptionHandler handler : in) {
+      if (handler.option instanceof NamedOptionDef) {
+        NamedOptionDef def = (NamedOptionDef) handler.option;
+        if (!def.isArgument()) {
+          m.put(def.name(), handler);
+          for (String alias : def.aliases()) {
+            m.put(alias, handler);
+          }
+        }
+      }
+    }
+    return m;
+  }
+
+  private boolean toBoolean(String name, String value) throws CmdLineException {
+    if ("true".equals(value) || "t".equals(value)
+        || "yes".equals(value) || "y".equals(value)
+        || "on".equals(value)
+        || "1".equals(value)
+        || value == null || "".equals(value)) {
+      return true;
+    }
+
+    if ("false".equals(value) || "f".equals(value)
+        || "no".equals(value) || "n".equals(value)
+        || "off".equals(value)
+        || "0".equals(value)) {
+      return false;
+    }
+
+    throw new CmdLineException(parser, String.format(
+        "invalid boolean \"%s=%s\"", name, value));
+  }
+
+  private class MyParser extends org.kohsuke.args4j.CmdLineParser {
+    @SuppressWarnings("rawtypes")
+    private List<OptionHandler> options;
+    private HelpOption help;
+
+    MyParser(final Object bean) {
+      super(bean);
+      ensureOptionsInitialized();
+    }
+
+    @SuppressWarnings({"unchecked", "rawtypes"})
+    @Override
+    protected OptionHandler createOptionHandler(final OptionDef option,
+        final Setter setter) {
+      if (isHandlerSpecified(option) || isEnum(setter) || isPrimitive(setter)) {
+        return add(super.createOptionHandler(option, setter));
+      }
+
+//      OptionHandlerFactory<?> factory = handlers.get(setter.getType());
+//      if (factory != null) {
+//        return factory.create(this, option, setter);
+//      }
+      return add(super.createOptionHandler(option, setter));
+    }
+
+    @SuppressWarnings("rawtypes")
+    private OptionHandler add(OptionHandler handler) {
+      ensureOptionsInitialized();
+      options.add(handler);
+      return handler;
+    }
+
+    private void ensureOptionsInitialized() {
+      if (options == null) {
+        help = new HelpOption();
+        options = Lists.newArrayList();
+        addOption(help, help);
+      }
+    }
+
+    private boolean isHandlerSpecified(final OptionDef option) {
+      return option.handler() != OptionHandler.class;
+    }
+
+    private <T> boolean isEnum(Setter<T> setter) {
+      return Enum.class.isAssignableFrom(setter.getType());
+    }
+
+    private <T> boolean isPrimitive(Setter<T> setter) {
+      return setter.getType().isPrimitive();
+    }
+  }
+
+  private static class HelpOption implements Option, Setter<Boolean> {
+    private boolean value;
+
+    @Override
+    public String name() {
+      return "--help";
+    }
+
+    @Override
+    public String[] aliases() {
+      return new String[] {"-h"};
+    }
+
+    @Override
+    public String[] depends() {
+      return new String[] {};
+    }
+
+    @Override
+    public boolean hidden() {
+      return false;
+    }
+
+    @Override
+    public String usage() {
+      return "display this help text";
+    }
+
+    @Override
+    public void addValue(Boolean val) {
+      value = val;
+    }
+
+    @Override
+    public Class<? extends OptionHandler<Boolean>> handler() {
+      return BooleanOptionHandler.class;
+    }
+
+    @Override
+    public String metaVar() {
+      return "";
+    }
+
+    @Override
+    public boolean required() {
+      return false;
+    }
+
+    @Override
+    public Class<? extends Annotation> annotationType() {
+      return Option.class;
+    }
+
+    @Override
+    public FieldSetter asFieldSetter() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public AnnotatedElement asAnnotatedElement() {
+      throw new UnsupportedOperationException();
+    }
+
+    @Override
+    public Class<Boolean> getType() {
+      return Boolean.class;
+    }
+
+    @Override
+    public boolean isMultiValued() {
+      return false;
+    }
+  }
+}
diff --git a/src/main/java/com/gitblit/utils/cli/SubcommandHandler.java b/src/main/java/com/gitblit/utils/cli/SubcommandHandler.java
new file mode 100644
index 0000000..b1ace32
--- /dev/null
+++ b/src/main/java/com/gitblit/utils/cli/SubcommandHandler.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2010 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.gitblit.utils.cli;
+
+import org.kohsuke.args4j.CmdLineException;
+import org.kohsuke.args4j.CmdLineParser;
+import org.kohsuke.args4j.OptionDef;
+import org.kohsuke.args4j.spi.OptionHandler;
+import org.kohsuke.args4j.spi.Parameters;
+import org.kohsuke.args4j.spi.Setter;
+
+public class SubcommandHandler extends OptionHandler<String> {
+
+  public SubcommandHandler(CmdLineParser parser,
+      OptionDef option, Setter<String> setter) {
+    super(parser, option, setter);
+  }
+
+  @Override
+  public final int parseArguments(final Parameters params)
+      throws CmdLineException {
+    setter.addValue(params.getParameter(0));
+    owner.stopOptionParsing();
+    return 1;
+  }
+
+  @Override
+  public final String getDefaultMetaVariable() {
+    return "COMMAND";
+  }
+}
diff --git a/src/main/java/log4j.properties b/src/main/java/log4j.properties
index c6b5d8c..115dcd0 100644
--- a/src/main/java/log4j.properties
+++ b/src/main/java/log4j.properties
@@ -25,6 +25,7 @@
 #log4j.logger.net=INFO
 
 #log4j.logger.com.gitblit=DEBUG
+log4j.logger.org.apache.sshd=ERROR
 
 log4j.logger.org.apache.wicket=INFO
 log4j.logger.org.apache.wicket.RequestListenerInterface=WARN

--
Gitblit v1.9.1